Aptos Tutorial Episode 1: Create Things

|
by: Wayne Culbreth

Learning Objectives: Resources, Abilities, Global Storage, Unit Testing

Intro

The Move language makes it very easy to create digital “things”, own those things, and move them around in the blockchain universe. Move is a very simple language - and that’s on purpose. Complexity always introduces the possibility of exploitation of unanticipated applications of that complexity. We’ve all seen the horror stories of exploits of smart contracts resulting in billions of dollars of stolen assets. We want, we need, our digital stuff to be safe. The simplicity of the Move language comes with safeguards that takes care of a lot of that for us.

This first episode is going to be a bit more theoretical than the rest as it is very important to have good, working situational awareness on what resources are, and how they are controlled, before jumping too deep into code. And it’s going to be layman theory (e.g. “digital things”) vs academic theory (e.g. “custom resource types with semantics inspired by linear logic”). I didn’t know what Move was until about 10 days ago, so ‘practical’ is all I got. Aaaaaand it’s a bit wordy (this thing’s more verbose than your first React project). But it is imperative to get a solid understanding of what’s going on with Move and Resources before you start building. The remaining episodes will be more code heavy. Onward.

Resources: Digital Things

A ‘resource’ in Move is very simply a ‘digital thing’. And that thing can be whatever you want or imagine it to be: a concert ticket, an NFT, a book, a contract between two businesses, a social media post, etc. If you can think it, it can be a resource.

“That sounds great, but it must be complicated to create a blockchain digital resource, right?” The typical answer to that question is, “yes”, but Move makes it incredibly easy for us. Over the next few episodes, we’re going to send our heroes, Alice and Bob, to a concert. They need tickets. In Move, we can create that ticket simply with:

struct ConcertTicket has key {
    seat: vector<u8>,
    ticket_code: vector<u8>,
}

That’s it. We’re done. That code in a Move module now enables us to send tickets to Alice and Bob. With the above structure, we can create a ticket simply with:

public fun create_ticket(account: &signer, seat: vector<u8>, ticket_code: vector<u8>): ConcertTicket {
    let seat = vector<u8>;
    let ticket_code = vector<u8>;
    ConcertTicket {seat, ticket_code}
}

In our “layman theory” approach, it will help to think about Resources in terms of discrete, physical objects rather than thinking about them like a programmer (e.g. “stack vs heap” or “object prototypes” etc). Our ‘struct’ is the recipe, the architectural drawings, instruction list or whatever analog analogy you want to use. Let’s break down the pieces:

struct WhateverYouWantToCallIt has Abilities {
  any_name_i_want: one_of_a_few_type_choices,
  any_other_name_i_want: one_of_a_few_type_choices,
  this_is_the_last_one_i_need: one_of_a_few_type_choices
}

You can name your resource whatever you want to, but it must start with a capital letter A-Z. After that, names can contain underscores _, letters a to z, letters A to Z, or digits 0 to 9. The struct will have certain ‘Abilities’ which we’ll cover in a minute. But for now, just know those abilities will include one or a combination of ‘key’, ‘store’, ‘copy’ and ‘drop’.

Within the struct, you can have any number of key pairs (I don’t know if there is a key pair count upper limit, but if there is I suspect it’s higher than any practical application would call for). The key names should be in snake case (note: that seems to be a recommended coding convention rather than a compiler requirement). The value in each of the key pairs has to be one of the following types:

bool
u8
u64
u128
address
signer
vector: vector<{non-reference MoveTypeId}>
struct: {address}::{module_name}::{struct_name}::<{generic types}>
reference: immutable & and mutable &mut references.
generic_type_parameter: <Tn>

I won’t go into exhaustive detail here as we’ll drill down into types later, but will just hit a few key points. While structs look like a JSON object, the struct can only be made of top-level key pairs, no sub keys. For example:

struct ConcertTicket has key {
    seat: vector<u8>,
    ticket_code: vector<u8>,
}

is good, but:

struct ConcertTicket has key {
    seat: {
      row: u8,
      seat_number: u8
    }
    ticket_code: vector<u8>,
}

is a no go. But we can have a struct as a value type, so we can get to the same end result by:

struct Seat has store {
    row: u8,
    seat_number: u8
}

struct ConcertTicket has key {
    seat: Seat,
    ticket_code: vector<u8>,
}

That seems like the long way around to get to the structure we want - so why do it that way? This brings us to a very important principle that is likely foreign to a number of developers:

Key Point: Once created, every value in a key pair inside a struct is a ‘digital thing’ that can be owned, transferred or even destroyed in accordance with the abilities of the struct.

What does that mean? Let’s look at another struct to make it very simple. Coming new into crypto, it seems like it should be difficult to create a new crypto currency. This is the struct for TestCoin, the token we’re all using on devnet right now:

struct Coin has store {
    value: u64
}

Pretty simple, right? Of course there are other structures and functions built around Coin so we can use it, but the basic struct is one key pair that simply holds a number. If I want to mint 50 value of Coin, I simply:

let my_coin = Coin { value: 50 };

The variable my_coin now holds a Coin resource with a value of 50. Now, what if we do this:

let my_other_coin = my_coin;

Our javascript experiences might lead us to conclude that we now have two variables, my_coin and my_other_coin each holding 50, so shouldn’t we have 100 total Coin value between the two? Yeah! LFG! Wen moon, ser?!!! (If you don’t speak ‘crypto’ dialect, I’m sorry but you’re just not as cool as the rest of us.)

Sorry, but nope. What we now have is my_other_coin with a value of 50 while my_coin is gone, not accessible, like it never existed. Because what we did in let my_other_coin = my_coin was actually ‘moving’ (light bulb goes off) the value from one variable to the other, and since my_coin is empty, it’s no longer valid. This is a new concept for some developers and one that may take a minute to wrap your brain around, but it is fundamental to understanding the Move language and building around resources. Again, think of resources in terms of physical things. If the struct is the recipe, creation and assignment into a variable is cooking the meal. We’re not just manipulating data, we’re taking the Coin out of my_coin and moving it into my_other_coin.

Another way to think about this from the developer brain is to understand what the compiler is doing for us behind the scenes. In every variable assignment, the Move compiler is inferring whether that ‘=’ sign is a ‘move’ or a ‘copy’. Although we typed one thing, what the compiler actually saw above was:

let my_other_coin = move my_coin;

Because my_coin is a resource, the compiler infers that we are moving the value. Now, if we have a non-resource scalar value like:

let a = 1;
let b = a;

what the compiler actually sees is

let a = 1;
let b = copy a;

because ‘a’ is a scalar value that did not come from a resource. We can, in fact, explicitly use the ‘move’ and ‘copy’ keywords in our code rather than relying on Move to infer what we’re trying to do. So, can we get back to our ‘get rich quick crypto scheme’ by doing:

let my_other_coin = copy my_coin;

Nope again, because the struct Coin does not have the explicit ability to be copied. Remember our mention of ‘Abilities’ above and the four options of ‘key, store, copy, drop’? Our Coin struct was only declared with the ‘store’ ability. Without the ‘copy’ ability, Move won’t infer or allow copies to be made - it will only move the value from one variable to another in an assignment. When you read things about Move’s “safety guarantees” - this is what they are talking about. The language won’t let you accidentally (or with ill-intent) make a copy of something that isn’t supposed to copied.

More on Abilities in a minute, but let’s continue on this example of moving value and resources. What if we destructure my_coin like this:

let Coin { value } = my_coin;

we now have a variable value that is a u64 number and my_coin has gone away because it is now empty. So, if we then try to:

let my_other_value = value;

even though value is a scalar u64, the Move compiler will infer the assignment as:

let my_other_value = move value;

Why is that when the other u64 assignment was inferred as a copy? Even though value is a u64, because it came from a non-copy resource, the contents will always be treated as a non-copy variable. Which brings us back to our key point above - everything inside a struct inherits the same Abilities as the struct itself.

Pause moment: If you’ve made it to here and this seems fuzzy - don’t worry, that’s not uncommon. However, unless these concepts are crystal clear to you, it might be a good idea to take a pause here, let this marinate, and come back and read it again. These are fundamental concepts that, once mastered, will greatly speed your development process with Move

Okay, so now it’s clear I can’t fill my bags by simply by manipulating the math on TestCoin. But that’s okay because I’ll just deploy my own copy of TestCoin.move and start programmatically generating all the Coin I could ever use. Hate to tell you, but just like that “banana with a rebel attitude wearing sunglasses while dragging on a cigarette” NFT you bought last week isn’t going to pay for your vacation in the Virgin Islands, deploying your own copy of TestCoin won’t either. Besides the fact that TestCoin would trade at $0.000000 USD today (and likely forever), a struct only has meaning in the module that declares it.

Key Point: Structs can only be created/manipulated/destroyed by the modules where they are declared and those modules are owned by a single account.

Just naming a struct “Coin” doesn’t make it TestCoin. If I have a struct called ConcertTicket and you have a struct called ConcertTicket - they are not the same. A struct and its contained values are fully identified by the linkage between the account and module where the struct was created (from our struct: {address}::{module_name}::{struct_name} above). The only Coin struct that is actual TestCoin is here:

AptosFramework::TestCoin::Coin

I can take an exact copy of TestCoin.move from repo and deploy it to an account I own as: 9b0a2b8dbf5ccadd1fd96b84b8bb9ff7::TestCoin::Coin

But as far as Aptos transactions are concerned, those two structs have absolutely nothing to do with each other. I can’t withdraw Coin from 9b0a2b8dbf5ccadd1fd96b84b8bb9ff7::TestCoin::withdraw

and then try to deposit that Coin with AptosFramework::TestCoin::deposit because the transaction will be invalid due to incompatible types.

Are you going to actually code or just blah, blah, blah??

I hear you - let’s build. I posted a repo for a Move project template structure that I pulled together from aptos.dev tutorials and a unit testing setup kindly provided by the Aptos team (thanks, Zekun). Clone that repo to get started. Each lesson will have a separate tag in the Tutorial repo. If you want to skip ahead to the final version for this episode, you can find that here.

The template has this file structure initially

├── sources
  ├── Module.move
  └── ModuleTests.move
├── src
  ├── lib.rs
  ├── main.rs
  └── move_unit_tests.rs
├── .gitignore
├── Cargo.toml
├── Move.toml
├── README.md

In essence, we’re building a Move project on top of some Rust resources that facilitate compiling, testing and building our Move code. I suspect in the very near future we will have access to a Move CLI that will take care of these capabilities for us, so I’m not going to spend any time on the Rust files other than a couple of minor points on Cargo.toml. In the first three lines we have:

[package]
name = "package_name"
version = "0.0.0"

Let’s give our package a name and a starting version. We’re going simply with “tutorial” but it can be whatever you want. The name needs to be in snake case to avoid a compiler warning. If you don’t use snake case, it will still build, but Cargo will think less of you and your skills as a developer. So, let’s change our Cargo.toml to

# /Cargo.toml

[package]
name = "tutorial"
version = "0.1.0"

And let’s adjust our Move.toml to:

# /Move.toml

[package]
name = "tutorials"
version = "0.1.0"

[addresses]
AptosFramework = "0x1"
TicketTutorial = "0xe110"

[dependencies]
AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", subdir="aptos-move/framework/aptos-framework", rev = "0b834fc218648d759e93a7d61a432d0dce6c9946" }

We’ve given our package a name and a version here. Another item of interest is the TicketTutorial = “0xe110” line. For the struct and function path we talked about above ({address}::{module_name}::{struct_name}), this is where we set the address. Once we compile the project (soon), we will publish the bytecode module to an account on Aptos blockchain. We can call structs and functions by using the discrete address like:

0x95876b0492fe3912863e55bab6f74703::Module::Struct

but that gets a bit cumbersome. The Move.toml gives us a place to put a named address to the package owning account. We’ve set ours to TicketTutorial, which allows us to reference a struct like:

TicketTutorial::Module::Struct

When we get ready to deploy, we’ll put an actual address of the account we’re publishing to in the Move.toml file, but for now we can just use the place holder address 0xe110.

The other thing we do in the Move.toml is reference our dependencies. In pretty much every move project, you’re going to reference the AptosFramework we have here. We also have to establish the address all dependencies are published at. For AptosFramework, that address is 0x1.

Let’s rename our Module.move to TicketTutorial.move. Inside TicketTutorial.move, we’ll change the first line to:

module TicketTutorial::Tickets {

where ‘TicketTutorial’ is the same named owner address from from Move.toml and ‘Tickets’ is how we want to reference the module. We’ve already got a dependency declared with use Std::Signer which is going to be used in basically every module you build.

We’re at the starting line now and we’ve got to get concert tickets to Alice and Bob. Let’s create our first struct by adding the following just past the use statement:

struct ConcertTicket has key {
    seat: vector<u8>,
    ticket_code: vector<u8>,
}

Let’s tackle two more important concepts here, Abilities and Global Storage. As we’ve mentioned, Abilities are what defines the things we can do with a resource. We’ve defined our ConcertTicket with one ability: “key”. This means we can hold a ConcertTicket resource in Global Storage (basically all the data stored in all the accounts on the Aptos chain). So I can create a ConcertTicket and move it to Alice’s account.

Compare “key” with the ability “store”. The “store” ability allows me to create a ConcertTicket, but I can’t move it to Alice’s account by itself. I have to store it inside another struct that has “key” ability. From there you can probably guess that “copy” allows me to copy a resource and “drop” allows me to destroy a resource. For some additional reading on Abilities, check out the Diem Move language reference here:

and for Global Storage, here:

Let’s create a ticket and give it to Alice. Just past the ConcertTicket struct, add the following:

public fun create_ticket(recipient: &signer, seat: vector<u8>, ticket_code: vector<u8>)  {
    move_to<ConcertTicket>(recipient, ConcertTicket {seat, ticket_code})
}

We’ve created our first function with a public scope so it can be called externally from the module as:

TicketTutorial::Tickets::create_ticket

Scope should be a familiar programming concept, so I wont cover it here - but you can drill down into the details in the function language reference:

We’re passing in a few parameters. The most important is recipient which is a reference to a Std::Signer type. A core, fundamental operating principle of all blockchains, whenever we do any write type operations on an account, the transaction must be signed. When we call this function to create a ticket for Alice, we’ll need to pass in a reference to her account as the signer. We don’t have to worry about whether or not she’s signed the transaction as the Aptos-VM will never call our function if she hasn’t signed it. The other two parameters are vector<u8> to hold the values for the ticket seat assignment and reference code. (Editor’s note: the data in the ConcertTicket struct is arbitrary. Yes, an actual ticket probably needs more data than this, but we’re not worried about the business logic right now. Feel free to add all the data fields you want.) We don’t have a string primitive in Move, so vector<u8> does the job for now (we’ll discuss Std::ASCII later which will make things more readible; we’ll stick with vector<u8> for now so we can appreciate with ASCII::String does for us later).

We learned a few paragraphs (pages?) back that we can create the resource by:

ConcertTicket {seat, ticket_code}

We’re creating the resource inline in our function then moving it to the account reference passed in recipient. Hurray! We’re moving our first resource! The move_to instruction is a Global Storage operator that quite simply moves a resource into an account. Along with move_from (which we’ll cover soon), this is how we move things in Move. We’ll be visiting these instructions often, but you can read ahead here for the details now:

A relative unique attribute of the Aptos chain and Move is that you cannot just send any account a resource. If you’ve been spammed with NFTs showing up in your wallet on other chains uninvited, then you understand why this is a good feature (whoever keeps spamming me with Solsweeps cartoon broom NFTs, I’m looking at you). All move_to and move_from instructions require signatures from the affected account. move_to has an interface of:

move_to<T>(&signer,T)

we’re providing the type and passing in our created ConcertTicket resource by:

move_to<ConcertTicket>(recipient, ConcertTicket {seat, ticket_code})

That’s it. We can now create and move a resource. It’s sooo simple it really makes you wonder why it took me nearly 3,000 words to get to about 6 lines of code. Now, let’s see if it works.

Testing… testing one, two, three…. is this thing on??

The last thing we’ll cover for this episode is a quick look at unit testing. We can set that up inside the module with some compiler directives. Below our create_ticket function, but before the closing }, let’s add the following:

#[test(recipient = @0x1)]
public(script) fun sender_can_create_ticket(recipient: signer) {
    create_ticket(&recipient, b"A24", b"AB43C7F");
    let recipient_addr = Signer::address_of(&recipient);
    assert!(exists<ConcertTicket>(recipient_addr), 1);
}

This is a simple inline unit test to make sure our code is working at a basic level before compiling and deploying. The first line is a compiler directive to indicate that the next function is a test:

#[test(recipient = @0x1)]

It also provides us the ability to create a signer we can pass to the test function with the @0x1 address notation. We call our create_ticket function to create and give our signer seat number “K24” with ticket code "AB43C7F” (arbitrary data; put yourself on the front row if you like). The b”string” is a string literal operator that creates a vector for us. With that one function call, we’ve created the ConcertTicket and stored it in recipient’s account at address 0x1.

This is a test, so we’ve got to make sure it worked. We use the function Signer::address_of to store the address of “recipient” in our variable recipient_addr. We can then use exists to see if a ConcertTicket resource is actually stored at that address. The exists instruction is another Global Storage operator with an interface of exists<T>(address): bool. Passing in our <ConcertTicket> type and the address we’re checking gives us a true/false response as to whether or not the resource exists at the address.

Lastly, Assert! is a macro like operation that lets us test a condition and abort the function with an error code if the condition isn’t true. If my now approaching 4,000 words just isn’t enough for you superbrains, more details are here:

In our test, we use the exists function to determine if the resource exists, indicating our test was successful. Let’s run that test (be sure to save your file first. If you’re like me, you’ve spent more time than you want to admit perplexed as to why your changes didn’t work only to see that big white dot on your VS Code tab after running the test 5 times).

Open a terminal in the project directory and simply run:

cargo test

If all is well, you’ll get the following output:

Finished test [unoptimized + debuginfo] target(s) in 0.50s
     Running unittests (target/debug/deps/tutorial-6df2116825e4520d)

running 1 test
CACHED MoveStdlib
CACHED CoreFramework
CACHED AptosFramework
BUILDING tutorials
Running Move unit tests
[ PASS    ] 0xe110::Tickets::sender_can_create_ticket
Test result: OK. Total tests: 1; passed: 1; failed: 0
test move_unit_tests::move_unit_tests ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.41s

     Running unittests (target/debug/deps/tutorial-b1774daddf2e13d8)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests tutorial

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Our testing setup is looking for tests in multiple places, but we’re just focused on the first test now and (hurray) it passed! Just to make sure, let’s comment out the function call in our test

// create_ticket(&recipient, b"A24", b"AB43C7F");

and run it again which gives us this output:

Running Move unit tests
[ FAIL    ] 0xe110::Tickets::sender_can_create_ticket

Test failures:

Failures in 0xe110::Tickets:

┌── sender_can_create_ticket ──────
│ error[E11001]: test failure
│    ┌─ /Users/culbrethw/Development/Tutorials/Tickets/sources/TicketTutorial.move:42:3
│    │
│ 36 │     public(script) fun sender_can_create_ticket(recipient: signer) {
│    │                        ------------------------ In this function in 0xe110::Tickets
│    ·
│ 42 │         assert!(exists<ConcertTicket>(recipient_addr), 1);
│    │         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Test was not expected to abort but it aborted with 1 here
│ 
│ 
└──────────────────

Test result: FAILED. Total tests: 1; passed: 0; failed: 1

Hurray! We failed! We see in the error message, Test was not expected to abort but it aborted with 1 here where the with 1 is the error code we raised if the Assert! failed. Of course, sometimes we want tests to fail under certain conditions, but our brains need to see all green so that we know everything is working as planned. We can construct our test with another compiler directive to expect a failure with a particular code by modifying our tests as:

#[test(recipient = @0x1)]
#[expected_failure(abort_code = 1)]
public(script) fun sender_can_create_ticket(recipient: signer) {

where abort_code is the error we expect. Running cargo test again and we’re back to all green:

Running Move unit tests
[ PASS    ] 0xe110::Tickets::sender_can_create_ticket
Test result: OK. Total tests: 1; passed: 1; failed: 0
test move_unit_tests::move_unit_tests ... ok

You can drill down further into unit testing here:

Congratulations! You made it all the way to the end. Lot’s of theory in this one - but critically important stuff. In the next episode, we’ll move deeper into code, give Alice and Bob the ability to buy tickets, maybe even trade or sell those tickets, and make sure everyone gets the seats they want at the concert. Stay tuned!