Skip to content

1. Create a Smart Contract

This is the first chapter of the tutorial on building an end-to-end dapp on Aptos. If you haven’t done it, review that introduction, and ensure your environment meets the prerequisites listed there.

Now that you are all set up, let’s explore the contract directory.

contract-directory

A Move.toml file is a manifest file that contains metadata such as name, version, and dependencies for the package.

Take a look at the new Move.toml file. You should see your package information and an AptosFramework dependency. The AptosFramework dependency points to the aptos-core/aptos-move/framework/aptos-framework GitHub repo main branch.

The sources directory holds a collection of .move modules files. And later when we want to compile the package using the CLI, the compiler will look for that sources directory and its Move.toml file.

The tests directory holds .move files that are used to test the files in our sources directory.

An account is needed to publish a Move module. When we installed the template, the tool created a new account for us and added it to the .env file. If you open that file, you will see content resembling:

Terminal window
PROJECT_NAME=my-first-dapp
VITE_APP_NETWORK=devnet
VITE_APTOS_API_KEY=YOUR_API_KEY
VITE_MODULE_PUBLISHER_ACCOUNT_ADDRESS=0x1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb
#This is the module publisher account's private key. Be cautious about who you share it with, and ensure it is not exposed when deploying your dApp.
VITE_MODULE_PUBLISHER_ACCOUNT_PRIVATE_KEY=0x84638fd5c42d0937503111a587307169842f355ab661b5253c01cfe389373f43

The Boilerplate template comes with a pre generated message_board.move file, a relevant test file and a Move.toml file. We will not be using message_board.move in this tutorial, so delete it.

As mentioned, our sources directory holds our .move module files; so let’s create a new todolist.move file.

  1. Create a new todolist.move file within the sources directory and add the following to that file:

    module todolist_addr::todolist {
    }
  2. Open the Move.toml file, replace the name from MessageBoard to Todolist and the address from message_board_addr to todolist_addr, like the following code:

    [package]
    name = "Todolist"
    version = "1.0.0"
    authors = []
    [addresses]
    todolist_addr = "_"
    [dependencies]
    AptosFramework = { git = "https://github.com/aptos-labs/aptos-framework.git", rev = "mainnet", subdir = "aptos-framework" }
    [dev-dependencies]

The '_' is a placeholder for the account address. When we run the move compiler, the compiler will replace it with the actual account address.

create-aptos-dapp comes with premade scripts to easily run move commands, like compile, test and publish.

  1. Open each of the files in the scripts/move directory and update the message_board_addr variable to be todolist_addr.
    ...
    namedAddresses: {
    todolist_addr: process.env.VITE_MODULE_PUBLISHER_ACCOUNT_ADDRESS,
    },
    ...

Before jumping into writing code, let’s first understand what we want our smart contract program to do. For ease of understanding, we will keep the logic pretty simple:

  1. An account creates a new list.
  2. An account creates a new task on their list.
    • Whenever someone creates a new task, emit a TaskCreated event.
  3. Let an account mark their task as completed.

We can start with defining a TodoList struct, that holds the:

  • tasks array
  • a task counter that counts the number of created tasks (we can use that to differentiate between the tasks)

And also create a Task struct that holds:

  • task_id - derived from the TodoList task counter.
  • creator_addr - the account address who created that task.
  • content - the task content.
  • completed - a boolean that marks whether that task is completed or not.

On the todolist.move file, update the content in the module with:

...
/// Main resource that stores all tasks for an account
struct TodoList has key {
tasks: Table<u64, Task>,
task_counter: u64
}
/// Individual task structure
struct Task has store, drop, copy {
task_id: u64,
creator_addr: address,
content: String,
completed: bool,
}
...

What did we just add?

TodoList

A struct that has the key and store abilities:

  • Key ability allows struct to be used as a storage identifier. In other words, key is an ability to be stored at the top-level and act as a storage. We need it here to have TodoList be a resource stored in our user account.

When a struct has the key ability, it turns this struct into a resource:

  • Resource is stored under the account - therefore it exists only when assigned to an account and can be accessed through this account only.

Task

A struct that has the store, drop and copyabilities.

Store - Task needs Store as it’s stored inside another struct (TodoList)

Copy - value can be copied (or cloned by value).

Drop - value can be dropped by the end of scope.

Let’s try to compile what we have now (Spoiler alert: it will not work):

  1. Run: npm run move:compile Seeing errors?! Let’s understand them.

    We have some errors on Unbound type- this is happening because we used some types but never imported them, and the compiler doesn’t know where to get them from.

    On the top of the module, import those types by adding:

    ...
    use aptos_std::table::Table;
    use std::string::String;
    ...

    That will tell the compiler where it can get those types from.

  2. Run the npm run move:compile command again; If all goes well, we should see a response resembling (where the resulting account address is your default profile account address):

    Terminal window
    Compiling, may take a little while to download git dependencies...
    UPDATING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
    INCLUDING DEPENDENCY AptosFramework
    INCLUDING DEPENDENCY AptosStdlib
    INCLUDING DEPENDENCY MoveStdlib
    BUILDING Todolist
    {
    "Result": [
    "1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb::todolist"
    ]
    }

    At this point, we have successfully compiled our Move module. Yay!

We also have a new move/build directory (created by the compiler) that holds our compiled modules, build information and sources directory.

The first thing an account can and should do with our contract is to create a new list.

Creating a list is essentially submitting a transaction, and so we need to know the signer who signed and submitted the transaction:

  1. Add a create_list function that accepts a signer inside the Todolist module.

    public entry fun create_list(account: &signer){
    }

    Let’s understand the components of this function

    • entry - an entry function is a function that can be called via transactions. Simply put, whenever you want to submit a transaction to the chain, you should call an entry function.

    • &signer - The signer argument is injected by the Move VM as the address who signed that transaction.

    Our code has a TodoList resource. Resource is stored under the account; therefore, it exists only when assigned to an account and can be accessed only through this account.

    That means to create the TodoList resource, we need to assign it to an account that only this account can have access to.

    The create_list function can handle that TodoList resource creation.

  2. Add the following to the create_list function

    /// Initializes a new todo list for the account
    public entry fun create_list(account: &signer) {
    let tasks_holder = TodoList {
    tasks: table::new(),
    task_counter: 0
    };
    // Move the TodoList resource under the signer account
    move_to(account, tasks_holder);
    }

    This function takes in a signer, creates a new TodoList resource, and uses move_to to have the resource stored in the provided signer account.

  3. Let’s make sure everything is still working by running the npm run move:compile command again.

As mentioned before, our contract has a create task function that lets an account create a new task. Creating a task is also essentially submitting a transaction, and so we need to know the signer who signed and submitted the transaction. Another element we want to accept in our function is the task content.

  1. Add a create_task function that accepts a signer and task content and the function logic.

    /// Creates a new task in the todo list
    public entry fun create_task(account: &signer, content: String) acquires TodoList {
    // Get the signer address
    let signer_address = signer::address_of(account);
    // Get the TodoList resource
    let todo_list = borrow_global_mut<TodoList>(signer_address);
    // Increment task counter
    let counter = todo_list.task_counter + 1;
    // Create a new task
    let new_task = Task {
    task_id: counter,
    creator_addr: signer_address,
    content,
    completed: false
    };
    // Add the new task to the tasks table
    todo_list.tasks.upsert(counter, new_task);
    // Update the task counter
    todo_list.task_counter = counter;
    // Emit a task created event
    event::emit(TaskCreated {
    task_id: counter,
    creator_addr: signer_address,
    content,
    completed: false
    })
    }
  2. You will notice that we have not created the TaskCreated event struct yet. Create it at the top of the file (under the use statements) with the following code:

    #[event]
    struct TaskCreated has drop, store {
    task_id: u64,
    creator_addr: address,
    content: String,
    completed: bool,
    }
  3. Since we now use three new modules - signer, event, and table (you can see it being used in signer::, event::, and table::) - we need to import these modules. At the top of the file, add those two use statements (replace the table use statement with the following code):

    use aptos_framework::event;
    use aptos_std::table::{Self, Table}; // This one we already have, need to modify it
    use std::signer;
  4. Let’s make sure everything is still working by running the npm run move:compile command again.

Back to the code; what is happening here?

  • First, we want to get the signer address, so we can get this account’s TodoList resource.
  • Then, we retrieve the TodoList resource with the signer_address; with that we have access to the TodoList properties.
  • We can now increment the task_counter property, and create a new Task with the signer_address, counter and the provided content.
  • We push it to the todo_list.tasks table that holds all of our tasks along with the new counter (which is the table key) and the newly created Task.
  • Then we assign the global task_counter to be the new incremented counter.
  • Finally, we emit the TaskCreated event that holds the new Task data. event::emit() is an aptos-framework function that emits a module event with payload msg. In our case, we are passing the function a TaskCreated event struct with the new Task data.

Another function we want our contract to hold is the option to mark a task as completed.

  1. Add a complete_task function that accepts a signer and a task_id:

    /// Marks a task as completed
    public entry fun complete_task(account: &signer, task_id: u64) acquires TodoList {
    // Get the signer address
    let signer_address = signer::address_of(account);
    // Get the TodoList resource
    let todo_list = borrow_global_mut<TodoList>(signer_address);
    // Get the task record
    let task_record = todo_list.tasks.borrow_mut(task_id);
    // Mark the task as completed
    task_record.completed = true;
    }

    Let’s understand the code.

    • As before in our create list function, we retrieve the TodoList struct by the signer address, so we can have access to the tasks table that holds all the account tasks.
    • Then, we get a mutable reference for the task with the provided task_id on the todo_list.tasks table.
    • Finally, we update that task completed property to be true.
  2. Now compile the code by running: npm run move:compile to make sure everything is still working.

As this code now compiles, we want to have some validations and checks before creating a new task or updating the task as completed, so we can be sure our functions work as expected.

  1. Add a check to the create_task function to make sure the signer account has a list:

    public entry fun create_task(account: &signer, content: String) acquires TodoList {
    // gets the signer address
    let signer_address = signer::address_of(account);
    // assert signer has created a list
    assert!(exists<TodoList>(signer_address), 1);
    ...
    }
  2. Add a check to the complete_task function to make sure the:

    • signer has created a list.
    • task exists.
    • task is not completed.

    With the following code:

    /// Marks a task as completed
    public entry fun complete_task(account: &signer, task_id: u64) acquires TodoList {
    // Get the signer address
    let signer_address = signer::address_of(account);
    // Ensure the account has initialized a todo list
    assert!(exists<TodoList>(signer_address), 1);
    // Get the TodoList resource
    let todo_list = borrow_global_mut<TodoList>(signer_address);
    // Ensure the task exists
    assert!(todo_list.tasks.contains(task_id), 2);
    // Get the task record
    let task_record = todo_list.tasks.borrow_mut(task_id);
    // Ensure the task is not already completed
    assert!(task_record.completed == false, 3);
    // Mark the task as completed
    task_record.completed = true;
    }

We just added our first assert statements!

If you noticed, assert accepts two arguments: the first is what to check for, and the second is an error code. Instead of passing in an arbitrary number, a convention is to declare errors on the top of the module file and use these instead.

On the top of the module file (under the use statements), add those error declarations:

// Errors
/// Account has not initialized a todo list
const ENOT_INITIALIZED: u64 = 1;
/// Task does not exist
const ETASK_DOESNT_EXIST: u64 = 2;
/// Task is already completed
const ETASK_IS_COMPLETED: u64 = 3;

Now we can update our asserts with these constants:

/// Creates a new task in the todo list
public entry fun create_task(account: &signer, content: String) acquires TodoList {
// Get the signer address
let signer_address = signer::address_of(account);
// Ensure the account has initialized a todo list
assert!(exists<TodoList>(signer_address), ENOT_INITIALIZED);
...
}
/// Marks a task as completed
public entry fun complete_task(account: &signer, task_id: u64) acquires TodoList {
// Get the signer address
let signer_address = signer::address_of(account);
// Ensure the account has initialized a todo list
assert!(exists<TodoList>(signer_address), ENOT_INITIALIZED);
// Get the TodoList resource
let todo_list = borrow_global_mut<TodoList>(signer_address);
// Ensure the task exists
assert!(todo_list.tasks.contains(task_id), ETASK_DOESNT_EXIST);
// Get the task record
let task_record = todo_list.tasks.borrow_mut(task_id);
// Ensure the task is not already completed
assert!(task_record.completed == false, ETASK_IS_COMPLETED);
// Mark the task as completed
task_record.completed = true;
}

WONDERFUL!!

Let’s stop for one moment and make sure our code compiles by running the npm run move:compile command. If all goes well, we should output resembling:

Terminal window
Compiling, may take a little while to download git dependencies...
UPDATING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING MessageBoard
{
"Result": [
"1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb::todolist"
]
}

If you encounter errors, make sure you followed the steps above correctly and try to determine the cause of the issues.

Now that we have our smart contract logic ready, we need to add some tests for it.

First, delete the test_end_to_end.move file in the tests directory, as we won’t be using it.

  1. For simplicity, and because we don’t have much code to test, we will have the tests in the todolist.move file. If you need to write a more complex test, you should create a separate test file in the tests directory.

    The test steps are:

    // create a list
    // create a task
    // update task as completed
  2. Add the following code to the bottom of the todolist.move file:

    #[test]
    public entry fun test_flow() {
    }

    Note: Test functions use the #[test] annotation.

  3. Update the test function to be:

    #[test(admin = @0x123)]
    public entry fun test_flow(admin: signer) acquires TodoList {
    // Create an admin account for testing
    account::create_account_for_test(signer::address_of(&admin));
    // Initialize a todo list for the admin account
    create_list(&admin);
    // Create a task and verify it was added correctly
    create_task(&admin, string::utf8(b"Create e2e guide video for aptos devs."));
    let todo_list = borrow_global<TodoList>(signer::address_of(&admin));
    assert!(todo_list.task_counter == 1, 5);
    // Verify task details
    let task_record = todo_list.tasks.borrow(todo_list.task_counter);
    assert!(task_record.task_id == 1, 6);
    assert!(task_record.completed == false, 7);
    assert!(task_record.content == string::utf8(b"Create e2e guide video for aptos devs."), 8);
    assert!(task_record.creator_addr == signer::address_of(&admin), 9);
    // Complete the task and verify it was marked as completed
    complete_task(&admin, 1);
    let todo_list = borrow_global<TodoList>(signer::address_of(&admin));
    let task_record = todo_list.tasks.borrow(1);
    assert!(task_record.task_id == 1, 10);
    assert!(task_record.completed == true, 11);
    assert!(task_record.content == string::utf8(b"Create e2e guide video for aptos devs."), 12);
    assert!(task_record.creator_addr == signer::address_of(&admin), 13);
    }

    Our #[test] annotation has changed and declares an account variable.

    Additionally, the function itself now accepts a signer argument.

    Let’s understand our tests.

    Since our tests run outside an account scope, we need to create accounts to use in our tests. The #[test] annotation gives us the option to declare those accounts. We use an admin account and set it to a random account address (@0x123). The function accepts this signer (account) and creates it by using a built-in function to create an account for test.

    Then we simply go through the flow by:

    • creating a list
    • creating a task
    • updating a task as completed

    And assert the expected data/behavior at each step.

    Before running the tests again, we need to import (use) some new modules we are now employing in our code:

  4. At the top of the file, add these use statements:

    #[test_only]
    use aptos_framework::account;
    #[test_only]
    use std::string::{Self};

    Note that we are using the #[test_only] annotation to import the modules only for testing. This is because we don’t want to use these modules in our production code.

  5. Run the npm run move:test command. If all goes right, we should see a success message like:

    Running Move unit tests
    [ PASS ] 0x1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb::todolist::test_flow
    Test result: OK. Total tests: 1; passed: 1; failed: 0
    {
    "Result": "Success"
    }
  6. Let’s add one more test to make sure our complete_task function works as expected. Add another test function with:

    #[test(admin = @0x123)]
    #[expected_failure(abort_code = ENOT_INITIALIZED)]
    public entry fun account_can_not_update_task(admin: signer) acquires TodoList {
    // Create an admin account for testing
    account::create_account_for_test(signer::address_of(&admin));
    // Attempt to complete a task without creating a list first (should fail)
    complete_task(&admin, 2);
    }

    This test confirms that an account can’t use that function if they haven’t created a list before.

    The test also uses a special annotation #[expected_failure] that, as the name suggests, expects to fail with an ENOT_INITIALIZED error code.

  7. Run the npm run move:test command. If all goes right, we should see a success message like:

    Terminal window
    Running Move unit tests
    [ PASS ] 0x1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb::todolist::account_can_not_update_task
    [ PASS ] 0x1cecfef9e239eff12fb1a3d189a121c37f48908d86c0e9c02ec103e0a05ddebb::todolist::test_flow
    Test result: OK. Total tests: 2; passed: 2; failed: 0
    {
    "Result": "Success"
    }

Now that everything works, we can compile the Move modules and publish the Move package to chain so our React app (and everyone else) can interact with our smart contract!

  1. Run: npm run move:test and npm run move:compile - all should work without errors.
  2. Run: npm run move:publish
  3. Enter yes to the prompt Do you want to publish this package at object address 0x8f66343d40de3eeef5dd45cab8c1531a542f0e5f546da9f11852d4c2b30165a7 [yes/no] >. (Spoiler alert: it will fail)

Oh no! We got an error!

It complains about an account mismatch with the MODULE_ADDRESS_DOES_NOT_MATCH_SENDER error code. Apparently we compiled the package with a different account we try to publish it.

Let’s fix it.

  1. Open the scripts/move/publish.js file.
  2. Update the addressName variable value to be todolist_addr.

That will use the same account we used for compiling the package.

Let’s try again:

  1. Run: npm run move:publish

  2. Enter yes in the prompt.

  3. Enter yes in the second prompt.

  4. That will compile, simulate and finally publish your module into devnet. You should see a success message:

    Terminal window
    Transaction submitted: https://explorer.aptoslabs.com/txn/0x68dadf24b9ec29b9c32bd78836d20032de615bbef5f10db580228577f7ca945a?network=devnet
    Code was successfully deployed to object address 0x2bce4f7bb8a67641875ba5076850d2154eb9621b0c021982bdcd80731279efa6
    {
    "Result": "Success"
    }
  5. You can now head to the Aptos Explorer link and view the transaction details. You can also see the module published on chain by looking for the object address.

Here is the full todolist.move file to confirm your work:

module todolist_addr::todolist {
use aptos_framework::event;
use aptos_std::table::{Self, Table};
use std::signer;
use std::string::String;
#[test_only]
use aptos_framework::account;
#[test_only]
use std::string::{Self};
// Errors
/// Account has not initialized a todo list
const ENOT_INITIALIZED: u64 = 1;
/// Task does not exist
const ETASK_DOESNT_EXIST: u64 = 2;
/// Task is already completed
const ETASK_IS_COMPLETED: u64 = 3;
#[event]
struct TaskCreated has drop, store {
task_id: u64,
creator_addr: address,
content: String,
completed: bool,
}
/// Main resource that stores all tasks for an account
struct TodoList has key {
tasks: Table<u64, Task>,
task_counter: u64
}
/// Individual task structure
struct Task has store, drop, copy {
task_id: u64,
creator_addr: address,
content: String,
completed: bool,
}
/// Initializes a new todo list for the account
public entry fun create_list(account: &signer) {
let tasks_holder = TodoList {
tasks: table::new(),
task_counter: 0
};
// Move the TodoList resource under the signer account
move_to(account, tasks_holder);
}
/// Creates a new task in the todo list
public entry fun create_task(account: &signer, content: String) acquires TodoList {
// Get the signer address
let signer_address = signer::address_of(account);
// Ensure the account has initialized a todo list
assert!(exists<TodoList>(signer_address), ENOT_INITIALIZED);
// Get the TodoList resource
let todo_list = borrow_global_mut<TodoList>(signer_address);
// Increment task counter
let counter = todo_list.task_counter + 1;
// Create a new task
let new_task = Task {
task_id: counter,
creator_addr: signer_address,
content,
completed: false
};
// Add the new task to the tasks table
todo_list.tasks.upsert(counter, new_task);
// Update the task counter
todo_list.task_counter = counter;
// Emit a task created event
event::emit(TaskCreated {
task_id: counter,
creator_addr: signer_address,
content,
completed: false
})
}
/// Marks a task as completed
public entry fun complete_task(account: &signer, task_id: u64) acquires TodoList {
// Get the signer address
let signer_address = signer::address_of(account);
// Ensure the account has initialized a todo list
assert!(exists<TodoList>(signer_address), ENOT_INITIALIZED);
// Get the TodoList resource
let todo_list = borrow_global_mut<TodoList>(signer_address);
// Ensure the task exists
assert!(todo_list.tasks.contains(task_id), ETASK_DOESNT_EXIST);
// Get the task record
let task_record = todo_list.tasks.borrow_mut(task_id);
// Ensure the task is not already completed
assert!(task_record.completed == false, ETASK_IS_COMPLETED);
// Mark the task as completed
task_record.completed = true;
}
#[test(admin = @0x123)]
public entry fun test_flow(admin: signer) acquires TodoList {
// Create an admin account for testing
account::create_account_for_test(signer::address_of(&admin));
// Initialize a todo list for the admin account
create_list(&admin);
// Create a task and verify it was added correctly
create_task(&admin, string::utf8(b"Create e2e guide video for aptos devs."));
let todo_list = borrow_global<TodoList>(signer::address_of(&admin));
assert!(todo_list.task_counter == 1, 5);
// Verify task details
let task_record = todo_list.tasks.borrow(todo_list.task_counter);
assert!(task_record.task_id == 1, 6);
assert!(task_record.completed == false, 7);
assert!(task_record.content == string::utf8(b"Create e2e guide video for aptos devs."), 8);
assert!(task_record.creator_addr == signer::address_of(&admin), 9);
// Complete the task and verify it was marked as completed
complete_task(&admin, 1);
let todo_list = borrow_global<TodoList>(signer::address_of(&admin));
let task_record = todo_list.tasks.borrow(1);
assert!(task_record.task_id == 1, 10);
assert!(task_record.completed == true, 11);
assert!(task_record.content == string::utf8(b"Create e2e guide video for aptos devs."), 12);
assert!(task_record.creator_addr == signer::address_of(&admin), 13);
}
#[test(admin = @0x123)]
#[expected_failure(abort_code = ENOT_INITIALIZED)]
public entry fun account_can_not_update_task(admin: signer) acquires TodoList {
// Create an admin account for testing
account::create_account_for_test(signer::address_of(&admin));
// Attempt to complete a task without creating a list first (should fail)
complete_task(&admin, 2);
}
}

Now let’s set up the frontend in chapter 2.