Set up the Move development environment

Install the Move CLI to access the compiler, package manager, and testing framework. This toolchain is required to build and verify Move-based programming projects.

Install the Move CLI

Install the latest version using the official installer script:

Shell
curl -L https://move-language.github.io/move/install-move-cli.sh | bash

Verify the installation:

Shell
move --version

Initialize a new project

Create a new Move package. This command generates the standard directory structure, including the sources folder and Move.toml configuration file.

Shell
move init

The Move.toml file defines package metadata, dependencies, and network settings. Customize the version field to match your project's release cycle.

Verify the project structure

Ensure the environment is ready by checking the generated files. The sources directory should contain a move.mod file and a default module file.

Shell
ls -R

Expected output:

Text
.
├── Move.toml
└── sources
    └── <your_package_name>.move

Write a contract using resource types

Move treats assets as first-class citizens through resource types. Unlike standard structs, resources cannot be copied, dropped, or implicitly moved. This enforcement prevents vulnerabilities like double-spending.

Define a struct with the resource annotation. The compiler mandates explicit management of every instance. You must define functions to create, transfer, and destroy these resources.

1
Define the resource struct

Use the struct keyword with the resource annotation to declare your asset. This tells the Move compiler that this type represents a unique, non-duplicable entity. You cannot implement Copy or Drop capabilities for this struct.

MOVE
MOVE
module my_project::token {
    use std::signer;
    use sui::object::{Self, UID};
    use sui::transfer;

    struct Token has key {
        id: UID,
        value: u64,
    }
}
2
Create the resource

Implement a public fun to construct the resource. Use object::new(&signer) to generate a unique ID tied to the transaction. This function initializes the internal fields and returns the resource instance wrapped in an object.

MOVE
MOVE
    public fun mint(signer: &signer, amount: u64): Token {
        Token {
            id: object::new(&signer),
            value: amount,
        }
    }
3
Transfer ownership

Resources must be explicitly moved to new owners. Use the transfer module to send the resource to another address or destroy it. The compiler ensures that you cannot use the resource after it has been moved, preventing use-after-free errors.

MOVE
MOVE
    public fun transfer_token(token: Token, recipient: address) {
        transfer::transfer(token, recipient);
    }
4
Destroy the resource

When an asset is no longer needed, it must be explicitly destroyed to reclaim storage. Use object::destroy_inner to extract the ID and then object::destroy to finalize the destruction. This step is mandatory; leaving a resource un-destroyed causes compilation errors.

MOVE
MOVE
    public fun burn(token: Token) {
        let Token { id, value: _ } = token;
        object::destroy(id);
    }
}

The key capability in the struct definition is essential. It allows the resource to be stored in accounts and transferred via the standard library functions. Without key, the resource cannot be held by an account, limiting its utility as a transferable asset.

Implement programmable transaction blocks

Programmable Transaction Blocks (PTBs) allow you to bundle multiple operations into a single atomic unit. Instead of submitting separate transactions for each action, you compose a sequence of instructions that execute together. This approach reduces network overhead and ensures that dependent operations either all succeed or all fail, maintaining state consistency.

The Sui execution engine processes these instructions in order, passing resources from one instruction to the next without finalizing the state until the entire block completes. This composability is essential for complex workflows like atomic swaps, batch transfers, or multi-step asset minting.

1. Initialize the transaction builder

Start by creating a new transaction builder instance. This object acts as the container for your sequence of operations. You will chain methods onto this builder to define the logic that runs inside the block.

RUST
let builder = Transaction::begin();

2. Add resource transfers

Use the builder to move resources between accounts. Each transfer instruction takes the source object, the recipient address, and the amount. These instructions are queued but not executed until the block is signed and submitted.

RUST
let coin = builder.object(coin_object_id);
builder.transfer_objects(vec![coin], recipient_address);

3. Execute program logic

You can call smart contract functions within the PTB. These calls can read state, modify objects, or emit events. The results of earlier instructions are available to later ones, enabling complex conditional logic within the same atomic block.

RUST
let result = builder.execute_move_function(
    module_id,
    function_name,
    vec![coin],
    vec![],
);

4. Sign and submit the block

Once all instructions are chained, sign the transaction with the relevant private keys. The signed PTB is then broadcast to the network. The validator processes the entire block as a single unit, ensuring atomicity.

RUST
let tx = builder.finish();
let signed_tx = tx.sign(&private_key);
sui_client.execute_transaction_block(signed_tx).await?;

Test logic with the Move framework

Before deploying any smart contract, you must verify that your ownership rules hold up under scrutiny. Move’s type system prevents resource duplication by default, but your business logic needs explicit testing to ensure assets move correctly between accounts and are destroyed when intended.

We will walk through writing unit tests that simulate transaction execution. These tests allow you to catch logic errors early, ensuring that resources cannot be duplicated or lost, which is critical for maintaining the security guarantees Move provides.

1
Set up the test module

Every Move module includes a #[test] section for unit testing. Start by importing the TestScenario module, which provides a sandboxed environment to simulate multiple accounts. This setup allows you to create fake addresses and initialize resources without touching the mainnet or testnet.

Text
Text
use std::test;
use std::string;
use test::address;

module my_project::test_module {
    use std::test;
    use my_project::core::MyResource;
    
    #[test]
    fun test_resource_creation() {
        let scenario = test::begin(0x42);
        // Setup logic here
    }
}
2
Create and transfer resources

Use the TestScenario to create accounts and test resource movement. Initialize a resource in one account and attempt to transfer it to another. Move’s compiler ensures the resource is marked as key or store, but your test must verify that the transfer logic in your module actually moves the ownership correctly.

Text
Text
    #[test]
    fun test_transfer() {
        let scenario = test::begin(0x42);
        let account1 = test::address(0x1);
        let account2 = test::address(0x2);
        
        // Create resource for account1
        let resource = core::create_resource();
        core::transfer_resource(account1, resource);
        
        // Verify account1 has the resource
        assert!(core::has_resource(account1), 1);
    }
3
Verify destruction and uniqueness

A core feature of Move is that resources cannot be copied. Write a test that attempts to duplicate a resource and ensure the transaction fails. Additionally, test that resources are properly destroyed when they go out of scope or are explicitly dropped, preventing memory leaks or stuck assets.

Text
Text
    #[test]
    fun test_uniqueness() {
        let scenario = test::begin(0x42);
        let account = test::address(0x1);
        
        let resource = core::create_resource();
        // Move destroys the original reference
        core::use_resource(account, resource);
        
        // Attempting to use 'resource' again should fail compilation
        // or runtime if not handled by the type system
    }
4
Run the test suite

Execute your tests using the Move CLI. The command move test runs all functions marked with #[test] in your module. Review the output to ensure all tests pass. If a test fails, the error message will indicate which assertion failed, helping you pinpoint logic errors in ownership or transfer mechanisms.

Text
Text
$ move test
[1/4] Compiling my_project
[2/4] Running tests...
[3/4] Test Results: 4 passed, 0 failed
[4/4] Success!

Testing your Move code ensures that your smart contracts behave as expected before they go live. By following these steps, you can build confidence in your ownership logic and prevent costly errors on the blockchain.

Deploy to a testnet network

Before pushing your Move module to the mainnet, you must verify its behavior on a testnet. This step isolates your logic from real financial risk while confirming that resource ownership and access controls function as intended. We will use the Sui testnet as our primary example, though the workflow mirrors Aptos closely.

1
Connect to the testnet RPC endpoint

Ensure your Move toolchain is configured to target the testnet network rather than the local validator. In your Move.toml, set the network configuration to point to the Sui testnet RPC URL. This directs all subsequent commands to the public testnet infrastructure.

2
Fund your testnet wallet

Deployments require gas fees. Navigate to the Sui Faucet or Aptos Testnet Faucet and request test tokens using your public address. Without a funded account, the CLI will reject the publish transaction immediately. Verify the balance using sui client gas to ensure sufficient coverage for bytecode publishing.

3
Build and publish the module

Execute the publish command to upload your compiled bytecode. For Sui, run sui client publish --gas-budget 10000. This command compiles your Move sources, generates the module package, and broadcasts the transaction to the testnet. The CLI will return a transaction digest, which you can use to track the deployment status on a block explorer.

4
Verify resource integrity

Once published, interact with your module using a simple test script. Check that resources are created and destroyed according to your ability declarations. If your code attempts to duplicate a resource marked drop but not store, the transaction should fail. This confirms that your Move logic enforces scarcity correctly on a live network.

Pre-deployment checklist

  • Verify gas limits cover bytecode size.
  • Confirm testnet faucet tokens are in wallet.
  • Check address format matches network standard.
  • Ensure no local-only imports remain in code.

Common Move programming mistakes

Move’s ownership model prevents data races, but it requires strict discipline. Developers coming from Rust often struggle with the language’s resource-oriented nature, where values cannot be implicitly copied or dropped. Misunderstanding these rules leads to compilation errors that halt development before deployment.

Violating Ownership Rules

The most frequent error is attempting to use a resource after it has been moved. In Move, resources are unique and linear; they must be explicitly consumed or destroyed. If you try to access a variable after moving it to a struct or passing it to another function, the compiler will reject the code.

MOVE
public fun transfer_token(token: Token, recipient: address) {
    // Error: use of moved value 'token'
    log_token(token); 
    destroy(token);
}

Always ensure you are not referencing a value after it has been transferred. Treat resources like physical keys: once you hand them over, you no longer have access to them.

Misusing Dynamic Fields

Dynamic fields allow structs to hold other structs, but they require careful handling. A common pitfall is forgetting to destroy the dynamic field when the parent struct is destroyed. If you don’t explicitly destroy the nested resource, it leaks, causing memory issues on-chain.

To avoid these mistakes, always trace the lifecycle of your resources. Ensure every move operation is accounted for and that dynamic fields are properly cleaned up when their parent is no longer needed.