First move with Move!
In this article, we are going to take our first step with the Move Programming Language. Learn about some intricate details of how it works. Learn a bit about how to write Move and understand its syntax and publish our first Move Module on the Aptos Chain. We will setup our local CLI to achieve all this.
Move is a programming language for Web3 that emphasizes on Scarcity and Access control. Structs cannot be accidentally duplicated or dropped unless explicitly defined, this by default enforces scarcity. Access Control comes from the notion of accounts as well as access privileges that have been enforced on the modules. A module unless it has a public constructor or public setters and getters, it cant be accessed from elsewhere.
Here we will write a Move program that will set a message on the Aptos Chain, thus altering the state of the chain and then read it back. Alright, lets jump in now!
In this article, we will see:
- Setting up the env
- What is a Module?
- Use Statements
- Resources and Events
- Drop and Store
- Constants
- Functions
- Mutable Functions
- Test Functions
- Unit Tests
- Compile, Test and Publish
- Interact with the Module
- References
Setting up the env
Let’s start by cloning the codebase that we are going to work with first. Please note that this repository was created for the purpose of this tutorial with some modifications from [1].
Now, your directory structure should look something like this.
.
├── Move.toml
└── sources
├── hello_blockchain.move
└── hello_blockchain_test.move
You would notice an extra directory called framework
. That directory just contains all the dependencies that are required for the project.
Before moving forward we need aptos cli installed on our system. You can head over to the installation site for all the different ways to install it, which can be found here. If you are on linux/WSL the following should work fine. Only prerequisite is that you should have python3 installed in your system. Which comes by default in most OS.
curl -fsSL "https://aptos.dev/scripts/install_cli.py" | python3
That should be it. After that you should be able to do this.
$ aptos -V
aptos 2.0.2
My version is 2.0.2
. Earlier versions upto 1.4 or above should work fine for this tutorial.
What is a Module?
Now, we will understand what each file here is doing. Let’s start with hello_move.move
file. This is the source file which contains the logic that how this program will behave once it has been published on the chain. Lets take a look into this file.
module hello_move::message {
The first line of the file looks somewhat like this. Here we are defining a module
in Move. Lets start with what is a module in Move.
Modules are libraries that define struct types along with functions that operate on these types. Struct types define the schema of Move’s global storage, and module functions define the rules for updating storage. Modules themselves are also stored in global storage.
A module
in Move has the following syntax.
module <address>::<identifier> {
(<use> | <friend> | <type> | <function> | <constant>)*
}
address
here can be both named or literal address. Here comparing the syntax and code we find that hello_move
in the code is a named address. And message
is an identifier.
This named address, is defined in the Move.toml
file, which we will explore later. Alright, so this module message
will be published under the named address hello_move
.
Use Statements
Next we see some use
statements.
use std::error;
use std::signer;
use std::string;
use aptos_framework::account;
use aptos_framework::event;
use
has the following syntax
use <address>::<module name>;
use <address>::<module name> as <module alias name>;
These are declaring some public modules that one plans to use through out that can help us get things done faster. Looking closer, we are importing the error
, signer
and string
libraries from the std
account and account
and event
from the aptos_framework
. use std::error
would introduce an alias error
, and that would mean anywhere we could use error
instead of using std::error
. Similarly, to importing libraries in other languages, we can use an as E
statement to change the alias from error
to E
.
Resources and Events
struct MessageHolder has key {
message: string::String,
message_change_events: event::EventHandle<MessageChangeEvent>,
}
This block of code defines a resource
. Lets understand a bit more of what that means. Here we have defined a struct
that has the key
ability. The key
ability allows a struct
to be used as a storage identifier. Its stored at the top-level and acts as storage. When a struct
has the key
ability it turns it into a resource
.
Resource
is stored under the account and it exists only when assigned to an account and can be accessed only through that account.
Here, we are defining a resource
called MessageHolder
, which contains 2 objects:
message
of typestring::String
: which would store the message, andmessage_change_events
of typeevent::EventHandle<MessageChangeEvent>
: which stores theevent
when the resource is updated.
In short, event
s are emitted during the execution of a transaction. Modules can define its own event
and choose when to emit it upon the execution of the module. Here, we are going to emit an event
every time the resource is updated. And message_change_events
will store such events.
Drop and store
struct MessageChangeEvent has drop, store {
from_message: string::String,
to_message: string::String,
}
Here we are defining a struct
with the abilities drop
and store
.
store
: Astruct
needs astore
ability as its been stored inside anotherstruct
. If you see, theMessageHolder
struct, it containsMessageChangeEvent
.drop
: value of thisstruct
can be dropped by the end of the execution of the module.
And looking into the struct, we have 2 variables
from_message
of typestring::String
, andto_message
of typestring::String
So when an event is emitted, it would contain the old message “from” which it is being updated and the new message “to” which it is being updated.
Constants
const ENO_MESSAGE: u64 = 0;
That line is self explanatory. We are defining a constant using the keyword const
named ENO_MESSAGE
of type u64
and it has a value of 0
.
Functions
#[view]
public fun get_message(addr: address): string::String acquires MessageHolder {
assert!(exists<MessageHolder>(addr), error::not_found(ENO_MESSAGE));
borrow_global<MessageHolder>(addr).message
}
The first line here #[view]
declares this as a view function. Such functions do not modify the state of the blockchain and can be called using an API. A mutable function can also be declared as a view function. But please mark a mutable function as private so that such functions cannot be maliciously called during runtime.
In the next line, we define the function get_message()
. The keywords used here are public
which indicates that this function would be publicly accessible and fun
which just says that this is a function.
Next, we see the parameters that are passed to the function (addr: address)
, the name of the parameter is addr
and it is of type address
. It has a return type of string::String
and it acquires MessageHolder
. acquires
is required here as the function accesses the MessageHolder
resource and we dont want other parties to modify the value of the resource
when we are reading it. Also, if we have a function that calls a function that uses acquires
then we also need to mark that child function with acquires
.
Next, we see an assert!
statement. assert
has the following syntax,
assert!(condition: bool, code: u64)
So we see that, exists<MessageHolder>(addr)
is the condition, which would return a boolean value True or False depending on if MessageHolder
resource is present for the address that is being passed. If its not present we throw an error
with the const code we declared earlier ENO_MESSAGE
. Incase, it is present. we return a immutable reference of the message that is present in the key
MessageHolder
using the borrow_global
function.
borrow_global
has the following syntax
borrow_global<T>(address): &T
It looks for a type T
in the address
. If its present it returns an immutable reference to that object. in the code we can see that once it is returned we are reading the .message
parameter from the object and returning it.
Alright, now that we have the get_message
function defined that would read the message
that is set in the chain, lets start defining the set_message
function that would set the message to what we want on the chain.
Mutable Functions
public entry fun set_message(account: signer, message: string::String)
acquires MessageHolder {
...
}
We see the function set_message
declared here like before. It has an extra keyword called entry
. An entry
modifier to a function allows to invoke these functions directly. And this allows to specify which function to begin the execution with. Thus, they can be considered as the “main” function in the module.
Next
let account_addr = signer::address_of(&account);
we get the address
of the signer
using the address_of
function which takes in a reference to the account
. Next we see.
if (!exists<MessageHolder>(account_addr)) {
move_to(&account, MessageHolder {
message,
message_change_events: account::new_event_handle<MessageChangeEvent>(&account),
})
}
We check if the resource
MessageHolder
exists in the given account address. Incase it does not we create a MessageHolder
and move that resource under the account using the move_to
function.
Incase, it does exist
else {
let old_message_holder = borrow_global_mut<MessageHolder>(account_addr);
let from_message = old_message_holder.message;
event::emit_event(&mut old_message_holder.message_change_events, MessageChangeEvent {
from_message,
to_message: copy message,
});
old_message_holder.message = message;
}
We use borrow_global_mut
to get a mutable reference of the MessageHolder
resource in the account_addr
and get the message stored in it. We emit an event using event::emit_event
. emit_event
accepts a reference to the event handle and a message. Here in our case we are passing a reference of the message_change_events
in the MessageHolder
resource and a new MessageChangeEvent
that contains the old and the new message. After that we set the message in the MessageHolder
resource to the new message
that we have received.
Alright, that defines our set_message
function.
Test Functions
Now that we have our get_message
and set_message
function declared lets write some tests to verify that everything works as expected. So moving down the file next we see a test function defined.
#[test(account = @0x1)]
The first line here declares that we are going to write a test function. And it mentions an account account = @0x1
which is the account through which this module is going to be tested. Here we set the account to a random account address that is created using a built-in function for testing purposes. Whenever, a module or a module member is marked for test
then it will not be included in the bytecode when compiled, unless compiled for testing.
public entry fun sender_can_set_message(account: signer) acquires MessageHolder {
...
}
Alright, next we just write a function as before. Please note that this is a test function. And thus when compiled normally (ie not for testing) this function wont be included in the bytecode. Such a test function, doesnt accept any parameters other than the type signer
, if passed any parameters other than the type signer
it will result in an error when run.
Also, the arg name passed in testing annotation account = @0x1
should match the parameter name that is being passed in the function sender_can_set_message(account: signer)
.
And then we get the account address of the signer
and store it in addr
.
let addr = signer::address_of(&account);
We use the create_account_for_test()
and pass it the addr
to create an account for the test purposes.
aptos_framework::account::create_account_for_test(addr);
We declare a variable called stra
and store the string "Hello, Move"
in that.
let stra = string::utf8(b"Hello, Move");
Then we use the set_message
function that we have already defined above to set a message in this test account.
set_message(account, stra);
We then use the get_message
function defined above, to retrieve the message we have set in the above line.
let rmsg = get_message(addr);
And finally, the check to see that message that we defined initially stra
matches with the message that was returned using get_message
that is stored in rmsg
. Incase it doesnt match, we throw the error code ENO_MESSAGE
.
assert!(
rmsg == stra,
ENO_MESSAGE
);
That defines the test function that was written. And additionally that explains the entire file hello_move.move
.
Unit Tests
One can also write unit tests in seperate files and aptos will pick that up based on the testing annotations. And test all the functions. Like for an example we have another file in the sources/
directory named hello_move_test.move
. This file has a test only module declared using #[test_only]
and thus this module will be included only when compiled for testing.
The file name can be anything, it doesnt necessarily need to have a _test.move
suffix. As long as there are test modules and functions declared using the test annotations, the compiler will pick it up while testing.
Looking into the hello_move_test.move
file we will see very similar things as before. It starts with
#[test_only]
Which declares this entire module as test only.
Next, it declares the message_tests
module.
module hello_move::message_tests {
After which it declares some of the aliases that we will use. Note that we also declared the hello_move::message
that is the module we just wrote in the other file.
use std::signer;
use std::unit_test;
use std::vector;
use std::string;
use hello_move::message;
Moving from there, we declare a function named get_account()
to get the account we will use for testing purposes. We use the create_signers_for_testing()
to create 1
signer
for testing. It returns a vector
and we then use the pop_back
method to return that one signer
.
fun get_account(): signer {
vector::pop_back(&mut unit_test::create_signers_for_testing(1))
}
Next, we see the sender_can_set_message
function which is exactly similar to the one we wrote in the other file except here we get the test account address using the get_account()
function that we just defined.
let account = get_account();
let addr = signer::address_of(&account);
Please note again, that this is a test only module, and the compiler will run this test and include in the bytecode, only when compiled for testing. Alright, that should explain the workings of the hello_move_test.move
file.
Move.toml
Now, lets take a look at the Move.toml
file that is defined at the root of the package.
Each move package has a manifest file at the root with the .toml
extension and it contains the metadata for the package such as name, version, and dependencies. Taking a look at the Move.toml
file.
[package]
name = "HelloMove"
version = "0.0.0"
Here we are declaring the name
and the version
of the package.
Next, we are declaring the named address hello_move
but we are leaving it undefined using the _
so that would mean that we need to pass the named address when we are compiling.
[addresses]
hello_move = "_"
And then we declare a dependency on the aptos-framework
as we have already seen that we use a few functions from the aptos-framework
.
[dependencies]
AptosFramework = { local = "./framework/aptos-framework" }
And that should define all the contents of the Move.toml
file. And thus all the files that are there in the package.
Compile, Test and Publish
Now that we know what all the files are about and what they contain. We can go ahead and compile the package. But first lets start with initializing aptos.
aptos init --network devnet
Tap Enter and follow through. The output should look something like this.
Configuring for profile default
Configuring for network Devnet
Enter your private key as a hex literal (0x...) [Current: Redacted | No input: Generate new key (or keep one if present)]
No key given, keeping existing key...
Account c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76 has been already found onchain
---
Aptos CLI is now set up for account c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76 as profile default! Run `aptos --help` for more information about commands
{
"Result": "Success"
}
Now lets fund this account using a faucet.
aptos account fund-with-faucet --account default
We should see an output similar to this
{
"Result": "Added 100000000 Octas to account c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76"
}
Now lets compile the module using
aptos move compile --named-addresses hello_move=default
Remember we kept the named address in the Move.toml
file empty so we need to pass the named address as default
one when we are compiling. Compilation output should look similar to this
Compiling, may take a little while to download git dependencies...
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING HelloMove
{
"Result": [
"c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76::message"
]
}
This would mean that the file was successfully compiled.
Next, lets test the modules
aptos move test --named-addresses hello_move=default
We should be able to see both the tests passing
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING HelloMove
Running Move unit tests
[ PASS ] 0xc1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76::message::sender_can_set_message
[ PASS ] 0xc1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76::message_tests::sender_can_set_message
Test result: OK. Total tests: 2; passed: 2; failed: 0
{
"Result": "Success"
}
And finally lets publish the module using
aptos move publish --named-addresses hello_move=default
The output you will see is similar to this.
Compiling, may take a little while to download git dependencies...
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING HelloMove
package size 1765 bytes
Do you want to submit a transaction for a range of [140300 - 210400] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
"Result": {
"transaction_hash": "0x22328486cd87414663d4461182b5f2de6447b29f9c32c682b152f6c127276568",
"gas_used": 1403,
"gas_unit_price": 100,
"sender": "c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76",
"sequence_number": 0,
"success": true,
"timestamp_us": 1690433681245985,
"version": 40008336,
"vm_status": "Executed successfully"
}
}
Now we have successfully published our module. We can go to the aptos explorer and check the transaction hash.
Here if we notice we can see the payload, ie the message that we want to be stored in the chain being passed.
Interact with the Module
Now that we have our module published on the chain, lets start interacting with the module.
To interact with our published module lets first trigger the set_message
method and update the message
stored in the resource MessageHolder
aptos move run --function-id 'default::message::set_message' --args 'string:first move with move'
Here we are setting the message to ‘first move with move’. The output should be similar to the following.
Do you want to submit a transaction for a range of [50300 - 75400] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
"Result": {
"transaction_hash": "0x4e7d9e1ba498f3367f714d7e425f184f8d64d7d5d68ad962f30273384cea6464",
"gas_used": 504,
"gas_unit_price": 100,
"sender": "c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76",
"sequence_number": 1,
"success": true,
"timestamp_us": 1690434192512775,
"version": 40055278,
"vm_status": "Executed successfully"
}
}
And now that the message is set, we can get the message using the view function that we wrote get_message
curl https://fullnode.devnet.aptoslabs.com/v1/accounts/c1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76/resource/0xc1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76::message::MessageHolder | json_pp
Here substitute the url with your account address.
Recall, its a view function, so we can call it direcly like an API. So we can actually just directly go to this link in the browser and see our message
that is stored in the MessageHolder
resource.
The output for curl
should give a result similar to this.
{
"data" : {
"message" : "first move with move",
"message_change_events" : {
"counter" : "0",
"guid" : {
"id" : {
"addr" : "0xc1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76",
"creation_num" : "4"
}
}
}
},
"type" : "0xc1afaaf97eaa61128b5b1439ee9732ef581b70f0e56f740f1bcf964e5427cf76::message::MessageHolder"
}
Here in the message
key you can see the message that we have just set.!
You can continue with updating the message as many times as required as you want using the set_message
function and then reading it. Do note that set_message
initiates a transaction that is it updates the state of the chain and thus you do need to pay gas everytime you make a transaction. If you see above, you can see the amount of gas used and the price per gas. This is why initially the account needs to be funded so that we can pay the gas required. The current account can be funded using a faucet as it is on the devnet, if on mainnet you need to have APT in your account to pay for this gas.
Alright, Congratulations! You now understand Move language, how to compile, test and publish a Move module and interact with that Move module! As a next step, you can head over to the aptos.dev docs to learn more about Move and the Aptos Chain.
References
[1] Move Tutorial from aptos-core
[2] Aptos Docs