Introduction
Welcome to Move. Move is a programming language designed by Meta (formerly Facebook) for building secure and reliable smart contracts and digital assets. It is the language behind the Diem blockchain. It was later adopted by Aptos and Sui blockchains.
Move is designed with a focus on safety and flexibility. It introduces several novel concepts that make it well-suited for blockchain applications, including resource types, strong static typing, and a modular architecture. Move takes its cue from Rust by using resource types with move (hence the name) semantics as an explicit representation of digital assets.
Move for Stylus makes possible to compile and run Move programs in the Arbitrum blockchain by translating it to WebAssembly and integrating it with Stylus VM.
This book aims to provide a comprehensive introduction to the Move programming language. It covers the fundamental concepts of Move, including its syntax, semantics, and key features.
Getting Started
Move requires an environment to compile, deploy, and interact with Move smart contracts. In this chapter we will cover all the prerequisites to get started: How to install the compiler, how to use the CLI to build and deploy contracts, how to setup your IDE.
Install Move-Stylus CLI
Prerequisites
To install the Move-Stylus CLI, you need to have Rust and git installed on your machine.
Installation
For the moment, the only way to install the Stylus CLI is by building it from source. You can do this by cloning the Stylus repository and using Cargo to build and install the CLI.
Install cargo-stylus
RUSTFLAGS="-C link-args=-rdynamic" cargo install --force --version 0.6.3 cargo-stylus
Cloning the Repository
git clone https://github.com/rather-labs/move-stylus/
Building and Installing the compiler and CLI
cd move-stylus
cargo install --locked --path crates/move-cli
Verify Installation
After the installation is complete, you can verify that the Move-Stylus CLI is installed correctly by checking its version:
move-stylus --version
You should see output similar to:
move-stylus 0.1.0
Note
You may need to restart your terminal or add Cargo’s bin directory to your
PATHif the command is not found. The default location for Cargo’s bin directory is$HOME/.cargo/bin.
Create a New Project
To create a new Stylus project, you can use the move-stylus CLI tool that you installed in the previous step. Open your terminal and run the following command:
move-stylus new counter
This command will create a new directory called counter with the basic structure of a Stylus project with all the necessary files and folders to get you started:
counter
├── Move.toml
├── .gitignore
└── sources/
└── counter.move
Where:
Move.toml: This is the manifest file for your Stylus project. It contains metadata about your project, such as name, and dependencies..gitignore: This file specifies which files and directories should be ignored by Git version control.sources/: This directory contains the Move source files for your project. Thecounter.movethe first module of the package.
In the next section we are going to implement a simple counter smart contract in the counter.move file.
Build and Test
Before building anything, we must implement our contract logic. Copy the following code into the sources/counter.move file created in the previous step:
module counter::counter;
use stylus::{
tx_context::TxContext,
object::{Self, UID},
transfer::{Self}
};
#[test_only]
use stylus::test_scenario;
/// Initial value for new counters.
const INITIAL_VALUE: u64 = 1;
/// A simple counter object.
public struct Counter has key {
id: UID,
owner: address,
value: u64
}
/// Create a new counter with initial value.
entry fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: INITIAL_VALUE,
});
}
/// Increment a counter by 1.
entry fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
/// Read counter.
#[ext(abi(view))]
entry fun read(counter: &Counter): u64 {
counter.value
}
/// Set value (only runnable by the Counter owner)
entry fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == ctx.sender(), 0);
counter.value = value;
}
//
// Unit tests
//
#[test]
fun test_increment() {
let mut ctx = test_scenario::new_tx_context();
let uid = object::new(&mut ctx);
let mut c = Counter { id: uid, owner: @0x1, value: 0 };
c.increment();
assert!(c.value == 1);
test_scenario::drop_storage_object(c);
}
#[test]
fun test_read() {
let mut ctx = test_scenario::new_tx_context();
let uid = object::new(&mut ctx);
let c = Counter { id: uid, owner: @0x2, value: 42 };
let v = c.read();
assert!(v == 42);
test_scenario::drop_storage_object(c);
}
#[test]
fun test_set_value_by_owner() {
let mut ctx = test_scenario::new_tx_context();
let uid = object::new(&mut ctx);
let mut c = Counter {
id: uid,
owner: test_scenario::default_sender(),
value: 5
};
c.set_value(99, &ctx);
assert!(c.value == 99);
test_scenario::drop_storage_object(c);
}
#[test, expected_failure]
fun test_set_value_wrong_owner_should_fail() {
test_scenario::set_sender_address(@0x5);
let mut ctx = test_scenario::new_tx_context();
let uid = object::new(&mut ctx);
let mut c = Counter { id: uid, owner: @0x4, value: 5 };
c.set_value(99, &ctx);
assert!(c.value == 99);
test_scenario::drop_storage_object(c);
}
There are a lot of new concepts in this code that we will cover in future sections. For now, just note that we have defined a simple counter contract with the ability to create, increment, read, and set the value of a counter. We have also included some unit tests to verify the functionality of our contract.
Building the Project
Now that we have our contract code in place, we can build the project using the move-stylus CLI. Open a terminal, navigate to the root directory of your project (counter), and run the following command:
move-stylus build
You should see output indicating that the build was successful:
INCLUDING DEPENDENCY StylusFramework
INCLUDING DEPENDENCY MoveStdlib
BUILDING counter
COMPILING counter
After building, the code is ready to be deployed to a blockchain or tested locally. We will cover deployment in a later section.
Running Tests
To ensure that our contract works as expected, we can run the unit tests we defined earlier. Use the following command to run the tests:
move-stylus test
You should see output indicating that all tests have passed:
COMPILING counter
Running 0x0::counter tests (./sources/counter.move)
0x0::counter::test_increment ... PASSED
0x0::counter::test_read ... PASSED
0x0::counter::test_set_value_by_owner ... PASSED
0x0::counter::test_set_value_wrong_owner_should_fail [expected failure] ... PASSED
Total Tests : 4, Passed: 4, Failed: 0.
Deploy and Interact
In this section, we will guide you through the process of deploying your Move smart contract to the Arbitrum’s Nitro devnode using the move-stylus CLI and interacting with it using foundry’s cast command.
Prerequisites
- To run Arbitrum’s Nitro devnode, check the official documentation.
- To install foundry, follow the instructions in the foundry book.
Deploying the Contract
To deploy the counter contract, make sure you are running the Arbitrum Nitro devnode. Open a terminal, navigate to the root directory of your project (counter).
First build the project:
move-stylus build
And then run the following command:
move-stylus deploy --contract-name counter --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659
Warning
The private key used in this example is for demonstration purposes only. Do not use it in production or with real funds.
It corresponds to the address
0x3f1Eae7D46d88F08fc2F8ed27FCb2AB183EB2d0E, which is pre-funded in the Nitro devnode.
You should see output similar to:
Deploying contract 'counter' to endpoint 'http://localhost:8547' using provided private key...
stripped custom section from user wasm to remove any sensitive data
contract size: 1.9 KiB (1959 bytes)
wasm data fee: 0.000058 ETH (originally 0.000049 ETH with 20% bump)
deployed code at address: 0x525c2aba45f66987217323e8a05ea400c65d06dc
deployment tx hash: 0x641b8e7bf3207d61e011a1bc4a18c92d912f958d3be32911a50d8cd6296cff6b
contract activated and ready onchain with tx hash: 0xea84d26e12e89968c1f929263a27d92cdf8ea142e89a3f9c718ecb6a62444c6f
Take note of the deployed contract address (in this example, 0x525c2aba45f66987217323e8a05ea400c65d06dc), as you will need it to interact with the contract.
To make things easier, you can save the contract address in an environment variable:
export CONTRACT_ID=0x525c2aba45f66987217323e8a05ea400c65d06dc
Interacting with the Contract
Now that the contract is deployed, you can interact with it using foundry’s cast command.
Creating a counter
To create a new counter, use the following command:
cast send $CONTRACT_ID "create()" --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659 --rpc-url http://localhost:8547
You should see output similar to:
blockHash 0x10be8200fb69a6c61df508cf6294b3da65edca74357237e4ef38e856fcd1f5ce
blockNumber 10
contractAddress
cumulativeGasUsed 99090
effectiveGasPrice 100000000
from 0x3f1Eae7D46d88F08fc2F8ed27FCb2AB183EB2d0E
gasUsed 99090
logs [{"address":"0x525c2aba45f66987217323e8a05ea400c65d06dc","topics":["0x7445ca7ce975ec254db4c84b6d772e9c3d7c153ca8fb13d5f180e5cf000250f3","0x70a9a5599349d999ce7abadd4bb09639e9f1a364000543ad6458b8befbcdba4e"],"data":"0x","blockHash":"0x10be8200fb69a6c61df508cf6294b3da65edca74357237e4ef38e856fcd1f5ce","blockNumber":"0xa","transactionHash":"0xff2fd596fc75f46847264c259a855745461b5c4bba89446910b6d2e92b0ad7e6","transactionIndex":"0x1","logIndex":"0x0","removed":false}]
logsBloom 0x40000000000000000000000000000000000000000000000000004000000000100000000000000000000000000000000000000000000000020000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000200000000000040000000000000000000000000000000000000008000000000000000000000000000000000
root
status 1 (success)
transactionHash 0xff2fd596fc75f46847264c259a855745461b5c4bba89446910b6d2e92b0ad7e6
transactionIndex 1
type 2
blobGasPrice
blobGasUsed
to 0x525c2aBA45F66987217323E8a05EA400C65D06DC
gasUsedForL1 0
l1BlockNumber 0
timeboosted false
From this output, it’s important to extract the counter ID from the logs. In this example, the counter ID is 0x70a9a5599349d999ce7abadd4bb09639e9f1a364000543ad6458b8befbcdba4e which is the second topic in the logs array.
We will explain what this ID is in UID and ID section. For now, you only need to know that this ID uniquely identifies the counter you’ve just created.
You can save the counter ID in an environment variable for easier access:
export COUNTER_ID=0x70a9a5599349d999ce7abadd4bb09639e9f1a364000543ad6458b8befbcdba4e
Reading the counter value
To read the value of the counter, use the following command:
cast call $CONTRACT_ID "read(bytes32)(uint64)" $COUNTER_ID --rpc-url http://localhost:8547
You should see output similar to:
1
Which is the initial value of the counter.
Incrementing the counter
To increment the counter, use the following command:
cast send $CONTRACT_ID "increment(bytes32)" $COUNTER_ID --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659 --rpc-url http://localhost:8547
Setting the counter value
To set the counter to a specific value, use the following command (for example, setting it to 42):
cast send $CONTRACT_ID "setValue(bytes32,uint64)" $COUNTER_ID 42 --private-key 0xb6b15c8cb491557369f3c7d2c287b053eb229daa9c22138887752191c9520659 --rpc-url http://localhost:8547
Note
You can perform a read operation after each of these transactions to verify that the counter value has been updated correctly.
Concepts
This section covers the fundamental concepts you need to understand when working with Stylus and Move. These concepts form the foundation for building more complex smart contracts and applications on the Stylus platform.
Package
In Move, smart contracts are organized into Packages. A contract is the primary unit of deployment; once uploaded to the blockchain, it is assigned a unique, immutable address that others can use to call its functions.
A package acts as a container for modules (i.e. contracts), which serve as distinct namespaces for defining types (structs) and logic (functions).
package 0x...
module a
struct A1
fun hello_world()
module b
struct B1
fun hello_package()
Package Structure
Locally, a package is a directory with a Move.toml file and a sources directory. The Move.toml file - called the “package manifest” - contains metadata about the package, and the sources directory contains the source code for the modules. Package usually looks like this:
sources/
my_module.move
another_module.move
...
tests/
...
examples/
using_my_module.move
Move.toml
Package Address
Each package is identified by a unique address. This address is only relevant for internal use, to distinguish and reference packages inside the project. This addresses do not represent on chain addresses. Some special packages have reserved addresses.
Package Manifest
The Move.toml is a manifest file that describes the package and its dependencies. It is written in TOML format and contains multiple sections, the most important of which are [package], [dependencies] and [addresses].
[package]
name = "my_project"
version = "0.0.0"
edition = "2024"
[dependencies]
Example = { git = "https://github.com/example/example.git", subdir = "path/to/package", rev = "framework/testnet" }
[addresses]
std = "0x1"
[dev-addresses]
alice = "0xB0B"
Sections
Package
The [package] section is used to describe the package. None of the fields in this section are published on chain, but they are used in tooling and release management.
name- the name of the package when it is imported;version- the version of the package, can be used in release management;
Dependencies
The [dependencies] section is used to specify the dependencies of the project. Each dependency is specified as a key-value pair, where the key is the name of the dependency, and the value is the dependency specification. The dependency specification can be a git repository URL or a path to the local directory.
# git repository
Example = { git = "https://github.com/example/example.git", subdir = "path/to/package", rev = "framework/testnet" }
# local directory
StylusFramework = { local = "../stylus-framework/" }
Packages also import addresses from other packages. For example, the Sui dependency adds the std and sui addresses to the project. These addresses can be used in the code as aliases for the addresses.
Dev-dependencies
Resolving Version Conflicts with Override
Sometimes dependencies have conflicting versions of the same package. For example, if you have two dependencies that use different versions of the Example package, you can override the dependency in the [dependencies] section. To do so, add the override field to the dependency. The version of the dependency specified in the [dependencies] section will be used instead of the one specified in the dependency itself.
[dependencies]
Example = { override = true, git = "https://github.com/example/example.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
Addresses
The [addresses] section is used to add aliases for the addresses. Any address can be specified in this section, and then used in the code as an alias. For example, if you add alice = "0xA11CE" to this section, you can use alice as 0xA11CE in the code.
Dev-addresses
Address
Address is a unique identifier of a location on the blockchain. It is used to identify contracts, accounts, and objects. Address has a fixed size of 32 bytes and is usually represented as a hexadecimal string prefixed with 0x. Addresses are case insensitive.
0xe51ff5cd221a81c3d6e22b9e670ddf99004d71de4f769b0312b68c7c4872e2f1
The address above is an example of a valid address. It is 64 characters long (32 bytes) and prefixed with 0x.
Move also has reserved addresses that are used to identify standard packages and objects. Reserved addresses are typically simple values that are easy to remember and type. For example, the address of the Standard Library is 0x1. Addresses, shorter than 32 bytes, are padded with zeros to the left.
Here are some examples of reserved addresses:
0x1- address of the Sui Standard Library (aliasstd)0x2- address of the Stylus Framework (aliasstylus)
Comparison with Solidity (EVM)
If you are coming from an Ethereum background, it is important to note two key differences:
-
Size: Move addresses are 32 bytes, whereas Solidity addresses are 20 bytes.
-
Padding & Alignment: In the EVM, addresses are often “padded” to 32 bytes during ABI encoding for word alignment, but the underlying identity is only 20 bytes. In Sui, the full 32 bytes represent the actual identity of the account or object.
To maintain interoperability, Move addresses can be thought as 32-byte “containers” where only the least significant 20 bytes hold the actual EVM address. The most significant 12 bytes are strictly left-padded with zeros.
Account
An account is a way to identify a user. An account is generated from a private key, and is identified by an address. An account can own objects (within the contract, more on that later), and can send transactions.
Every transaction has a sender and a signer, and the sender is identified by an address. The sender is the address that initiates the transaction and acts as its origin on the blockchain, while the signer is the authority that provides the cryptographic signature to authorize the action.
Move Basics
This chapter introduces the fundamental syntax of the Move language. It explores core elements such as types, modules, functions, and control flow. This chapter is set apart from storage models or blockchain contexts, focusing instead on the core elements of the language.
Modules
A module is the fundamental unit of code organization in Move. It provides a mechanism to group related functionality and enforce isolation. By default, all members within a module are private, ensuring encapsulation and controlled access. In this section, we will cover how to define a module, declare its members, and reference it from other modules.
Module Declaration
Modules in Move are declared using the module keyword, followed by the package address, the module name, a semicolon, and the module body. Module names must follow the snake_case convention—lowercase letters with underscores separating words—and must be unique within the package.
module <package_address>::<module_name>;
If you need to declare more than one module in a file, you must use module block syntax.
module <package_address>::<module_name> {
// module body
}
Typically, each file in the sources/ directory defines a single module. The file name must correspond to the module name; for instance, a counter module should reside in a file named counter.move.
Structs, Functions, Constants and Imports are all declared within the module body.
Module Members
Module members are defined within the body of a module. They can include data structures, functions, and constants. The example below demonstrates a simple module that declares a struct, a function, and a const value:
module book::counter;
const INITIAL_COUNT: u64 = 0;
public struct Counter has drop {
value: u64
}
fun create(): Counter {
Counter { value: INITIAL_COUNT }
}
/// Increment a counter by 1.
fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
/// Read counter.
fun read(counter: &Counter): u64 {
counter.value
}
Address and Named Address
A module address in Move can be specified in two ways:
- Address literal — written directly, without requiring the
@prefix.module 0x1::my_module; - Named address — defined in the
[addresses]section of the Package Manifest.module book::my_module;
For example, both representations above resolve to the same value because the Move.toml includes the entry:
[addresses]
book = "0x0"
Comments
Comments provide a way to annotate or document code. They are ignored by the compiler and do not produce any compiled WASM output. Common uses include explaining logic, leaving notes for collaborators, temporarily disabling code, or generating documentation.
Line Comments
A line comment begins with //. Everything following // on that line is ignored by the compiler:
// This is a line comment
let x = 10; // The compiler ignores this note
Block Comments
Block comments allow you to comment out one or more lines of code. They begin with /* and end with */. Everything between these delimiters is ignored by the compiler. Block comments can span multiple lines, a single line, or even part of a line.
/* This is a block comment
spanning multiple lines */
let x = 10;
/* You can also use them on a single line */
let y = 20;
/* Or even inline */ let z = 30; /* ignored */
Doc Comments
Documentation comments (///) are used to generate API documentation directly from source code. They resemble block comments but are placed immediately before the definition of the item they describe. The compiler interprets them as structured documentation rather than ignoring them entirely.
/// Represents a simple item with a value
struct Item has copy, drop {
value: u64,
}
/// Creates a new `Item` with the given value
public fun new_item(x: u64): Item {
Item { value: x }
}
Whitespace
In Move, whitespace characters —such as spaces, tabs, and newlines— do not affect program semantics. They are used solely to improve readability and code formatting, without altering the behavior of the program.
Primitive Types
Move provides a set of built-in primitive types for representing simple values. These types form the foundation upon which all other types are constructed. The primary primitive types are:
- Booleans
- Unsigned integers
- Addresses (covered in the next section)
Before exploring each primitive type in detail, it is useful to understand how variables are declared and assigned in Move.
Variables and Assignment
Variables are declared using the let keyword. By default, variables are immutable, meaning their values cannot be changed after initialization. To declare a mutable variable, the mut keyword must be added before the variable name.
let <variable_name>[: <type>] = <expression>;
let mut <variable_name>[: <type>] = <expression>;
Where:
<variable_name>is the name of the variable being declared.<type>is an optional type annotation specifying the variable’s type.<expression>is the value to be assigned to the variable.
Example
// Immutable variable
let x: u64 = 10;
// Mutable variable
let mut y: u64 = 20;
y = y + 1; // allowed because y is mutable
Booleans
The bool type represents a boolean value, which can be either true or false. These are reserved keywords in Move. Since the compiler can infer the type directly from the literal value, it is not necessary to explicitly annotate booleans with their type.
let flag = true; // type inferred as bool
let is_valid = false;
Unsigned Integers
Move provides a set of unsigned integer types with fixed bit widths, ranging from 8 bits to 256 bits. These types are used to represent non-negative integer values and differ in the maximum value they can store.
The available integer types are:
u8— 8-bit unsigned integeru16— 16-bit unsigned integeru32— 32-bit unsigned integeru64— 64-bit unsigned integeru128— 128-bit unsigned integeru256— 256-bit unsigned integer
Example
let small: u8 = 255; // maximum value for u8
let medium: u64 = 1_000; // u64 can hold larger values
let large: u256 = 1_000_000_000; // u256 supports very large integers
Integer Literals and Type Inference
Boolean literals such as true and false are unambiguous and always represent values of type bool. In contrast, integer literals (e.g., 42) can correspond to any of the available unsigned integer types.
In most cases, the compiler infers the type automatically, defaulting to u64 when no additional context is provided. However, there are situations where type inference is insufficient, and an explicit type annotation is required. This can be achieved in two ways:
- Type annotation during assignment
- Type suffix applied directly to the literal
Examples
// Compiler infers type as u64
let a = 42;
// Explicit type annotation
let b: u8 = 42;
// Type suffix
let c = 42u8;
let d = 1000u128;
Arithmetic Operations
Move supports the standard arithmetic operations for unsigned integers: addition, subtraction, multiplication, division, and modulus (remainder). Each operation has well-defined semantics and may abort under specific conditions.
| Syntax | Operation | Aborts If |
|---|---|---|
+ | Addition | Result exceeds the maximum value of the type |
- | Subtraction | Result is less than zero |
* | Multiplication | Result exceeds the maximum value of the type |
% | Modulus | Divisor is 0 |
/ | Division | Divisor is 0 |
Example
let a: u64 = 10;
let b: u64 = 3;
let sum = a + b; // 13
let diff = a - b; // 7
let product = a * b; // 30
let quotient = a / b; // 3 (truncating division)
let remainder = a % b; // 1
Bitwise Operations
Integer types in support bitwise operations, which treat values as sequences of bits (0 or 1) rather than numerical integers. Bitwise operations do not abort.
| Syntax | Operation | Description |
|---|---|---|
& | Bitwise AND | Performs a boolean AND on each bit pairwise |
| | Bitwise OR | Performs a boolean OR on each bit pairwise |
^ | Bitwise XOR | Performs a boolean exclusive OR on each bit pairwise |
Example
let a: u8 = 0b1010;
let b: u8 = 0b1100;
let and_result = a & b; // 0b1000
let or_result = a | b; // 0b1110
let xor_result = a ^ b; // 0b0110
Bit Shifts
Each integer type supports bit shifts. The right-hand side operand (the number of bits to shift) must always be a u8.
Bit shifts can abort if the shift amount is greater than or equal to the bit width of the type (8, 16, 32, 64, 128, or 256).
| Syntax | Operation | Aborts if |
|---|---|---|
>> | Shift Right | Shift amount ≥ size of the integer type |
<< | Shift Left | Shift amount ≥ size of the integer type |
Example
let a: u8 = 0b0001_0000;
let left_shift = a << 2; // 0b0100_0000
let right_shift = a >> 2; // 0b0000_0100
Comparisons
Integer types are the only types in Move that support comparison operators. Both operands must be of the same type; otherwise, explicit casting is required. Comparison operations do not abort.
| Syntax | Operation |
|---|---|
< | less than |
> | greater than |
<= | less than or equal to |
>= | greater than or equal to |
Example
let a: u64 = 10;
let b: u64 = 20;
let is_less = a < b; // true
let is_equal = a == b; // false
Equality
All integer types support equality (==) and inequality (!=) operations. Both operands must be of the same type; otherwise, explicit casting is required. Equality operations do not abort.
| Syntax | Operation |
|---|---|
== | equal |
!= | not equal |
Example
let x: u16 = 42;
let y: u16 = 42;
let z: u16 = 7;
let eq = x == y; // true
let ne = x != z; // true
Casting
Move supports explicit casting between integer types using the as keyword. This allows values of one integer type to be converted into another.
<expression> as <type>
Where:
<expression>is the value to be cast.<type>is the target integer type.
Parentheses may be required to avoid ambiguity when casting within larger expressions.
Example
// Basic casting
let x: u8 = 42;
let y: u16 = x as u16;
// Casting inside an expression (requires parentheses)
let z = 2 * (x as u16); // parentheses prevent ambiguity
Move does not permit silent overflow or underflow in arithmetic operations. If an operation produces a value outside the valid range of the type, the program will raise a runtime error. This behavior is a deliberate safety feature designed to prevent unexpected results and ensure that integer arithmetic remains predictable and secure.
Address Type
Move uses a special type called address to represent blockchain addresses. It is a 32-byte value capable of representing any address on the chain.
Addresses can be written in two forms:
- Hexadecimal addresses prefixed with
0x - Named addresses defined in
Move.toml
// address literal
let value: address = @0x1;
// named address registered in Move.toml
let value = @std;
let other = @stylus;
An address literal begins with the @ symbol followed by either a hexadecimal number or an identifier:
- The hexadecimal number is interpreted as a 32-byte value.
- The identifier is resolved in the
Move.tomlfile and replaced with the corresponding address by the compiler.
If the identifier is not found in Move.toml, the compiler will throw an error.
Address Length
In EVM, the blockchain addresses are typically 20 bytes long. However, Move’s address type is 32 bytes long to ensure compatibility.
When working with EVM addresses in Move, it is common to use the lower 20 bytes of the 32-byte address type. The higher 12 bytes are usually set to zero.
For example, the UID types internally contains an address:
/// References a object ID
public struct ID has copy, drop, store {
bytes: address,
}
/// Globally unique IDs that define an object's ID in storage. Any object, that is a struct
/// with the `key` ability, must have `id: UID` as its first field.
public struct UID has store {
id: ID,
}
Since addresses are 32 bytes long, the UID type can represent any object ID in the Move storage system.
Warning
When interacting with EVM contracts, it is important to ensure that the addresses are correctly formatted and that only the lower 20 bytes are present.
i.e:
let address: address = @0x1234567890abcdef1234567890abcdef12345678;
Signer Type
signer is a built-in Move type. A signer represents a capability that allows its holder to act on behalf of a specific address. Conceptually, the native implementation can be thought of as:
struct signer has drop { a: address }
A signer holds the address which signed the transaction being executed.
Comparison to address
A Move program can freely create any address value without special permission by using address literals:
let a1 = @0x1;
let a2 = @0x2;
// ... and so on for every other possible address
However, creating a signer value is restricted. A Move program cannot arbitrarily create a signer for any address. Instead, a signer can only be obtained through entry functions that are invoked as part of a transaction signed by the corresponding address.
entry fun example(s: signer) {
/// Do something with signer
}
Warning
Only one signer can be passed to an entry function, representing the address that signed the transaction. Attempting to pass multiple signers or create signers for arbitrary addresses will result in a compilation error.
Note
In an EVM context, the signer corresponds to the
tx.originvalue.
Expressions
In programming languages, an expression is a unit of code that evaluates to a value. In Move, almost everything is an expression, with the sole exception of the let statement, which is a declaration rather than an expression.
Expressions are sequenced using semicolons (;). If no expression follows a semicolon, the compiler automatically inserts the unit value (), which represents an empty expression.
Literals
A literal is a fixed value written directly in source code. Literals are commonly used to initialize variables or pass constant values as arguments to functions.
Types of Literals
- Boolean values
true,false - Integer values
Examples:
0,1,123123 - Hexadecimal values
Numbers prefixed with
0xrepresent integers in hexadecimal form. Examples:0x0,0x1,0x123 - Byte vector values
Prefixed with
b, representing a sequence of bytes. Example:b"bytes_vector" - Byte values
Hexadecimal literals prefixed with
x, representing raw byte sequences. Example:x"0A"
Example
let flag = true; // Boolean literal
let count = 123; // Integer literal
let hex_num = 0xFF; // Hexadecimal literal
let bytes = b"hello world"; // Byte vector literal
let raw_byte = x"0A"; // Byte vector literal
let vec_literal = vector[1, 2, 3]; // vector[] is a vector literal
Operators
Operators are used to perform arithmetic, logical, and bitwise operations on values. Since these operations always produce values, they are considered expressions.
Example
// Arithmetic expression
let sum = 1 + 2; // 1 + 2 is an expression
let sum = (1 + 2); // same expression with parentheses
// Logical expression
let is_true = true && false; // true && false is an expression
let is_true = (true && false); // same expression with parentheses
Blocks
A block in Move is a sequence of statements and expressions enclosed in curly braces {}. The block itself is an expression, and its value is determined by the last expression inside the block.
Note
The final expression must not end with a semicolon, otherwise the block evaluates to the unit value
().
Example
// Block returning the value of its last expression
let x = {
let a = 10;
let b = 20;
a + b // last expression, no semicolon
}; // x = 30
// Block returning unit ()
let y = {
let a = 5;
a + 2;
}; // y = ()
Function Calls
A function call is an expression. When invoked, it executes the function body and returns the value of the last expression in that body, provided the final expression does not end with a semicolon. If the last expression ends with a semicolon, the function returns the unit value ().
Example
fun add(x: u64, y: u64): u64 {
x + y
}
fun log_value(x: u64): () {
x;
}
// Function calls
let result = add(2, 3); // result = 5
let unit_val = log_value(10); // unit_val = ()
Control Flow Expressions
Control flow expressions determine how execution proceeds within a program. In Move, they are also expressions, meaning they evaluate to a value. The value returned depends on the branch or path taken.
// if is an expression, so it returns a value.
// If there are 2 branches, the types of the branches must match.
if (bool_expr) expr1 else expr2;
// while is an expression, but it returns `()`.
while (bool_expr) { expr; };
// loop is an expression, but returns `()`.
loop { expr; break };
// Example with break returning a value
let val = loop {
let x = 5;
break x * 2; // loop returns 10
};
Structs
Move’s type system is particularly powerful when defining custom types. A struct lets developers model domain‑specific data structures that encapsulate both state and behavior. This makes it possible to design types that align closely with application requirements, beyond primitive values.
A custom type is declared using the struct keyword followed by the type name. The body of the struct contains its fields, each written in the form field_name: field_type. Fields must be separated by commas, and they can be of any type, including primitives, generics, or other structs. This flexibility allows developers to compose complex data models that reflect the domain requirements of their application.
Note
Move does not support recursive structs, meaning a struct cannot contain itself as a field.
/// A struct representing an author.
public struct Author {
/// The name of the author.
name: String,
}
/// A struct representing a book.
public struct Book {
/// The title of the book.
title: String,
/// The author of the book. Uses the `Author` type.
author: Author,
/// The year the book was published.
year: u16,
/// Whether the book is the author’s first publication.
is_first: bool,
/// The edition number of the book, if any.
edition: Option<u16>,
}
In this example, we define a Book struct with five fields. The title field is of type String, the author field is of type Author, the year field is of type u16, the is_first field is of type bool, and the edition field is of type Option<u16>. The edition field is optional, allowing you to represent books that may not have a specific edition number.
By default, the fields of a struct are private to the module in which the struct is defined. Direct field access from other modules is not allowed. To enable controlled access, the defining module must expose public functions that read or modify the fields.
Creating and Using an Instance
Once a struct has been defined, it can be instantiated using the syntax: StructName { field1: value1, field2: value2, ... }. The order of fields in the initializer does not matter, but every field must be provided.
// Creating an instance of the Author struct
let author = Author { name: b"Jane Doe".to_string() };
In the example above, we create an instance of the Author struct by providing a value for the name field. To access the fields of a struct, you can use the . operator.
// Accessing the name field of the Author struct
let author_name = author.name;
Only the module that defines the struct can directly access its fields (both mutably and immutably). Other modules must use public functions provided by the defining module to read or modify the fields.
Unpacking a struct
Struct values are non‑discardable by default. This means that once a struct is initialized, it must be used—either stored or unpacked into its constituent fields. Unpacking refers to deconstructing a struct into its fields so they can be accessed directly. The syntax uses the let keyword, followed by the struct name and the field names to bind each field to a local variable.
// Unpacking the Author struct
let Author { name } = author;
In this example, we unpack the author instance of the Author struct, binding the name field to a local variable called name. After unpacking, you can use the name variable directly in your code. Because the value is not used, the compiler will raise a warning. To avoid this, you can use the underscore _ to indicate that the variable is intentionally unused.
// Unpacking the Author struct and ignoring unused fields
let Author { name: _ } = author;
Struct with unnamed fields
Move also supports structs with unnamed fields, often referred to as tuple structs. These structs are defined similarly to regular structs but use parentheses instead of curly braces to enclose the fields. Each field is accessed by its index rather than by name.
/// A struct representing a point in 2D space.
public struct Point(u64, u64);
In this example, we define a Point struct with two unnamed fields representing the x and y coordinates. To create an instance of this struct, you would use the following syntax:
// Creating an instance of the Point struct
let point = Point(10, 20);
To access the fields of a tuple struct, you use the dot . operator followed by the index of the field (starting from 0).
// Accessing the fields of the Point struct
let x = point.0;
let y = point.1;
Note
In tuple structs, the order of fields matters, as they are accessed by their index.
Abilities introduction
Move’s type system supports abilities, which define the behaviors that instances of a type are permitted to perform. They are specified directly in the struct.
In the previous section, we introduced struct definitions and demonstrated how to create and work with them. Notice that instances of the Author and Book structs had to be unpacked for the code to compile. This is the default behavior of a struct without any declared abilities.
Note
Throughout this manual, you will encounter chapters titled
Ability: <name>, where<name>refers to a specific ability. Each of these chapters provides a detailed explanation of the ability.
Syntax
Abilities are declared in the struct definition using the has keyword, followed by a comma-separated list of abilities. The syntax is as follows:
struct <StructName> has <ability1>, <ability2>, ... {
// struct fields
}
Where:
<StructName>is the name of the struct being defined.<ability1>, <ability2>, ...are the abilities assigned to the struct.
Move supports the following abilities:
copy— allows the struct to be duplicated. Explained in Ability: copy.drop— allows the struct to be discarded without being used. Explained in Ability: drop.key— allows the struct to be stored in storage. Explained in Ability: key.store— allows the struct to be stored in structs with thekeyability. Explained in Ability: store.
No abilities
By default, if no abilities are specified, the struct has none of the abilities. This means the struct cannot be copied, discarded, or stored in strorage. They can only be passed around and requires special handling to use them. We call those structs Hot Potato, which is a powerful pattern discussed in more detail in Hot Potato Pattern chapter.
Ability: drop
The drop ability allows instances of a struct to be discarded without being used. This means that when a value of a struct type with the drop ability goes out of scope, it can be safely ignored without any special handling. This is a safety feature in Move language that ensures that all assets are properly managed. Ignoring a value with the drop ability results in a compilation error, preventing accidental loss of resources.
module book::drop_ability;
/// This struct has the `drop` ability.
public struct IgnoreMe has drop {
a: u8,
b: u8,
}
/// This struct does not have the `drop` ability.
public struct NoDrop {}
#[test]
// Create an instance of the `IgnoreMe` struct and ignore it.
// Even though we constructed the instance, we don't need to unpack it.
fun test_ignore() {
let no_drop = NoDrop {};
let _ = IgnoreMe { a: 1, b: 2 }; // no need to unpack
// The value must be unpacked for the code to compile.
let NoDrop {} = no_drop; // OK
}
The drop ability is commonly applied to custom collection types to avoid the need for explicit cleanup when the collection is no longer required. For instance, the vector type includes the drop ability, which allows a vector to be ignored without further handling. The most distinctive aspect of Move’s type system, however, is that types can be defined without drop. This guarantees that assets must be explicitly managed and cannot be silently ignored.
Types with drop ability
All native types in Move have the drop ability. This includes primitive types like u8, u16, u32, u64, u128, u256, bool, and address, as well as vector<T> (when T has drop).
Standard library types such as Option<T> (when T has drop) and String have drop as well.
Importing Modules
Move supports modularity and code reuse through module imports. Modules within the same package can import each other, and new packages can depend on existing ones to access their modules. This section explains the basics of importing modules and using them in your code.
Importing a module
Modules defined in the same package can be imported using the use keyword followed by the module’s fully qualified name. The syntax is as follows:
use <package_address>::<module_name>;
Example
// File: sources/counter.move
module book::counter;
const INITIAL_COUNT: u64 = 0;
public struct Counter has drop {
value: u64
}
public fun create(): Counter {
Counter { value: INITIAL_COUNT }
}
/// Increment a counter by 1.
public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
/// Read counter.
public fun read(counter: &Counter): u64 {
counter.value
}
Another module defined in the same package can import and use the counter module as follows:
// File: sources/main.move
module book::main;
// Importing the counter module from the book package
use book::counter;
fun main() {
// Creating a new Counter instance using the create function from the counter module
let my_counter = counter::create();
// Incrementing the counter
counter::increment(&mut my_counter);
// Reading the current value of the counter
let value = counter::read(&my_counter);
}
In this example, we import the counter module from the book package. We then use the functions defined in the counter module to create, increment, and read a counter.
Note
To import an item (struct, function, constant, etc.) from another module, it must be declared with the
publickeyword (orpublic(package)—see visibility modifiers). For instance, theCounterstruct and thecreatefunction incountermodule are markedpublic, allowing them to be accessed inmain.
Importing members
You can also import specific members from a module using the use keyword followed by the fully qualified name of the member. The syntax is as follows:
use <package_address>::<module_name>::<member_name>;
Example
// File: sources/main.move
module book::main;
// Importing specific members from the counter module
use book::counter::{create, increment, read, Counter};
fun main(): Counter {
let my_counter = create();
increment(&mut my_counter);
let value = read(&my_counter);
my_counter
}
Imports can either be grouped using curly braces {} as shown above, or declared individually:
use book::counter::create;
use book::counter::increment;
use book::counter::read;
use book::counter::Counter;
Note
Importing individual function names in Move is uncommon, as overlapping names can lead to ambiguity. A better practice is to import the full module and call functions using the module path. Types, however, have unique identifiers and are best imported separately.
You can use the Self keyword in a group import to bring in both the module itself and its members. Self refers to the module as a whole, allowing you to import the module alongside its contents.
// File: sources/main.move
module book::main;
// Importing specific members from the counter module
use book::counter::{Self, Counter};
fun main(): Counter {
let my_counter = counter::create();
counter::increment(&mut my_counter);
let value = counter::read(&my_counter);
my_counter
}
Resolving Name Conflicts
When importing modules or members, name conflicts can arise if two imported items share the same name. To resolve such conflicts, Move allows you to use the as keyword to create an alias for the imported item.
// File: sources/main.move
module book::main;
// Importing specific members from the counter module
use book::counter::{Self as count, Counter as Count};
fun main(): Count {
let my_counter = count::create();
count::increment(&mut my_counter);
let value = count::read(&my_counter);
my_counter
}
Adding an External Dependency
To use modules from an external package, you need to declare the dependency in your manifest file.
[dependencies]
Remote = { git = "https://github.com/example/example-stylus.git", rev = "main", subdir = "packages/remote" }
Local = { local = "../local_packages/local" }
The [dependencies] section lists each package dependency. The entry key is the package name (e.g., Remote or Local), and the value is either a Git import or a local path. A Git import specifies the package URL, the subdirectory containing the package, and the revision, while a local path points to the relative directory of the package.
When you add a dependency, all of its own dependencies are also made available to your package. If a dependency is declared in the Move.toml file, the compiler will automatically fetch (and refetch) it during the build process.
Note
The standard library and the Stylus framework are automatically included as dependencies.
Importing Modules from Another Package
To import modules from another package, you first need to declare the dependency in your package’s manifest file (Move.toml). Once the dependency is declared, you can import modules from that package using the use keyword followed by the package name and module name.
Typically, packages specify their addresses in the [addresses] section. Instead of writing full addresses, you can use aliases. For instance, the std alias is defined in the Standard Library package and serves as a shorthand for 0x1 when accessing standard library modules.
Note
Module address names are defined in the
[addresses]section of theMove.tomlmanifest, not taken from the names listed in[dependencies].
Standard Library
The Move Standard Library offers functionality for native types and operations. It is a core collection of modules that do not interact with storage but instead provide essential tools for working with and manipulating data.
Exported address
The Standard Library is exported at address 0x1. It can also be used via the alias std.
Content
The Stylus Framework includes the following modules:
| Module | Description | Chapter |
|---|---|---|
std::string | Provides basic string operations | String |
std::ascii | Provides basic ASCII operations | String |
std::option | Implements Option<T> | Option |
std::vector | Native operations on the vector type | Vector |
std::bit_vector | Provides operations on bit vectors | - |
std::fixed_point32 | Provides the FixedPoint32 type | - |
Integers
The Move Standard Library provides a set of functions associated with integer types. These functions are split into multiple modules, each associated with a specific integer type. The modules should not be imported directly, as their functions are available on every integer value.
Note
All of the modules provide the same set of functions. Namely,
max,diff,divide_and_round_up,sqrtandpow.
| Module | Description |
|---|---|
std::u8 | Functions for the u8 type |
std::u16 | Functions for the u16 type |
std::u32 | Functions for the u32 type |
std::u64 | Functions for the u64 type |
std::u128 | Functions for the u128 type |
std::u256 | Functions for the u256 type |
Source Code
The source code of the Move Standard Library is available in the Move for Stylus repository.
Vector
Vectors are the built-in mechanism for storing collections of elements in Move. They resemble arrays in other programming languages, but with some key differences. This section provides an introduction to the vector type and its operations.
Syntax
The vector type is declared using the vector keyword followed by the element type in angle brackets. Elements can be any valid Move type, including other vectors.
Move also provides a vector literal syntax: you can construct vectors with the vector keyword followed by square brackets containing the elements, or leave the brackets empty to create an empty vector.
// A vector of unsigned 64-bit integers
let v1: vector<u64> = vector[1, 2, 3];
// A nested vector of booleans
let v2: vector<vector<bool>> = vector[vector[true, false], vector[false, true]];
// An empty vector of bytes
let empty_vec: vector<u8> = vector[];
The vector type is a built-in type in Move, so you do not need to import any modules to use it. Vector operations are defined in the std::vector module of the Standard Library, which is implicitly imported and can be used directly without explicit use import.
Operations
The standard library offers several methods for working with vectors. Some of the most commonly used operations include:
- push_back: Appends an element to the end of the vector.
- pop_back: Removes the last element from the vector.
- length: Returns the total number of elements in the vector.
- is_empty: Returns
trueif the vector contains no elements. - remove: Deletes the element at a specified index.
let mut v: vector<u64> = vector[];
// Adding elements to the vector
v.push_back(10);
v.push_back(20);
let len = v.length();
assert_eq!(len, 2);
assert_eq!(v.is_empty(), false);
let last_element = v.pop_back();
assert_eq!(last_element, 20);
Destroying a Vector of non-droppable types
A vector containing non-droppable types cannot be discarded. If you create a vector of types that lack the drop ability, the value must be handled explicitly. When such a vector is empty, the compiler enforces an explicit call to the destroy_empty function.
struct NonDroppable { }
fun destroy_vector_of_non_droppable() {
let mut v: vector<NonDroppable> = vector[];
// Perform operations on the vector...
// Explicitly destroy the empty vector
v.destroy_empty();
}
The destroy_empty function will fail at runtime if you call it on a non-empty vector.
Option
The Option type in Move is a powerful way to represent values that may or may not be present. It is similar to the concept of nullable types in other programming languages but provides a more explicit and type-safe way to handle optional values. Option is defined in the std::option module of the Standard Library as follows:
module std::option;
/// Abstraction of a value that may or may not be present.
public struct Option<Element> has copy, drop, store {
vec: vector<Element>
}
Note
The
std::optionmodule is implicitly imported in every module, so you don’t need to add an explicit import.
The Option type is a generic type parameterized by Element. It defines a single field, vec, which is a vector of Element. This vector can have a length of 0 or 1, representing the absence or presence of a value.
Note
Although
Optionis implemented as a struct containing a vector rather than an enum, this design exists for historical reasons—Optionwas introduced before Move supported enums.
The Option type has two variants:
- Some: holds a value.
- None: indicates no value.
Option provides a type-safe way to represent the absence of a value, eliminating the need for empty or undefined values.
In Practice
To illustrate why the Option type is useful, consider an application that collects user input and stores it in variables. Some fields are mandatory, while others are optional. For instance, a contact’s email address is optional. Using an empty string to represent the absence of an email address would require additional checks to distinguish between an empty string and a missing value. Instead, the Option type can be used to represent the email directly.
module book::contact_registry;
use std::string::String;
/// A struct representing a contact record.
public struct Contact has drop {
name: String,
email: Option<String>,
phone: String,
}
/// Create a new `Contact` struct with the given fields.
public fun register(
name: String,
email: Option<String>,
phone: String,
): Contact {
Contact { name, email, phone }
}
In the previous example, the email field is defined as Option<String>. This means it can either hold a String value wrapped in Some, or be explicitly empty with None. By using Option, the optional nature of the field is made explicit, removing ambiguity and avoiding the need for extra checks to distinguish between an empty string and a missing value.
Creating and Using Option values
To create an Option value, you can use the some and none constructors provided by the std::option module.
// Creates an Option<u64> with a value of 42
let mut opt = option::some(42);
assert!(opt.is_some());
// Creates an empty Option<u64>
let empty: Option<u64> = option::none();
assert!(empty.is_none());
let mut opt_2 = option::some(b"Alice");
// internal value can be `borrow`ed and `borrow_mut`ed.
assert_ref_eq!(opt_2.borrow(), &b"Alice");
// `option.extract` takes the value out of the option, leaving the option empty.
let inner = opt_2.extract();
// `option.is_none()` returns true if option is None.
assert_eq!(opt.is_none(), true);
String
Move does not support a built-in string type. Instead, it have two implementations in the standard library: The std::string module provides a UTF-8 encoded String type, while the std::ascii module provides an ASCII-only String type.
Strings are bytes
In Move, strings are represented as sequences of bytes. The std::string::String type is a wrapper around a vector of bytes (vector<u8>), which allows it to store UTF-8 encoded text. The std::ascii::String type is similarly a wrapper around a vector of bytes, but it enforces that all bytes are valid ASCII characters (values from 0 to 127). Both modules provide functions for creating, manipulating, and querying strings and safety checks.
Working with UTF-8 Strings
The String type in the std::string module is defined as follows:
module std::string;
/// A `String` holds a sequence of bytes which is guaranteed to be in utf8 format.
public struct String has copy, drop, store {
bytes: vector<u8>,
}
Creating a UTF-8 String
You can create a UTF-8 string using the utf8 function. It can also be created using the alias .to_string() on the vector<u8>.
use std::string;
let utf8_str = string::utf8(b"Hello");
let another_utf8_str = b"Hello".to_string();
Common Operations
UTF-8 strings in Move provide several methods for working with text. The most common operations include concatenation, slicing, and retrieving the length. For custom operations, the bytes() method can be used to access the underlying byte vector.
let mut str = b"Hello,".to_string();
let another = b" World!".to_string();
// `append(String)` adds content to the end of the string
str.append(another);
// `sub_string(start, end)` copies a slice of the string
str.sub_string(0, 5); // "Hello"
// `length()` returns the number of bytes in the string
str.length(); // 12 (bytes)
// Methods can also be chained! For example, get the length of a substring
str.sub_string(0, 5).length(); // 5 (bytes)
// Check whether the string is empty
str.is_empty(); // false
// Access the underlying byte vector for custom operations
let bytes: &vector<u8> = str.bytes();
Safe UTF-8 Operations
The default utf8 method may abort if the provided bytes are not valid UTF-8. If you are unsure whether the bytes are valid, use the try_utf8 method instead. This method returns an Option<String>:
Some(String)if the bytes form a valid UTF-8 string.Noneif the bytes are invalid.
Note
Functions with names starting with
try_*typically return anOption. If the operation succeeds, the result is wrapped inSome. If it fails, the function returnsNone.
UTF-8 Limitations
The string module does not provide a way to access individual characters directly. This is because UTF-8 is a variable-length encoding, where a character can occupy anywhere from 1 to 4 bytes. As a result, the length() method returns the number of bytes in the string, not the number of characters.
Working with ASCII Strings
The String type in the std::ascii module is defined as follows:
module std::ascii;
/// A `String` holds a sequence of bytes which are guaranteed to be valid ASCII characters.
public struct String has copy, drop, store {
bytes: vector<u8>,
}
Creating an ASCII String
You can create an ASCII string using the string function. It can also be created using the alias .to_string() on a std::string::String type.
use std::ascii::{Self};
let ascii_str = ascii::string(b"Hello");
// The first to_string() converts from byte array to std::string::String
// The second to_string() converts from std::string::String to std::ascii::String
let another_ascii_str = b"Hello".to_string().to_string();
Common Operations
ASCII strings in Move provide similar methods to UTF-8 strings for working with text. The most common operations include concatenation, slicing, and retrieving the length.
use std::ascii::{Self};
let mut str = ascii::string(b"Hello,");
let another = ascii::string(b" World!");
// `append(String)` adds content to the end of the string
str.append(another);
// `substring(start, end)` copies a slice of the string
str.substring(0, 5); // "Hello"
// `length()` returns the number of bytes in the string
str.length(); // 12 (bytes)
// Check whether the string is empty
str.is_empty(); // false
// Vector as bytes
let bytes: &vector<u8> = str.as_bytes();
Safe ASCII Operations
Unlike UTF-8, ASCII strings are guaranteed to be single-byte characters. This means operations like length() directly return the number of characters, since each character is exactly one byte.
If you are unsure whether the bytes are valid ASCII, you can use the try_string method. It returns an Option<String>:
- Some(String): if the bytes form a valid ASCII string.
- None: if the bytes are invalid.
Control Flow
Control flow statements determine how a program executes by directing its path. They allow you to make decisions, repeat sections of code, or exit early from a block or function.
Move includes the following control flow statements:
- if / if-else — decide whether a block of code should run.
- loop / while — repeat a block of code until a condition is met.
- break / continue — exit a loop early or skip to the next iteration.
- return — exit a function before reaching its end.
Conditional Statements
The if expression allows you to execute a block of code only if a specified condition is true. You can also use else to provide an alternative block of code if the condition is false.
The syntax for an if expression is as follows:
if (<bool_expression>) <expression>;
if (<bool_expression>) <expression> else <expression>;
The else keyword is optional. If the condition evaluates to true, the first expression is executed; otherwise, the second expression (if provided) is executed. If the else clause is used, both branches must return values of the same type.
Like any other expression, if requires a semicolon at the end if there are other expressions following it.
Here are some examples of using if and if-else statements in Move:
// Example of an if statement
let x = 10;
if (x > 5) {
// This block executes because the condition is true
let y = x * 2;
};
// Example of an if-else statement
let a = 3;
let b = 7;
let max;
let max = if (a > b) {
a; // This block does not execute
} else {
b; // This block executes because the condition is false
};
Conditional expressions are among the most important control flow statements in Move. They evaluate user-provided input or stored data to make decisions. One key use case is the assert! macro, which verifies that a condition is true and aborts execution if it is not.
Note
You can abort the execution in any of the branches using the
abortstatement regarding of the return type of the other branch.
Repeating Code with Loops
Loops allow you to repeat a block of code multiple times based on a condition. Move supports two types of loops: loop and while. In many cases, you can use either type of loop to achieve the same result, but usuallly while loops are more concise when the number of iterations is determined by a condition whereas loop is more flexible for infinite loops or when the exit condition is complex.
The while Loop
The while loop repeatedly executes a block of code as long as a specified condition evaluates to true. The boolean expression is evaluated before each iteration, and if it evaluates to false, the loop terminates.
The syntax for a while loop is as follows:
while (<bool_expression>) { <expression>; };
Here is an example of using a while loop in Move:
let mut count = 0;
while (count < 5) {
// This block executes as long as count is less than 5
count = count + 1;
};
assert_eq!(count, 5);
Infinite Loops with loop
The loop statement creates an infinite loop that continues executing until it is explicitly exited using a break statement. This type of loop is useful when the number of iterations is not known in advance or when you want to create a loop that runs indefinitely until a certain condition is met.
The syntax for a loop statement is as follows:
loop { <expression>; };
Let’s rewrite the previous while loop example using a loop statement:
let mut count = 0;
loop {
if (count >= 5) {
break; // Exit the loop when count reaches 5
};
count = count + 1;
};
assert_eq!(count, 5);
If the if expression was not used inside the loop, the loop would run indefinitely, causing the program to hang or crash.
Exiting Loops Early
You can exit a loop early using the break statement. The break statement immediately terminates the nearest enclosing loop and transfers control to the statement following the loop. It is usually used in conjunction with conditional statements to determine when to exit the loop (as seen in the previous example). It can be used in both loop and while loops.
Skipping to the Next Iteration
The continue statement allows you to skip the current iteration of a loop and proceed to the next iteration. When continue is encountered, the remaining code in the loop body for that iteration is skipped, and the loop condition is re-evaluated (for while loops) or the next iteration begins (for loop statements). It is typically used within conditional statements to skip certain iterations based on specific criteria.
Here is an example of using the continue statement in a while loop:
let mut count = 0;
let mut sum = 0;
while (count < 10) {
count = count + 1;
if (count % 2 == 0) {
continue; // Skip even numbers
};
sum = sum + count; // Only odd numbers are added to sum
};
Early Return
The return statement allows you to exit a function before reaching its end and optionally return a value to the caller. When return is executed, the function terminates immediately, and control is transferred back to the point where the function was called.
The syntax for the return statement is as follows:
return <expression>
Here is an example of using the return statement in a Move function:
public fun is_odd(num: u64): bool {
if (num % 2 == 1) {
return true
};
false
}
Unlike in many other languages, the return statement is not required for the last expression in a function.
In Move, the final expression in a function block is automatically returned. However, the return statement is useful when you want to exit a function early if a certain condition is met.
Enums and Match
An enum in Move is a user-defined type that can represent one of several variants. Each variant can optionally hold associated data. Enums are useful for modeling data that can take on different forms.
Note
Recursive enums, where a variant can contain another instance of the same enum type, are not supported.
Definition
An enum is defined using the enum keyword followed by the enum name and its variants. Each variant can have associated data types. Enums, like structs, can have abilities such as copy, drop, and store. Enums must have at least one variant.
public enum Shape has copy, drop {
Dot,
Circle(u8),
Rectangle { width: u8, height: u8 },
}
In the code example above, we define an enum Shape with three variants:
Dotwith no associated data.Circlethat holds a singleu8value representing the radius.Rectanglethat holds two named fields:widthandheight.
Instantiating
Enums are internal to the module in which they are defined. They can only be constructed, read, and unpacked within that same module.
Similar to structs, enums are instantiated by specifying the enum type, selecting a variant, and providing values for any fields associated with that variant.
let dot = Shape::Dot;
let circle = Shape::Circle(5);
let rectangle = Shape::Rectangle { width: 10, height: 20 };
Depending on the requirements of your application, enums can either expose public constructors for external use or be instantiated privately within the defining module as part of the internal logic.
Using in Type Definitions
The primary advantage of enums is their ability to encapsulate different data structures within a single type. For example, consider a struct that holds a vector of Shape values:
public struct ShapeCollection(vector<Shape>) has copy, drop;
let shapes = ShapeCollection(vector[
Shape::Dot,
Shape::Circle(5),
Shape::Rectangle { width: 10, height: 20 },
]);
All variants of the Shape enum share the same type—Shape—which enables the creation of a homogeneous vector containing multiple variants. This level of flexibility is not possible with structs, since each struct defines a single, fixed structure.
Pattern Matching with match
Pattern matching allows you to destructure enums and execute different code paths based on the variant. It is similar to a switch-case statement found in other programming languages but is more powerful due to its ability to bind variables to associated data.
Pattern matching enables logic to be executed based on the structure or variant of a value. It is expressed using the match construct, which takes the value to be matched in parentheses, followed by a block of match arms.
Each arm specifies a pattern and the corresponding expression to run when that pattern is satisfied.
public fun is_dot(shape: Shape): bool {
match (shape) {
Shape::Dot => true,
Shape::Circle(_) => false,
Shape::Rectangle { width: _, height: _ } => false,
}
}
The match keyword evaluates the shape parameter and compares it against each pattern in the match arms. When a pattern matches, the corresponding expression is executed. In this example, if shape is a Dot, the function returns true; otherwise, it returns false.
For variants with associated data, you can use _ to ignore the data if it is not needed, or you can bind it to a variable for use within the expression.
The any condition
In situations where you want to match any variant without caring about its specific type or associated data, you can use the wildcard pattern _. This pattern matches any value and is useful for providing a default case.
public fun is_dot(shape: Shape): bool {
match (shape) {
Shape::Dot => true,
_ => false, // Matches any other variant
}
}
In certain scenarios—such as matching against primitive values or collections like vectors—it may be impractical to enumerate every possible case. In those scenarios, the wildcard pattern _ can be employed to match any value that does not explicitly match previous patterns.
public fun is_circle_with_positive_radius(shape: Shape): bool {
match (shape) {
Shape::Circle(0) => false,
Shape::Circle(_) => true,
_ => false, // Matches Dot and Rectangle variants
}
}
Constants
Constants are immutable values defined at the module level. They provide a convenient way to assign meaningful names to static values that are reused across a module. For example, a default product price can be represented as a constant rather than hardcoding the value multiple times. Constants are embedded in the module’s compiled code, and each reference to a constant results in the value being copied.
const DEFAULT_PRODUCT_PRICE: u64 = 100;
const SHIOP_OWNER: address = @0xb0b;
/// Error code indicating an incorrect price
const EWrongPrice: u64 = 1;
public fun purchase(price: u64) {
// Use the constant to check the price
assert!(price == DEFAULT_PRODUCT_PRICE, EWrongPrice);
// Purchase logic...
}
Naming Conventions
Constants must begin with a capital letter, a rule enforced by the compiler. For constants representing values, the established convention is to use all uppercase letters with underscores separating words. This style ensures that constants are easily distinguishable from other identifiers in the code.
An exception applies to error constants, which follow the ECamelCase naming convention.
// Regular constant
const MAX_RETRIES: u8 = 5;
// Error constant
const EInvalidOperation: u64 = 100;
Immutability
Constants are immutable, meaning their values cannot be changed after they are defined. Attempting to reassign a value to a constant will result in a compilation error.
const MAX_CONNECTIONS: u32 = 10;
// This will cause a compilation error
public fun change_max_connections() {
MAX_CONNECTIONS = 20;
}
Config Pattern
A typical application often requires a set of constants that are reused across the codebase. Since constants are private to the module in which they are defined, they cannot be accessed directly from other modules. A common solution is to create a dedicated config module that exposes these constants publicly, ensuring they can be referenced wherever needed while maintaining a centralized definition.
module book::config;
/// Default product price
public const DEFAULT_PRICE: u64 = 100;
/// Maximum number of items allowed in a cart
public const MAX_CART_ITEMS: u64 = 50;
/// Returns the default product price
public fun default_price(): u64 {
DEFAULT_PRICE
}
/// Returns the maximum number of items allowed in a cart
public fun max_cart_items(): u64 {
MAX_CART_ITEMS
}
By exposing constants through a dedicated config module, other modules can import and reference them directly. This approach simplifies maintenance: if a constant value needs to be updated, only the config module must be modified during a package upgrade. As a result, updates are centralized, reducing duplication and ensuring consistency across the codebase.
Aborting Execution
A transaction Move can either succeed or fail. When execution succeeds, all modifications to on‑chain data are applied, and the transaction is committed to the blockchain. If execution aborts, none of the changes are preserved. The abort keyword and revert function from the Stylus Framework are used to terminate a transaction and revert any modifications that were made.
Note
It is important to understand that Move does not provide a catch mechanism. When a transaction aborts, all changes performed up to that point are rolled back, and the transaction is marked as failed.
Abort
The abort keyword is used to terminate execution immediately. It must be used with an error code. The abort code is a u64 value.
let user_has_access = false;
// Abort with error code 1
if (!user_has_access) {
abort 1;
}
Error Constants
Defining error constants is a good practice for making error codes more descriptive. These constants are declared using const and are typically prefixed with E followed by a UpperCamelCase name. Error constants behave like any other constants and do not receive special treatment. Their main purpose is to enhance code readability and make abort scenarios easier to interpret.
const EUserNotAuthorized: u64 = 1;
let user_has_access = false;
// Abort with error code 1
if (!user_has_access) {
abort EUserNotAuthorized;
}
assert!
The assert! macro is a convenient way to check a condition and abort execution if the condition is not met. It takes a boolean expression and an optional error code. If the expression evaluates to false, the transaction aborts with the specified error code (or a default code if none is provided).
let user_has_access = false;
// Assert that the user has access, aborting with error code 2 if not
assert!(user_has_access, 2);
Custom error structs
Move allows you to define custom structures to represent errors. This approach provides more context about the error and can include additional information beyond a simple error code. The errors raised using these structs follows the Solidity’s errors ABI, meanning that they can be docoded by any external tools that understand it.
To be able to use a struct as an error, it must be annotated with the #[ext(abi_error)] attribute. This attribute indicates that the struct is intended to be used as an external ABI error.
#[ext(abi_error)]
public struct CustomError has copy, drop {
error_message: String,
error_code: u64,
}
public fun revert_custom_error(s: String, code: u64) {
revert( CustomError { error_message: s, error_code: code });
}
Functions
Functions form the foundation of Move programs. They can be invoked from user transactions or other functions, and they organize executable code into reusable units. A function may accept arguments and return a value. Functions are declared at the module level using the fun keyword. By default, like other members of a module, they are private and accessible only within the defining module.
module book::math;
public fun add(a: u64, b: u64): u64 {
a + b
}
#[test]
public fun test_add() {
let result = add(2, 3);
// result now holds the value 5
assert_eq!(result, 5);
}
In this example, a function add is defined that accepts two arguments of type u64 and returns their sum. The test_add function, located within the same module, serves as a test by invoking add. The test relies on the assert_eq! macro to check whether the result of add matches the expected value. If the condition inside assert! evaluates to false, execution is automatically aborted.
Function declaration
Functions are declared using the fun keyword, followed by the function name, a list of parameters enclosed in parentheses, an optional return type, and a block of code enclosed in curly braces. The last expression in the function body is treated as the return value.
fun function_name(param1: Type1, param2: Type2): ReturnType {
// function body
// last expression is the return value
}
Note
In Move, functions are typically named using
snake_case, where words are separated by underscores (e.g.,my_function_name).
Accesing functions
Public functions
Like other members of a module, functions can be imported and accessed through a path. This path is composed of the module path followed by the function name, separated with ::. For instance, if there is a function named add in the math module inside the book package, its full path would be book::math::add. Once the module has been imported, the function can be accessed directly as math::add, as shown in the following example:
module book::usage;
use book::math::{Self};
public fun use_addition(): u64 {
let sum = math::add(10, 20);
sum
}
Entry functions
Functions can be invoked from user transactions by declaring them as entry. The difference between public and entry is that public functions are accessible from other modules whereas can be called in transactions. A function can be both public and entry at the same time.
module book::transaction_example;
entry fun perform_addition(a: u64, b: u64) {
// Perform some state change
}
Note
You must change the function name to
camelCasewhen calling an entry function from a transaction. For example, theperform_additionfunction would be called asperformAdditionin a transaction.This is to follow Solidity’s naming conventions.
Multiple return values
Move functions are capable of returning multiple values, which is especially useful when more than one piece of data needs to be produced by a function. The return type is expressed as a tuple of types, and the returned result is given as a tuple of expressions.
fun divide_and_remainder(a: u64, b: u64): (u64, u64) {
let quotient = a / b;
let remainder = a % b;
(quotient, remainder)
}
When a function call returns a tuple, the result must be unpacked into variables using the let (tuple) syntax.
let (q, r) = divide_and_remainder(10, 3);
assert_eq!(q, 3);
assert_eq!(r, 1);
If a declared value must be mutable, the mut keyword is written before the variable name.
let (mut q, r) = divide_and_remainder(10, 3);
q = q + 1;
assert_eq!(q, 4);
assert_eq!(r, 1);
When certain arguments are not needed, they can be ignored by using the _ symbol.
let (_, r) = divide_and_remainder(10, 3);
assert_eq!(r, 1);
Struct Methods
Move Compiler supports receiver syntax instance.method(), which allows defining methods that can be called on instances of a struct. The term “receiver” specifically refers to the instance that receives the method call. This is like the method syntax in other programming languages. It is a convenient way to define functions that operate on the fields of a struct, providing direct access to the struct’s fields and creating cleaner, more intuitive code than passing the struct as a parameter.
Method syntax
If the first argument of a function is a struct internal to the module that defines the function, then the function can be called using the . operator. However, if the type of the first argument is defined in another module, then the method won’t be associated with the struct by default. In this case, the . operator syntax is not available, and the function must be called using standard function call syntax.
When a module is imported, its methods are automatically associated with the struct.
module book::hero;
/// A struct representing a hero.
public struct Hero has drop {
health: u8,
mana: u8,
}
/// Create a new Hero.
public fun new(): Hero { Hero { health: 100, mana: 100 } }
/// A method which casts a spell, consuming mana.
public fun heal_spell(hero: &mut Hero) {
hero.health = hero.health + 10;
hero.mana = hero.mana - 10;
}
/// A method which returns the health of the hero.
public fun health(hero: &Hero): u8 { hero.health }
/// A method which returns the mana of the hero.
public fun mana(hero: &Hero): u8 { hero.mana }
#[test_only]
use std::unit_test::assert_eq;
#[test]
// Test the methods of the `Hero` struct.
fun test_methods() {
let mut hero = new();
hero.heal_spell();
assert_eq!(hero.health(), 110);
assert_eq!(hero.mana(), 90);
}
Method Aliases
Method aliases help avoid name conflicts when modules define multiple structs alongside with their methods. They can also provide more descriptive method names for structs. Public aliases are only allowed for structs defined in the same module. For structs defined in other modules, aliases can still be created but cannot be made public.
Here’s the syntax:
// for local method association
use fun function_path as Type.method_name;
// exported alias
public use fun function_path as Type.method_name;
In the example below, we changed the hero module and added another type - Villain. Both Hero and Villain have similar field names and methods. To avoid name conflicts, we prefixed methods with hero_ and villain_ respectively. However, using aliases allows these methods to be called on struct instances without the prefix:
module book::hero_and_villain;
/// A struct representing a hero.
public struct Hero has drop {
health: u8,
}
/// A struct representing a villain.
public struct Villain has drop {
health: u8,
}
/// Create a new Hero.
public fun new_hero(): Hero { Hero { health: 100 } }
/// Create a new Villain.
public fun new_villain(): Villain { Villain { health: 200 } }
// Alias for the `hero_health` method. It will be imported automatically when
// the module is imported.
public use fun hero_health as Hero.health;
public fun hero_health(hero: &Hero): u8 { hero.health }
// Alias for the `villain_health` method. Will be imported automatically
// when the module is imported.
public use fun villain_health as Villain.health;
public fun villain_health(villain: &Villain): u8 { villain.health }
#[test_only]
use std::unit_test::assert_eq;
#[test]
// Test the methods of the `Hero` and `Villain` structs.
fun test_associated_methods() {
let hero = new_hero();
assert_eq!(hero.health(), 100);
let villain = new_villain();
assert_eq!(villain.health(), 200);
}
In the test function, the health method is called directly on the Hero and Villain instances without the prefix, as the compiler automatically associates the methods with their respective structs.
Note
In the test function,
hero.health()is calling the aliased method, not directly accessing the private health field. While theHeroandVillainstructs are public, their fields remain private to the module. The method callhero.health()uses the public alias defined bypublic use fun hero_health as Hero.health, which provides controlled access to the private field.
Visibility Modifiers
Every module member has a visibility. By default, all module members are private - meaning they are only accessible within the module they are defined in. However, you can add a visibility modifier to make a module member public - visible outside the module, or public(package) - visible in the modules within the same package, or entry - can be called from a transaction but can’t be called from other modules.
Internal Visibility
A function or a struct defined in a module which has no visibility modifier is private to the module. It can’t be called from other modules.
module book::internal_visibility;
// This function can be called from other functions in the same module
fun internal() { /* ... */ }
// Same module -> can call internal()
fun call_internal() {
internal();
}
The following code will not compile:
module book::try_calling_internal;
use book::internal_visibility;
// Different module -> can't call internal()
fun try_calling_internal() {
internal_visibility::internal();
}
Warning
Note that just because a struct field is not visible from Move does not mean that its value is kept confidential — it is always possible to read the contents of an on-chain object from outside of Move. You should never store unencrypted secrets inside of objects.
Public Visibility
A function can be made public by adding the public keyword before the fun keyword.
module book::public_visibility;
// This function can be called from other modules
public fun public_fun() { /* ... */ }
A public function can be imported and called from other modules. The following code will compile:
module book::try_calling_public;
use book::public_visibility;
// Different module -> can call public_fun()
fun try_calling_public() {
public_visibility::public_fun();
}
Note
structmust always be declared withpublicvisibility. By default the only thing you can do is just importing it. To access or modify the fields of a struct, you need to define public functions (getters and setters) within the module where the struct is defined.
Package Visibility
A function with package visibility can be called from any module within the same package, but not from modules in other packages. In other words, it is internal to the package.
module book::package_visibility;
public(package) fun package_only() { /* ... */ }
A package function can be called from any module within the same package:
module book::try_calling_package;
use book::package_visibility;
// Same package `book` -> can call package_only()
fun try_calling_package() {
package_visibility::package_only();
}
Native Functions
Some functions in the framework are marked with the native modifier. These functions are natively provided by the framework or the Stylus VM (host functions) and do not have a body in Move source code.
/// Event module from the Stylus Framework
module stylus::event;
/// Native function to emit an event with type T
public native fun emit<T: copy + drop>(event: T);
Ownership and Scope
Every variable in Move has a scope and an owner. The scope is the range of code where the variable is valid, and the owner is the scope that this variable belongs to. Once the owner scope ends, the variable is dropped. This is a fundamental concept in Move, and it is important to understand how it works.
Ownership
A variable defined in a function scope is owned by this scope. The Move runtime goes through the function scope and executes every expression and statement. After the function scope ends, the variables defined in it are dropped (preventing future code to access those variables).
module book::ownership;
public fun owner() {
let a = 1; // a is owned by the `owner` function
} // a is dropped here
public fun other() {
let b = 2; // b is owned by the `other` function
} // b is dropped here
#[test]
fun test_owner() {
owner();
other();
// a & b are not valid here
}
In the example above, the variable a is owned by the owner function, and the variable b is owned by the other function. When each of these functions are called, the variables are defined, and when the function ends, the variables are discarded.
Returning a Value
If we changed the owner function to return the variable a, then the ownership of a would be transferred to the caller of the function.
module book::ownership;
public fun owner(): u8 {
let a = 1; // a defined here
a // scope ends, a is returned
}
#[test]
fun test_owner() {
let a = owner();
// a is valid here
} // a is dropped here
Passing by Value
Additionally, if we passed the variable a to another function, the ownership of a would be transferred to this function. When performing this operation, we move the value from one scope to another. This is also called move semantics.
module book::ownership;
public fun owner(): u8 {
let a = 10;
a
} // a is returned
public fun take_ownership(v: u8) {
// v is owned by `take_ownership`
} // v is dropped here
#[test]
fun test_owner() {
let a = owner();
// `u8` is copyable, pass `move a` when calling the function to force the transfer of its ownership
take_ownership(move a);
// a is not valid here
}
Scopes with Blocks
Each function has a main scope, and it can also have sub-scopes via the use of blocks. A block is a sequence of statements and expressions, and it has its own scope. Variables defined in a block are owned by this block, and when the block ends, the variables are dropped.
module book::ownership;
public fun owner() {
let a = 1; // a is owned by the `owner` function's scope
{
let b = 2; // the block that declares b owns it
{
let c = 3; // the block that declares c owns it
}; // c is dropped here
}; // b is dropped here
// a = b; // error: b is not valid here
// a = c; // error: c is not valid here
} // a is dropped here
However, if we return a value from a block, the ownership of the variable is transferred to the caller of the block.
module book::ownership;
public fun owner(): u8 {
let a = 1; // a is owned by the `owner` function's scope
let b = {
let c = 2; // the block that declares c owns it
c // c is returned from the block and transferred to b
};
a + b // both a and b are valid here
}
Copyable Types
Some types in Move are copyable, which means that they can be copied without transferring ownership. This is useful for types that are small and cheap to copy, such as integers and booleans. The Move compiler will automatically copy these types when they are passed to or returned from a function, or when they’re moved to another scope and then accessed in their original scope.
Abilities: Copy
In Move, the copy ability on a type indicates that the instance or the value of the type can be copied, or duplicated. While this behavior is provided by default when working with numbers or other primitive types, it is not the default for custom types. Move is designed to express digital assets and resources, and controlling the ability to duplicate resources is a key principle of the resource model. However, the Move type system allows you to add the copy ability to custom types:
public struct Copyable has copy {}
In the example above, we define a custom type Copyable with the copy ability. This means that instances of Copyable can be copied, both implicitly and explicitly.
let a = Copyable {}; // allowed because the Copyable struct has the `copy` ability
let b = a; // `a` is copied to `b`
let c = *&b; // explicit copy via dereference operator
// Copyable doesn't have the `drop` ability, so every instance (a, b, and c) must
// be used or explicitly destructured. The `drop` ability is explained below.
let Copyable {} = a;
let Copyable {} = b;
let Copyable {} = c;
In the example above, a is copied to b implicitly, and then explicitly copied to c using the dereference operator. If Copyable did not have the copy ability, the code would not compile, and the Move compiler would raise an error.
Note
In Move, destructuring with empty brackets is often used to consume unused variables, especially for types without the drop ability. This prevents compiler errors from values going out of scope without explicit use. Also, Move requires the type name in destructuring (e.g.,
Copyableinlet Copyable {} = a;) because it enforces strict typing and ownership rules.
Copying and Drop
The copy ability is closely related to the drop ability. If a type has the copy ability, it is very likely that it should have drop too. This is because the drop ability is required to clean up resources when the instance is no longer needed. If a type only has copy, managing its instances gets more complicated, as the instances must be explicitly used or consumed.
public struct Value has copy, drop {}
All of the primitive types in Move behave as if they have the copy and drop abilities. This means that they can be copied and dropped, and the Move compiler will handle the memory management for them.
All native types in Move have the copy ability. This includes:
References
In the Ownership and Scope section, we explained that when a value is passed to a function, it is moved to the function’s scope. This means that the function becomes the owner of the value, and the original scope (owner) can no longer use it. This is an important concept in Move, as it ensures that the value is not used in multiple places at the same time. However, there are use cases when we want to pass a value to a function but retain ownership. This is where references come into play.
To illustrate this, let’s consider a simple example - an application for a metro (subway) pass. We will look at 4 different scenarios where a card can be:
- Purchased at a kiosk for a fixed price
- Shown to an inspector to prove that the passenger has a valid pass
- Used at the turnstile to enter the metro, and purchase a ride
- Recycled after it’s empty
Layout
The initial layout of the metro pass application is simple. We define the Card type and the USES constant that represents the number of rides on a single card. We also add error constants for the case when the card is empty and when the card is not empty.
module book::metro_pass;
/// Error code for when the card is empty.
const ENoUses: u64 = 0;
/// Error code for when the card is not empty.
const EHasUses: u64 = 1;
/// Number of uses for a metro pass card.
const USES: u8 = 3;
/// A metro pass card
public struct Card { uses: u8 }
/// Purchase a metro pass card.
public fun purchase(): Card {
Card { uses: USES }
}
References
References are a way to show a value to a function without giving up ownership. In our case, when we show the Card to the inspector, we don’t want to give up ownership of it, and we don’t allow the inspector to use up any of our rides. We just want to allow the reading of the value of our Card and to prove its ownership.
To do so, in the function signature, we use the & symbol to indicate that we are passing a reference to the value, not the value itself.
/// Show the metro pass card to the inspector.
public fun is_valid(card: &Card): bool {
card.uses > 0
}
Because the function does not take ownership of the Card, it can read its data but cannot write to it, meaning it cannot modify the number of rides. Additionally, the function signature ensures that it cannot be called without a Card instance. This is an important property that allows the Capability Pattern, which we will cover in the next chapters.
Creating a reference to a value is often referred to as “borrowing” the value. For example, the method to get a reference to the value wrapped by an Option is called borrow.
Mutable Reference
In some cases, we want to allow the function to modify the Card. For example, when using the Card at a turnstile, we need to deduct a ride. To achieve this, we use the &mut keyword in the function signature.
/// Use the metro pass card at the turnstile to enter the metro.
public fun enter_metro(card: &mut Card) {
assert!(card.uses > 0, ENoUses);
card.uses = card.uses - 1;
}
As you can see in the function body, the &mut reference allows mutating the value, and the function can spend rides.
Passing by Value
Lastly, let’s illustrate what happens when we pass the value itself to the function. In this case, the function takes the ownership of the value, making it inaccessible in the original scope. The owner of the Card can recycle it and thereby relinquish ownership to the function.
/// Recycle the metro pass card.
public fun recycle(card: Card) {
assert!(card.uses == 0, EHasUses);
let Card { uses: _ } = card;
}
In the recycle function, the Card is passed by value, transferring ownership to the function. This allows it to be unpacked and destroyed.
Note
In Move,
_is a wildcard pattern used in destructuring to ignore a field while still consuming the value. Destructuring must match all fields in a struct type. If a struct has fields, you must list all of them explicitly or use _ to ignore unwanted fields.
Full Example
To illustrate the full flow of the application, let’s put all the pieces together in a test.
#[test]
fun test_card_2024() {
// declaring variable as mutable because we modify it
let mut card = purchase();
card.enter_metro(); // modify the card but don't move it
assert!(card.is_valid()); // read the card!
card.enter_metro(); // modify the card but don't move it
card.enter_metro(); // modify the card but don't move it
card.recycle(); // move the card out of the scope
}
Generics
Generics are a way to define a type or function that can work with any type. This is useful when you want to write a function which can be used with different types, or when you want to define a type that can hold any other type. Generics are the foundation of many advanced features in Move including collections, abstract implementations, and more.
In the Standard Library
In this chapter we already mentioned the vector type, which is a generic type that can hold any other type. Another example of a generic type in the standard library is the Option type, which is used to represent a value that may or may not be present.
Generic Syntax
To define a generic type or function, a type signature needs to have a list of generic parameters enclosed in angle brackets (< and >). The generic parameters are separated by commas.
/// Container for any type `T`.
public struct Container<T> has drop {
value: T,
}
/// Function that creates a new `Container` with a generic value `T`.
public fun new<T>(value: T): Container<T> {
Container { value }
}
In the example above, Container is a generic type with a single type parameter T, the value field of the container stores the T. The new function is a generic function with a single type parameter T, and it returns a Container with the given value. Generic types must be initialized with a concrete type, and generic functions must be called with a concrete type, although in some cases the Move compiler can infer the correct type.
#[test]
fun test_container() {
// these three lines are equivalent
let container: Container<u8> = new(10); // type inference
let container = new<u8>(10); // create a new `Container` with a `u8` value
let container = new(10u8);
assert_eq!(container.value, 10);
// Value can be ignored only if it has the `drop` ability.
let Container { value: _ } = container;
}
In the test function test_container, we demonstrate three equivalent ways to create a new Container with a u8 value. Because numeric constants have ambiguous types, we must specify the type of the number literal somewhere (in the type of the container, the parameter to new, or the number literal itself); once we specify one of these the compiler can infer the others.
Warning
A function with generic parameters cannot be
entry. Entry functions must have concrete types for all parameters. This is because we can’t determine the concrete types from the ABI encoding.
Multiple Type Parameters
You can define a type or function with multiple type parameters. The type parameters are separated by commas.
/// A pair of values of any type `T` and `U`.
public struct Pair<T, U> {
first: T,
second: U,
}
/// Function that creates a new `Pair` with two generic values `T` and `U`.
public fun new_pair<T, U>(first: T, second: U): Pair<T, U> {
Pair { first, second }
}
In the example above, Pair is a generic type with two type parameters T and U, and the new_pair function is a generic function with two type parameters T and U. The function returns a Pair with the given values. The order of the type parameters is important, and should match the order of the type parameters in the type signature.
#[test]
fun test_generic() {
// these three lines are equivalent
let pair_1: Pair<u8, bool> = new_pair(10, true); // type inference
let pair_2 = new_pair<u8, bool>(10, true); // create a new `Pair` with a `u8` and `bool` values
let pair_3 = new_pair(10u8, true);
assert_eq!(pair_1.first, 10);
assert_eq!(pair_1.second, true);
// Unpacking is identical.
let Pair { first: _, second: _ } = pair_1;
let Pair { first: _, second: _ } = pair_2;
let Pair { first: _, second: _ } = pair_3;
}
If we added another instance where we swapped type parameters in the new_pair function, and tried to compare two types, we’d see that the type signatures are different, and cannot be compared.
#[test]
fun test_swap_type_params() {
let pair1: Pair<u8, bool> = new_pair(10u8, true);
let pair2: Pair<bool, u8> = new_pair(true, 10u8);
// this line will not compile
// assert_eq!(pair1, pair2);
let Pair { first: pf1, second: ps1 } = pair1; // first1: u8, second1: bool
let Pair { first: pf2, second: ps2 } = pair2; // first2: bool, second2: u8
assert_eq!(pf1, ps2); // 10 == 10
assert_eq!(ps1, pf2); // true == true
}
Since the types for pair1 and pair2 are different, the comparison pair1 == pair2 will not compile.
Why Generics?
In the examples above we focused on instantiating generic types and calling generic functions to create instances of these types. However, the real power of generics lies in their ability to define shared behavior for the base, generic type, and then use it independently of the concrete types. This is especially useful when working with collections, abstract implementations, and other advanced features in Move.
/// A user record with name, age, and some generic metadata
public struct User<T> {
name: String,
age: u8,
/// Varies depending on application.
metadata: T,
}
In the example above, User is a generic type with a single type parameter T, with shared fields name, age, and the generic metadata field, which can store any type. No matter what metadata is, all instances of User will contain the same fields and methods.
/// Updates the name of the user.
public fun update_name<T>(user: &mut User<T>, name: String) {
user.name = name;
}
/// Updates the age of the user.
public fun update_age<T>(user: &mut User<T>, age: u8) {
user.age = age;
}
Phantom Type Parameters
In some cases, you may want to define a generic type with a type parameter that is not used in the fields or methods of the type. This is called a phantom type parameter. Phantom type parameters are useful when you want to define a type that can hold any other type, but you want to enforce some constraints on the type parameter.
/// A generic type with a phantom type parameter.
public struct Coin<phantom T> {
value: u64
}
The Coin type here does not contain any fields or methods that use the type parameter T. It is used to differentiate between different types of coins, and to enforce some constraints on the type parameter T.
public struct USD {}
public struct EUR {}
#[test]
fun test_phantom_type() {
let coin1: Coin<USD> = Coin { value: 10 };
let coin2: Coin<EUR> = Coin { value: 20 };
// Unpacking is identical because the phantom type parameter is not used.
let Coin { value: _ } = coin1;
let Coin { value: _ } = coin2;
}
In the example above, we demonstrate how to create two different instances of Coin with different phantom type parameters USD and EUR. The type parameter T is not used in the fields or methods of the Coin type, but it is used to differentiate between different types of coins. This helps ensure that the USD and EUR coins are not mistakenly mixed up.
Constraints on Type Parameters
Type parameters can be constrained to have certain abilities. This is useful when you need the inner type to allow certain behaviors, such as copy or drop. The syntax for constraining a type parameter is T: <ability> + <ability>.
/// A generic type with a type parameter that has the `drop` ability.
public struct Droppable<T: drop> {
value: T,
}
/// A generic struct with a type parameter that has the `copy` and `drop` abilities.
public struct CopyableDroppable<T: copy + drop> {
value: T, // T must have the `copy` and `drop` abilities
}
The Move Compiler will enforce that the type parameter T has the specified abilities. If the type parameter does not have the specified abilities, the code will not compile.
/// Type without any abilities.
public struct NoAbilities {}
#[test]
fun test_constraints() {
// Fails - `NoAbilities` does not have the `drop` ability
// let droppable = Droppable<NoAbilities> { value: 10 };
// Fails - `NoAbilities` does not have the `copy` and `drop` abilities
// let copyable_droppable = CopyableDroppable<NoAbilities> { value: 10 };
}
Testing
Testing is a crucial aspect of software development, especially in blockchain applications where security and correctness are paramount. In this section, we will cover the fundamentals of testing in Move, including how to write and organize tests effectively.
The #[test] Attribute
Tests in Move are functions marked with the #[test] attribute. This attribute tells the compiler that the function is a test function and should be run when tests are executed. Test functions are regular functions, but they must take no arguments and have no return value. They are excluded from the release compiled code and are never published.
module book::testing;
#[test_only]
use std::unit_test::assert_eq;
// The test attribute is placed before the `fun` keyword (can be both above or
// right before the `fun` keyword, as in `#[test] fun my_test() { ... }`)
// The name of the test in this case would be `book::testing::simple_test`.
#[test]
fun simple_test() {
let sum = 2 + 2;
assert_eq!(sum, 4);
}
// The name of this test would be `book::testing::more_advanced_test`.
#[test] fun more_advanced_test() {
let sum = 2 + 2 + 2;
assert_eq!(sum, 4);
}
Running Tests
To run tests, you can use the move-stylus test command from the move-stylus cli. This command will first build the package in test mode and then run all tests found in the package. In test mode, modules from both sources/ and tests/ directories are processed and their tests executed.
x@y-MacBook-Pro example % move-stylus test
COMPILING another_mod
COMPILING other_mod
COMPILING hello_world
Running 0x0::hello_world tests (./sources/hello_world.move)
0x0::hello_world::test_1 ... PASSED
0x0::hello_world::test_2 ... PASSED
0x0::hello_world::test_3 ... PASSED
0x0::hello_world::test_4 ... PASSED
0x0::hello_world::test_5 ... PASSED
0x0::hello_world::test_6 [expected failure] ... PASSED
Total Tests : 6, Passed: 6, Failed: 0.
Test Fail Cases with #[expected_failure]
Tests for fail cases can be marked with #[expected_failure]. This attribute, when added to a #[test] function, tells the compiler that the test is expected to fail. This is useful when you want to test that a function fails when a certain condition is met.
Note
This attribute can only be added to a
#[test]function.
If execution does not result in an abort, the test will fail.
module book::testing_failure;
const EInvalidArgument: u64 = 1;
#[test]
#[expected_failure]
fun test_fail() {
abort 0 // aborts with code 0
}
// attributes can be grouped together
#[test, expected_failure]
fun test_fail_1() {
abort EInvalidArgument // aborts with code EInvalidArgument
}
Utilities with #[test_only]
In some cases, it is helpful to give the test environment access to some internal functions or features. This simplifies the testing process and allows for more thorough testing. However, it is important to remember that these functions should not be included in the final package. This is where the #[test_only] attribute comes in handy.
module book::testing;
#[test_only]
use std::unit_test::assert_eq;
// Public function which uses the `secret` function.
public fun multiply_by_secret(x: u64): u64 {
x * secret()
}
/// Private function which is not available to the public.
fun secret(): u64 { 100 }
#[test_only]
/// This function is only available for testing purposes in tests and other
/// test-only functions. Mind the visibility - for `#[test_only]` it is
/// common to use `public` visibility.
public fun secret_for_testing(): u64 {
secret()
}
#[test]
// In the test environment we have access to the `secret_for_testing` function.
fun test_multiply_by_secret() {
let expected = secret_for_testing() * 2;
assert_eq!(multiply_by_secret(2), expected);
}
Functions marked with the #[test_only] will be available to the test environment, and to the other modules if their visibility is set to public.
The Move Object Model: A Paradigm Shift in Digital Assets
Smart-contract programming languages have historically focused on defining and managing digital assets. For example, the ERC-20 standard in Ethereum pioneered a set of standards to interact with digital currency tokens, establishing a blueprint for creating and managing digital currencies on the blockchain. Subsequently, the introduction of the ERC-721 standard marked a significant evolution, popularizing the concept of non-fungible tokens (NFTs), which represent unique, indivisible assets. These standards laid the groundwork for the complex digital assets we see today.
However, Ethereum’s programming model lacked a native representation of assets. In other words, externally, a Smart Contract behaved like an asset, but the language itself did not have a way to inherently represent assets. From the start, Move aimed to provide a first-class abstraction for assets, opening up new avenues for thinking about and programming assets.
It is important to highlight which properties are essential for an asset:
-
Ownership: Every asset is associated with an owner(s), mirroring the straightforward concept of ownership in the physical world—just as you own a car, you can own a digital asset. Move enforces ownership in such a way that once an asset is moved, the previous owner completely loses any control over it. This mechanism ensures a clear and secure change of ownership.
-
Non-copyable: In the real world, unique items cannot be duplicated effortlessly. Move applies this principle to digital assets, ensuring they cannot be arbitrarily copied within the program. This property is crucial for maintaining the scarcity and uniqueness of digital assets, mirroring the intrinsic value of physical assets.
-
Non-discardable: Just as you cannot accidentally lose a house or a car without a trace, Move ensures that no asset can be discarded or lost in a program. Instead, assets must be explicitly transferred or destroyed. This property guarantees the deliberate handling of digital assets, preventing accidental loss and ensuring accountability in asset management.
Move managed to encapsulate these properties in its design, becoming an ideal language for digital assets.
Summary
- Move was designed to provide a first-class abstraction for digital assets, enabling developers to create and manage assets natively.
- Essential properties of digital assets include ownership, non-copyability, and non-discardability, which Move enforces in its design.
- Move’s asset model mirrors real-world asset management, ensuring secure and accountable asset ownership and transfer.
What is an Object?
The Object Model can be viewed as a high-level abstraction representing digital assets as objects. These objects have their own type and associated behaviors, a unique identifier, and support native storage operations like transfer, share and freeze. Designed to be intuitive and easy to use, the Object Model enables a wide range of use cases to be implemented with ease.
Objects have the following properties:
-
Type: Every object has a type, defining the structure and behavior of the object. Objects of different types cannot be mixed or used interchangeably, ensuring objects are used correctly according to their type system.
-
Unique ID: Each object has a unique identifier, distinguishing it from other objects. This ID is generated upon the object’s creation and is immutable. It’s used to track and identify objects within the system.
-
Owner: Every object is associated with an owner (which might be an address or another object), who has control over changes to the object. Ownership can be exclusive, shared across the network, or frozen, allowing read-only access without modification or transfer capabilities.
-
Data: Objects encapsulate their data, simplifying management and manipulation. The data structure and operations are defined by the object’s type.
Warning
Ownership does not control the confidentiality of an object — it is always possible to read the contents of an on-chain object from outside of Move. You should never store unencrypted secrets inside of objects.
Note
Certain kind of object have a pre-computed ID called
NamedId. It is possible to have two distinct objects with the sameNameId. Those cases should be handled with care.
Ownership
There exist four distinct ownership types for objects: account-owned, shared, immutably shared (a.k.a frozen), and object-owned (a.k.a wrapped). Each model offers unique characteristics and suits different use cases, enhancing flexibility and control in object management.
Account Owned
The account-owned is the foundational ownership type. The object is owned by a single account, granting that account exclusive control over the object within the behaviors associated with its type. This model embodies the concept of true ownership, where the account possesses complete authority over the object, making it inaccessible to others for modification or transfer. Therefore, no one can use your assets.
Shared State
A shared object is a public, mutable object that is accessible to anyone on the network. They are designed such that multiple users or smart contracts can interact with the same object. Shared objects can be read and modified by any account, and the rules of interaction are defined by the implementation of the object.
Immutably Shared or Frozen State
A frozen (immutably shared) object becomes permanently read-only. These immutable objects, while readable, cannot be modified or moved, providing a stable and constant state accessible to all network participants. Frozen objects are ideal for public data, reference materials, and other use cases where the state permanence is desirable.
Object Owned or Wrapped Objects
Wrapping refers to nesting objects to organize data structures in Move. When an object is wrapped, the object no longer exists independently on-chain. You can no longer look up the object by its ID, as the object becomes part of the data of the object that wraps it. This feature allows creating complex relationships between objects, storing large heterogeneous collections, and implementing extensible and modular systems. Practically speaking, since the transactions are initiated by accounts, the transaction still accesses the parent object, but it can then access the child objects through the parent object.
A practical use case is a game character. Alice can own the Hero object from a game, and the Hero can own items: also represented as objects, like a Map, or a Compass. Alice may take the Map from the Hero object, and then send it to Bob, or sell it on a marketplace. With object owner, it becomes very natural to imagine how the assets can be structured and managed in relation to each other.
Summary
- Account Owned: Objects are owned by a single account, granting exclusive control over the object.
- Shared: Objects can be shared with the network, allowing multiple accounts to read and modify the object.
- Frozen: Objects become permanently read-only, providing a stable and constant state.
- Object Owned: Objects can own other objects, enabling complex relationships and modular systems.
Ability key
We already covered two out of four abilities: Drop and Copy. They affect the behavior of a value in a scope and are not directly related to storage. Now it is time to cover the key ability, which allows a struct to be stored.
Defining an Object
For a struct to be considered an object and used with storage functions, it must fulfill three strict requirements:
- The
keyAbility: The struct must be declared withhas key. - The
idField: The very first field in the struct must be namedidand have the typeUIDorNamedId. - The
storeRequirement: All other fields within the struct must have thestoreability.
/// `User` object definition.
public struct User has key {
id: UID,
name: String, // field types must have `store`
}
/// Creates a new instance of the `User` type.
/// Uses the special struct `TxContext` to derive a Unique ID (UID).
public fun new(name: String, ctx: &mut TxContext): User {
User {
id: object::new(ctx), // creates a new UID
name,
}
}
Note
UIDis a type that does not have thedroporcopyabilities. Because every object contains aUID, objects themselved can never be dropped or copied. This ensures that assets cannot be accidentally deleted or duplicated, enforcing strict scarcity and accountability.
Types with the key Ability
Due to the UID or NamedId requirement for types with key, none of the native types in Move can have the key ability, nor can any of the types in the Standard Library. The key ability is present only in some Stylus Framework types and in custom types.
Summary
- The
keyability defines an object - The first field of an object must be id with type
UID - Fields of a
keytype must the havestoreability - Objects cannot have
droporcopy
The key ability defines objects in Move and forces the fields to have store. In the next section we cover the store ability to later explain how storage operations work.
Ability store
The key ability requires all fields to have store, which defines what the store ability means: it is the ability to serve as a field of an Object. A struct with copy or drop but without store can never be stored. A type with key but without store cannot be wrapped - used as a field—in another object, and is constrained to always remain at the top level.
Definition
The store ability allows a type to be used as a field in a struct with the key ability.
use std::string::String;
/// Extra metadata with `store`; all fields must have `store` as well!
public struct Metadata has store {
bio: String,
}
/// An object for a single user record.
public struct User has key {
id: UID,
name: String, // String has `store`
age: u8, // All integers have `store`
metadata: Metadata, // Another type with the `store` ability
}
Note
All native types (except references) in Move have the
storeability. All of the types defined in the standard library have thestoreability as well.
Storage Functions
The Stylus Framework provides a set of built-in functions within the transfer module to define and manage object ownership:
transfer::transfer: sends an object to a specific address, placing it in an address-owned state.transfer::freeze_object: transitions an object into an immutable state. It becomes a public constant and can never be modified.transfer::share_object- transitions an object into a shared state, making it accessible to all users.
Transfer
The transfer::transfer function is used to send an object to a specific address. It’s signature is as follows:
module stylus::transfer;
// Transfer `obj` to `recipient`.
public fun transfer<T: key>(obj: T, recipient: address);
It only accepts a type with the key ability and the address of the recipient. Note that the function is generic over type T, which represents the type of the object being transferred. The object is passed into the function by value; it is moved into the function’s scope and then moved to the recipient’s address.
In the following example, you can see how it can be used in a module that defines and sends an object to the transaction sender.
module book::transfer_to_sender;
/// A struct with `key` is an object. The first field is `id: UID`!
public struct AdminCap has key { id: UID }
/// `init` function is a special function that translated as the equivalent
/// of the constructor function in Solidity
entry fun init(ctx: &mut TxContext) {
// Create a new `AdminCap` object, in this scope.
let admin_cap = AdminCap { id: object::new(ctx) };
// Transfer the object to the transaction sender.
transfer::transfer(admin_cap, ctx.sender());
}
/// Transfers the `AdminCap` object to the `recipient`. Thus, the recipient
/// becomes the owner of the object, and only they can access it.
public fun transfer_admin_cap(cap: AdminCap, recipient: address) {
transfer::transfer(cap, recipient);
}
When the module is deployed, the init function must be called (the module constructor) to initialize the contract. The AdminCap object which we created in it will be transferred to the transaction sender. The ctx.sender() function returns the sender address for the current transaction.
Once the AdminCap has been transferred to the sender, for example, to 0xa11ce, the sender, and only the sender, will be able to access the object.
Freeze
The transfer::freeze_object function is used to put an object into an immutable state. Once an object is frozen, it can never change, and it can be accessed by anyone by immutable reference.
module stylus::transfer;
// Make object immutable and allow anyone to read it.
public fun freeze_object<T: key>(obj: T);
The function only accepts a generic type T with the key ability. Just like all other storage functions, it takes the object by value.
Let’s extend the previous example and add a function that allows the admin to create a Config object and freeze it:
/// Some `Config` object that the admin can `create_and_freeze`.
public struct Config has key {
id: UID,
message: String
}
/// Creates a new `Config` object and freezes it.
public fun create_and_freeze(
_: &AdminCap,
message: String,
ctx: &mut TxContext
) {
let config = Config {
id: object::new(ctx),
message
};
// Freeze the object so it becomes immutable.
transfer::freeze_object(config);
}
/// Returns the message from the `Config` object.
/// Can access the object by immutable reference!
public fun message(c: &Config): String { c.message }
Config is an object that has a message field, and the create_and_freeze function creates a new Config and freezes it. Once the object is frozen, it can be accessed by anyone by immutable reference. The message function is a public function that returns the message from the Config object. Config is now publicly available by its ID, and the message can be read by anyone.
Share
The transfer::share_object function is used to put an object into a shared state. Once an object is shared, it can be accessed by anyone by a mutable reference (hence, immutable too). The transfer::share_object function is used to put an object into a shared state. Once an object is shared, it can be accessed by anyone by a mutable reference (hence, immutable too).
This means it does not make sense to pass a shared object by value, since you can’t do anything with it (cannot be transfered, and if you try to unpack its values, the compiler throw an error because the id field is not handled).
The function signature is as follows, only accepts a type with the key ability:
module stylus::transfer;
/// Put an object to a Shared state - can be accessed mutably and immutably.
public fun share_object<T: key>(obj: T);
It is important to note that sharing an object is a terminal state for its accessibility: once shared, an object can no longer be transferred or frozen. However, it can still be deleted. This creates an exception to the by value rule—a shared object can only be passed by value if the receiving function explicitly consumes (deletes) it:
/// Creates a new `Config` object and shares it.
public fun create_and_share(message: String, ctx: &mut TxContext) {
let config = Config {
id: object::new(ctx),
message
};
// Share the object so it becomes shared.
transfer::share_object(config);
}
/// Deletes the `Config` object, takes it by value.
/// Can be called on a shared object!
public fun delete_config(c: Config) {
let Config { id, message: _ } = c;
id.delete()
}
// Won't work!
public fun transfer_config(c: Config, to: address) {
transfer::transfer(c, to);
}
Recap
-
Transfer
transfer::transferis used to send an object to an address- The object becomes address owned and can only be accessed by the recipient
- Address owned object can be used by reference or by value, including being transferred to another address
-
Freeze
transfer::freeze_objectfunction is used to put an object into an immutable state- Once an object is frozen, it can never be changed, deleted or transferred, and it can be accessed by anyone by immutable reference
-
Share
transfer::share_objectfunction is used to put an object into a shared state- Once an object is shared, it can be accessed by anyone by a mutable reference
- Shared objects can be deleted, but they can’t be transferred or frozen
UID and ID
The use of the UID type is required on all types that have the key ability. Here we go deeper into UID and its usage.
Definition
The UID type is defined in the stylus::object module and is a wrapper around an ID which, in turn, wraps the address type. The UIDs are guaranteed to be unique, and can’t be reused after the object was deleted.
module stylus::object;
/// References a object ID
public struct ID has copy, drop, store {
bytes: address,
}
/// Globally unique IDs that define an object's ID in storage.
/// Any object, that is a struct with the `key` ability, must have `id: UID` as its first field.
public struct UID has store {
id: ID,
}
Conversion methods
The framework provides these conversion methods to “peek” inside the UID and extract its underlying data:
/// Get the inner bytes of `id` as an address.
public fun uid_to_address(uid: &UID): address {
uid.id.bytes
}
public use fun uid_to_address as UID.to_address;
/// Get the raw bytes of a `uid`'s inner `ID`
public fun uid_to_inner(uid: &UID): ID {
uid.id
}
public use fun uid_to_inner as UID.to_inner;
Creating a new UID
To ensure every object has a unique identity within the contract’s storage, the framework generates a new UID using a Keccak256 hash of three specific parameters:
- Block Timestamp: Ties the ID to the time of creation.
- Block Number: Ties the ID to the specific blockchain height.
- Global Counter: Increments with every call to ensure uniqueness for multiple objects created within the same block or transaction.
By combining these elements, the framework creates a collision-resistant address that is unique across the entire network.
The object::tx_context module provides methods to access the required transaction information. Specifically, tx_context::fresh_object_address handles the logic for hashing the data and producing the new address.
When object::new is called, it wraps this address into a UID and emits a NewUID event. This allows users and off-chain indexers to identify the exact address of the newly created object.
module stylus::object;
use stylus::tx_context::TxContext;
use stylus::event::emit;
/// Creates a new `UID`, which must be stored in an object's `id` field.
/// This is the only way to create `UID`s.
///
/// Each time a new `UID` is created, an event is emitted on topic 0.
/// This allows the transaction caller to capture and persist it for later
/// reference to the object associated with that `UID`
public fun new(ctx: &mut TxContext): UID {
let res = UID { id: ID { bytes: ctx.fresh_object_address() } };
emit(NewUID { uid: res.to_inner() });
res
}
/// Event emitted when a new UID is created.
#[ext(event(indexes = 1))]
public struct NewUID has copy, drop {
uid: ID,
}
UID Lifecycle
When deleting an object from storage, we must account for the abilities of its fields. Specifically, the UID struct lacks the drop ability; it cannot simply go out of scope, or the Move compiler will complain. It must be handled explicitly.
The object::delete function is designed for this purpose. After an object is “unpacked” (deconstructed), this function consumes the UID by value, moving it into the object::delete scope.
From an implementation standpoint, this function performs a critical role: it triggers the complete removal of the Object’s data from the contract’s storage, wiping the specific slots occupied by the Object.
module stylus::object;
/// Deletes the UID and removes the associated object from storage.
public native fun delete(id: UID);
Named Ids
While standard UIDs are generated dynamically, the framework also supports NamedIds. These are used when the storage location of an object must be deterministic and predictable. This allows the system or other contracts to retrieve an object without requiring the user to manually provide its UID during every transaction.
Definition
A NamedId is a specialized struct managed by the compiler. It utilizes a phantom type parameter T to derive a deterministic address. This ensures that a specific type always maps to the same coordinates in storage. To maintain safety and compatibility with the storage model, the generic type T must be a struct with the key ability.
module stylus::object;
/// Named IDs provide a deterministic storage location based on type `T`.
/// Each `NamedId` can only be associated with one struct definition.
public struct NamedId<phantom T: key> has store {
id: ID,
}
Deriving a NamedId
The stylus::object::new_named_id function is responsible for creating a new NamedId for a given generic type T. Intenally, the native function compute_named_id performs a Keccak256 hash of the generic struct’s fully qualified name.
native fun compute_named_id<T: key>(): address;
public fun new_named_id<T: key>(): NamedId<T> {
NamedId { id: ID { bytes: compute_named_id<T>() } }
}
Example
The following snippet demonstrates how to implement a Singleton pattern using NamedIds. We define a COUNTER_ struct to derive the id for our Counter object.
module example::counter;
use stylus::{
tx_context::TxContext,
object::{Self, NamedId},
transfer::{Self}
};
// The struct which name will be used to generate the NamedId
public struct COUNTER_ has key {}
// A storage object with a NamedId as first field.
public struct Counter has key {
id: NamedId<COUNTER_>,
value: u64
}
// To create the counter, we call the object::new_named_id function over type COUNTER_ to get the NamedId.
entry fun create(ctx: &TxContext) {
transfer::share_object(Counter {
id: object::new_named_id<COUNTER_>(),
value: 42
});
}
NamedId Lifecycle
In the same fashion we handled the deletion of Objects with UID, the framework provides a native function to delete Objects with NamedId.
/// Deletes the object with a `NamedId` from the storage.
public native fun remove<T: key>(id: NamedId<T>);
Note
NamedIds remove function is re-exported asdelete, so it can be used the way asUID’sdeletefunction.
Wrapped objects
Wrapping refers to nesting structs to organize data structures in Move. When an object is wrapped, the object no longer exists independently on the contract’s storage. You can no longer look up the object by its ID, as the object becomes part of the data of the object that wraps it. Most importantly, you can no longer pass the wrapped object as an argument in a Move call. The only access point is through the object that wraps it.
It is not possible to create circular wrapping behavior, where A wraps B, B wraps C, and C also wraps A.
To embed a struct type in an object with a key ability, the struct type must have the store ability.
This example shows a basic wrapper pattern:
public struct Foo has key {
id: UID,
bar: Bar,
}
public struct Bar has key, store {
id: UID,
value: u64,
}
In this scenario the object type Foo wraps the object type Bar. The object type Foo is the wrapper or wrapping object.
Creating a wrapped object
Consider the following example:
- The
wrapfunction takes theObjectby value, which requires the caller to be the current owner. - The
Objectis moved into thewrappedfield of a newWrapperinstance. - The
Wrapperis transferred to the sender. The innerObjectis no longer a top-level asset; it is now part of theWrapper’s private state and is not directly accessible using itsID.
module example::wrapped;
use stylus::{
tx_context::TxContext,
object::{Self, UID},
transfer::{Self}
};
// The object to be wrapped
public struct Object has key, store {
id: UID,
data: vector<u8>
}
// The wrapper object
public struct Wrapper has key {
id: UID,
wrapped: Object
}
// This function takes the Object by value, wraps it in the Wrapper and transfer the Wrapper to the transaction sender.
public fun wrap(o: Object, ctx: &mut TxContext) {
transfer::transfer(Wrapper { id: object::new(ctx), wrapped: o }, ctx.sender());
}
Unwraping a wrapped object
You can take out the wrapped object and transfer it to an address, modify it, delete it, or freeze it. This is called unwrapping. When an object is unwrapped, it becomes an independent object again and can be accessed directly by its ID. The object’s ID stays the same across wrapping and unwrapping.
Note
The wrapped object cannot be extracted without destroying the wrapper!
This example shows a basic function used to unwrap an object:
/// Unpacks the Wrapper, deletes its UID, and returns the inner Object to the sender.
public fun unwrap(w: Wrapper, ctx: &TxContext) {
// Unpack the struct to access the fields
let Wrapper { id, wrapped } = w;
// The Wrapper's UID must be explicitly deleted
id.delete();
// The inner Object is now a top-level asset again
transfer::transfer(wrapped, ctx.sender());
}
If a user calls wrap followed by unwrap, the final state returns the original Object to the user’s address as a standalone entity.
Objects Namespace
To maintain compatibility with the EVM, Move objects are organized within a global storage layout. Conceptually, the storage can be represented by the following Solidity mapping:
// Conceptually: owner => object_id => object_data
mapping(bytes32 => mapping(bytes32 => Object<T>)) public Objects;
This nested approach serves two primary purposes:
-
Ownership Partitioning: The outer mapping is keyed by the owner identifier. This can be an account address or an object
UID(or precomputedNamedId) in the case of wrapped objects. -
Object Retrieval: The inner mapping is keyed by the unique Object
UID(or precomputedNamedId), ensuring that data for specific assets can be retrieved effortlessly.
Important
The data organized this way naturally prevents unauthorized access, as each account or object can only access its own namespace unless explicitly allowed (e.g., via the Peep API).
Note
The mapping itself is stored at a specific slot, the slot 0.
Handling different ownership types
The framework distinguishes between different object lifecycles by routing them to specific owner keys within the mapping. This design allows the runtime to handle owned, shared, and frozen objects under a single consistent logic:
| Object State | Owner Key | Purpose |
|---|---|---|
| Owned | address / UID / NamedId | Objects belonging to a specific account or parent object. |
| Shared | 0x1 | Objects made globally accessible for any user to interact with. |
| Frozen | 0x2 | Objects made permanently read-only and immutable. |
By utilizing 0x1 and 0x2 as fixed keys, the framework ensures these states are globally unique and easily handled.
Type Safety and Validation
To enforce strict type safety, the framework prevents “type-casting” at the storage level. Every object’s data blob begins with a Type Hash header:
-
Offset 0-8: Stores a 64-bit Type hash derived from the Move struct definition.
-
Offset 8: Contains the actual serialized fields of the object.
When a contract attempts to peep or load an object, the runtime first verifies that the hash in storage matches the hash of the Move type specified in the code. If they don’t match, the transaction reverts.
The following section provides a detailed overview of the storage layout, including how different data types are encoded and stored within this framework.
Storage Layout
Just like in Solidity, understanding how data is stored in Move is crucial for optimizing smart contract performance and ensuring efficient resource management. The way data is stored in storage follows the same encoding principles as in Solidity. There are some exceptions that are specific to Move, but the general concepts remain the same.
Almost every data type is encoded in a way that optimizes for space, packing multiple smaller data types into a single 32-byte storage slot when possible.
Typehash
Every type has a unique typehash, which is a u64 value derived from type’s canonical representation. This typehash is used to identify the type of data stored in a particular storage slot. The typehash is always present and it occupies the first 8 bytes of the storage slot.
For example, consider the following struct:
public struct MyStruct has key {
id: UID,
a: u8,
b: u16,
}
The storage layout for an instance of MyStruct would look like this:
n [ bbatttttttt]
Where:
nis the slot number.ttttttttis the 8-byte typehash forMyStruct.ais the byte representing the fielda.bbis the 2-byte representation of the fieldb.
The typehash is used internally by the runtime to ensure that the data being accessed matches the expected type, providing a layer of type safety and to check if an slot is empty.
Data sizes
The following table summarizes the sizes of various data types in Move:
| Data Type | Size (bytes) |
|---|---|
bool | 1 |
u8 | 1 |
u16 | 2 |
u32 | 4 |
u64 | 8 |
u128 | 16 |
u256 | 32 |
address | 20 |
Structs
Non-storage structs
If the struct is not an storage object, the struct is interpreted as a tuple of its fields. The fields are laid out in the order they are defined in the struct, with smaller fields packed together to optimize space:
public struct PackedStruct {
w: u8,
x: u16,
y: bool,
z: u32,
}
public struct MyStruct has key {
id: UID,
a: u8,
b: u16,
c: PackedStruct,
}
The storage layout for an instance of MyStruct would look like this:
n [ zzzzyxxwbbatttttttt]
└───┬──┘
▼
PackedStruct
Storage structs
The case of nested storage objects is different. Each storage object is stored in its own storage slot, and the parent struct only contains a reference (the UID) to the child storage object:
public struct PackedStruct has key, store{
id: UID,
w: u8,
x: u16,
y: bool,
z: u32,
}
public struct MyStruct has key {
id: UID,
a: u8,
b: u16,
c: PackedStruct,
}
The storage layout for an instance of MyStruct would look like this:
MyStruct:
n [ bbatttttttt]
n+1 [<bytes storing PackedStruct ID> ] (32 bytes)
PackedStruct
m [ zzzzyxxwuuuuuuuu]
Where:
nis the first slot ofMyStruct.n+1is the second slot ofMyStruct, which contains theUIDof thePackedStruct.ttttttttis the 8-byte typehash forMyStruct.ais the byte representing the fielda.bis the 2-byte representation of the fieldb.mis the slot where the actualPackedStructdata is stored, identified by theUIDin slotn+1.uuuuuuuuis the 8-byte typehash forPackedStruct.wis the byte representing the fieldw.xis the 2-byte representation of the fieldx.yis the byte representing the fieldy.zis the 4-byte representation of the fieldz.
Enums
Simple enums
If the enum is simple (i.e., none of the variants contain data), it is stored as a single byte representing the variant index.
public enum SimpleEnum {
A,
B,
C,
}
public struct MyStruct has key {
id: UID,
e: SimpleEnum,
}
The storage layout for an instance of MyStruct would look like this:
n [ etttttttt]
Where:
nis the slot number.ttttttttis the 8-byte typehash forMyStruct.eis the byte representing the variant index ofSimpleEnum.
Complex enums
If the enum is complex (i.e., some variants contain data), it is stored with a discriminant byte followed by the data for the active variant.
Note
Since complex enums can vary in size depending on the active variant, the space used by the enum is the size of the larger variant.
public enum ComplexEnum {
A(u8),
B(u16, u32),
C,
}
public struct MyStruct has key {
id: UID,
e: ComplexEnum,
}
The storage layout for an instance of MyStruct would look like this:
n [ BBBBBBbtttttttt]
Where:
nis the slot number.ttttttttis the 8-byte typehash forMyStruct.bis the byte representing the variant ofComplexEnum.BBBBBBis the space allocated for the largest variant ofComplexEnum(in this case, variantBwhich contains au16and au32
Dynamic arryas and Strings
Like in Solidity, dynamically sized arrays have unpredictable sizes, so they cannot be placed between other state variables in storage. For layout purposes, they are treated as occupying a fixed 32 bytes, while their actual contents are stored separately, beginning at a storage slot determined by a Keccak‑256 hash.
To know more about how dynamic arrays and strings are stored, refer to the Solidity documentation on dynamic arrays.
Note
Strings in Move are implemented as
vector<u8>, so they follow the same storage layout rules as dynamic arrays.
Data ordering
Data is ordered in storage based on the order of declaration in the struct. This means that the order of the fields in the struct definition directly affects how they are laid out in storage. For example, consider the following struct:
pub struct Inefficient has key{
id: UID,
a: u8,
b: u256,
c: u8,
d: u256,
}
The storage layout for an instance of Inefficient would look like this:
n [ atttttttt]
n + 1 [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]
n + 2 [ c]
n + 3 [dddddddddddddddddddddddddddddddd]
Occupying 4 storage slots.
To optimize the storage layout, we can reorder the fields to group smaller data types together:
pub struct Efficient has key{
id: UID,
a: u8,
c: u8,
b: u256,
d: u256,
}
The storage layout for an instance of Efficient would look like this:
n [ catttttttt]
n + 1 [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]
n + 2 [dddddddddddddddddddddddddddddddd]
Important
All the things valid for
UIDare also valid forNamedIdas well.
EVM Specifics
This compiler is based on the SUI’s version of the Move language, which is tailored for the SUI blockchain. Since Stylus targets the EVM, some adjustements were made to ensure compatibility with EVM’s architecture and execution model.
Some of those adjustments are coded directly inside the compiler (like the ABI encoding/decoding or the adapted object model, while others are implemented as part of the Stylus Framework, a library that provides EVM-compatible abstractions and utilities for Move developers.
In this chapter we are going to cover all the EVM-specific features supported.
Abi
The ABI (Application Binary Interface) defined for Move modules compiled for Stylus follow the same specification as the one used in Solidity. This allows for seamless interaction between Move contracts and other EVM-compatible contracts and tools. It also means that existing tools that understand Solidity’s ABI can be used to interact with Move contracts compiled for Stylus.
Function modifiers
You can specify Solidity’s function modifiers for Move functions using the #[ext(abi(...)] attribute. This attribute allows you to annotate a Move function with one or more modifiers. These modifiers will be used when generating the ABI for the function.
For example:
#[ext(abi(view))]
entry fun read(counter: &Counter): u64 {
counter.value
}
In this example, the read function is annotated with the view modifier, indicating that it does not modify the state of the contract. This information will be included in the generated ABI, allowing external tools to understand the function’s behavior.
Supported modifiers include:
viewpurepayable
Exporting the ABI
To export the ABI of a module, you can use the move-stylus CLI tool. You can run the following command to generate the ABI file:
move-stylus export-abi
Both human readable and JSON format are supported. By default, the ABI will be exported in JSON format. To export the ABI in human readable format, you can use the -r flag:
move-stylus export-abi -r
For the example counter in the Deploy and Interact section, the exported ABI in human readable format would look like this:
/**
* This file was automatically generated and represents a Move program.
* For more information, please see [The Move to Stylus compiler](https://github.com/rather-labs/move-stylus).
*/
// SPDX-License-Identifier: MIT-OR-APACHE-2.0
pragma solidity ^0.8.23;
interface Counter {
event NewUID(bytes32 indexed uid);
function create() external;
function increment(bytes32 counter) external;
function read(bytes32 counter) view external returns (uint64);
function setValue(bytes32 counter, uint64 value) external;
}
Transaction Context
In Stylus, the transaction context provides essential information about the transaction being executed. This context includes details such as the sender’s address, the value transferred, gas limit, and other relevant metadata.
To access the transaction context in your Move modules, you can use the TxContext struct provided by the Stylus Framework. This struct encapsulates all the information about the executing transaction.
Note
The
TxContextstruct is a way to access transaction-specific information accessed using global variables in Solidity.
The information is accesed through TxContext’s struct methods:
sender: Return the address of the user that signed the current transaction.value: Return the number of WEI sent with the message.block_number: Return the current block’s number.block_basefee: Return the current block’s base fee (EIP-3198 and EIP-1559).block_gas_limit: Return the current block’s gas limit.block_timestamp: Return the current block’s timestamp as seconds since unix epoch.chain_id: Return the chain ID of the current transaction.gas_price: Return the gas price of the transaction.data: Return the calldata of the current transaction as avector<u8>.
Using TxContext
The TxContext struct is an special struct handled entirely by the compiler. This means that an instance of the object cannot be created directly. Instead, you can obtain a reference (muttable or immutable) to the current transaction context by declaring it as a parameter in an entry function.
use stylus::tx_context::{Self};
entry fun example_function(tx: &TxContext) {
let sender_address = tx.sender();
let value_sent = tx.value();
// You can now use sender_address and value_sent as needed
}
In the example above, the example_function entry function takes a reference to the TxContext as a parameter. Inside the function, you can access various properties of the transaction context using the provided methods.
ABI
Any appearence of the TxContext struct in the function signature will be omitted from the generated ABI, as it is an implicit parameter provided by the execution environment. For example, in the set_value function extracted from the Build and Test guide:
/// Set value (only runnable by the Counter owner)
entry fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == ctx.sender(), 0);
counter.value = value;
}
The transaction context is used to assert that the sender of the transaction is the owner of the Counter resource. However, in the generated ABI, the ctx parameter will not be included, as it is implicitly provided by the Stylus execution environment.
As seen in the Abi section, the ABI for the set_value function is:
function setValue(bytes32 counter, uint64 value) external;
Events
Events enable data to be recorded publicly on the blockchain. Each log entry includes the contract’s address, up to four topics, and binary data of arbitrary length.
Stylus Framework makes events compatible with the EVM event model, allowing seamless interaction between Move contracts and EVM-based tools and libraries.
Declaring Events
Events are common structs annotated with the #[ext(event(..)] attribute. This attribute indicates that the struct is intended to be used as an event.
#[ext(event(indexes = 2))]
public struct Event has copy, drop {
a: u32,
b: address,
c: u128,
d: vector<u8>,
}
Events must have the copy and drop abilities. In the example above, the Event struct has four fields: a, b, c, and d. The indexes = 2 parameter specifies that the first two fields (a and b) will be indexed topics in the event log.
You can also declare events as anonymous by annoating anonymous inside the event attribute:
#[ext(event(indexes = 2, anonymous))]
public struct Event has copy, drop {
a: u32,
b: address,
c: u128,
d: vector<u8>,
}
An anonymous event does not include the event signature as the first topic in the log entry.
Note
If an event struct has more indexed fields than allowed (maximum of 4 for anonymous events, maximum of 3 for non-anonymous events), the Move compiler will raise a compilation error.
Emitting Events
To emit an event, use the stylus::event::emit function followed by an instance of the event struct.
use stylus::event::{Self};
public fun emit_event_example() {
let event_instance = Event {
a: 42,
b: @0x1,
c: 1000,
d: vec![1, 2, 3, 4, 5],
};
stylus::event::emit(event_instance);
}
In the example above, the emit_event_example function creates an instance of the Event struct and emits it using the stylus::event::emit function. The emitted event will be recorded on the blockchain with the specified indexed topics and data.
Warning
If an event struct is not annotated with the
#[ext(event(..)]attribute, it will not be recognized as an event, and attempting to emit it will result in a compilation error.
Payable Functions
In Solidity, a payable function is a special type of function that can receive Ether. When a function is marked as payable, it allows the contract to accept and process incoming Ether transactions.
In Move, the amount of WEI sent with a transaction can be accessed using the TxContext struct from the Stylus Framework. This struct provides a method called value() that returns the amount of WEI sent with the message.
Payable functions do not require any special declaration in Move. Any entry function can access the value sent with the transaction through the TxContext. Here is an example of a payable function in Move:
use stylus::tx_context::{Self};
#[abi(payable)]
entry fun deposit_funds(ctx: &TxContext) {
let amount = ctx.value();
// Process the received amount as needed
}
In the example above, the deposit_funds function is an entry function that can receive WEI. The amount of WEI sent with the transaction is accessed using the ctx.value() method. The #[abi(payable)] attribute indicates that this function is intended to be payable, useful when exporting the contract’s ABI.
Receive & Fallback Functions
Receive and Fallback are special-purpose functions triggered by indirect interactions—scenarios where a contract is called without a specific function identifier. While these are unique to the EVM and not native to Move, the framework provides a bridge that allows developers to implement this logic directly in Move modules.
Receive Function
In the EVM ecosystem, the receive function is a dedicated gateway for plain Ether transfers (calls with empty calldata). According to the Solidity specification:
A contract can have at most one receive function, declared using
receive() external payable { ... }(without the function keyword). This function cannot have arguments, cannot return anything and must have external visibility and payable state mutability. It can be virtual, can override and can have modifiers.
To implement this in Move, targeting Arbitrum Stylus, a function must meet the following requirements:
- Naming: The function name must be exactly
receive. - Visibility: It must be marked as an
entryfunction. - Signature: It must take no arguments (except a reference to the
stylus::TxContext) and return no values. - State Mutability: It must be annotated with the
#[ext(abi(payable))]attribute to accept ether.
use stylus::tx_context::TxContext;
#[ext(abi(payable))]
entry fun receive(ctx: &TxContext) {
// Custom logic
}
Important
The
receivefunction is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers. If no such function exists, but a payablefallbackfunction exists, thefallbackfunction will be called on a plain Ether transfer. If neither areceiveEther nor a payablefallbackfunction is present, the contract cannot receive Ether through a transaction that does not represent a payable function call and throws an exception.
Fallback Function
The fallback function serves as the “catch-all” handler for a contract. According to the Solidity specification:
A contract can have at most one fallback function, declared using either
fallback () external [payable]orfallback (bytes calldata input) external [payable] returns (bytes memory output)(both without the function keyword). This function must have external visibility. A fallback function can be virtual, can override and can have modifiers.
In the Stylus framework, implementing a fallback function requires adhering to these specific rules:
- Naming: The function name must be exactly
fallback. - Visibility: It must be marked as an
entryfunction. - State Mutability: It may optionally be annotated with
#[ext(abi(payable))]if it needs to accept Ether. - Signature: It takes no arguments, except optionally a reference to the
TxContext, and can return avector<u8>representing some raw output bytes without abi-encoding.
For instance, both of these are valid fallback declarations:
#[ext(abi(payable))]
entry fun fallback(ctx: &TxContext): vector<u8> {
// Do something with the calldata
}
entry fun fallback() {}
The fallback function is triggered in two main cases:
- Unknown Selectors: When a caller attempts to invoke a function signature that does not exist in the module.
- Empty Data: When a contract receives a call with no data and no
receivefunction is defined.
Errors & Revert
In the Stylus framework, errors are represented as Move structs. To use a struct as a revert reason, it must be annotated with the #[ext(abi_error)] attribute.
Revert
The framework provides a native revert function to halt execution and undo all state changes, returning the abi-encoded error to the caller.
module stylus::error;
/// Reverts the current transaction.
///
/// This function reverts the current transaction with a given error.
public native fun revert<T: copy + drop>(error: T);
The revert function is generic over the type T. For a successful compilation, T must be a struct annotated with #[ext(abi_error)].
Error encoding
The framework ensures that Move errors follow the Solidity ABI specification. This allows Ethereum-compatible tools like Etherscan to decode and display the error correctly.
The error selector is a 4-byte identifier that tells the EVM which specific custom error is being triggered. It is calculated by taking the first 4 bytes of the Keccak256 hash of the error’s signature string. The signature string is composed of the error struct name followed by the field types in parentheses.
#[ext(abi_error)]
public struct ExampleError {
message: String,
code: u8
}
For the struct above, the signature string is ExampleError(string,uint8). The selector is derived as: keccak256("ExampleError(string,uint8)") → 0x... (first 4 bytes).
The complete error message consists of the 4-byte selector followed by the ABI-encoded fields of the struct.
Tip
The signature
Error(string)is a special, built-in Solidity error type. If you define a struct that results in this signature, the Stylus node and most Ethereum explorers will automatically decode and display the raw string message.
Cross contract calls
In smart contract development, cross contract calls refer to the ability of one smart contract to invoke functions or methods of another smart contract. This feature is essential for building complex decentralized applications (dApps) that require interaction between multiple contracts.
To perform a cross-contract call, you must define an Interface—a Move module that describes the target contract’s signatures without implementing their logic.
Defining the Interface
Using the ERC-20 standard as an example, we define an interface module. This module acts as a “header file” that tells the compiler how to encode arguments and decode results for the target contract.
module erc20call::erc20call;
use stylus::contract_calls::{ContractCallResult, CrossContractCall};
#[ext(external_call)]
public struct ERC20(CrossContractCall) has drop;
public fun new(configuration: CrossContractCall): ERC20 {
ERC20(configuration)
}
#[ext(external_call(view))]
public native fun total_supply(self: &ERC20): ContractCallResult<u256>;
#[ext(external_call(view))]
public native fun balance_of(self: &ERC20, account: address): ContractCallResult<u256>;
#[ext(external_call)]
public native fun transfer(self: &ERC20, account: address, amount: u256): ContractCallResult<bool>;
#[ext(external_call(view))]
public native fun allowance(self: &ERC20, owner: address, spender: address): ContractCallResult<u256>;
#[ext(external_call)]
public native fun approve(self: &ERC20, spender: address, amount: u256): ContractCallResult<bool>;
#[ext(external_call)]
public native fun transfer_from(self: &ERC20, sender: address, recipient: address, amount: u256): ContractCallResult<bool>;
The CrossContractCall Struct
The CrossContractCall struct is a key component that facilitates cross contract calls. It encapsulates the necessary information and configuration required to perform these calls. When you create an instance of the ERC20 struct, you pass in a CrossContractCall configuration that specifies how to interact with the target contract.
The struct used to represent the external contract (e.g., ERC20) must follow these rules:
-
It must be annotated with the
#[ext(external_call)]attribute. -
It must be a tuple struct containing only a field of type
CrossContractCall. -
It must have the
dropability.
Creating a New Instance
To create a new instance of the cross contract call struct, you need to provide a CrossContractCall configuration. This configuration specifies the address of the target contract and any additional settings required for the cross contract calls.
-
new(address): This function creates a newCrossContractCallinstance with the specified contract address. It initializes the configuration with default values for gas and value. -
gas(u64): Amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one. -
value(u256): Value in WEI to send to the account. -
delegate(): This function configures the cross contract call to be a delegate call.
let erc20 = erc20call::new(
ccc::new(erc20_address)
.gas(100000)
.value(0)
);
Defining the Functions
Since the actual code lives on another contract, your interface functions are declared as native. The compiler automatically generates the underlying WASM code to perform the call based on these signatures. Each cross contract call function must follow the following requirements:
-
It must take a reference to your interface struct (e.g.,
&ERC20) as first argument, followed by the actual function arguments. -
It must return one of the following types:
ContractCallResult<T>whereTis the return type of the target function in the called contract. This wrapper type is used to handle potential errors that may occur during the cross contract call.ContractCallEmptyResultif the target function in the called contract does not return any value.
-
It must be annotated with the
#[ext(external_call)]attribute. You can also add function modifiers such asvieworpureto indicate that the function does not modify the state. This will hint the compiler to optimize the call accordingly by using a static call instead of a common one.
Note
The cross contact calls follow the same ABI rules as the regular functions. i.e: If the target function contains a
IDparameter, the type for that parameter will bebytes32.
ContractCallResult<T> struct
The ContractCallResult<T> struct is a generic wrapper type used to encapsulate the result of a cross contract call. It provides methods to handle the result and potential errors that may occur during the call:
-
succeded: Returnstrueif the cross contract call was successful, otherwise returnsfalse.let result: ContractCallResult<u256> = erc20.balance_of(address); if (result.succeded()) { let balance: u256 = result.get_result(); // Use the balance } else { // Handle the error } -
get_result: Returns the actual result of the cross contract call if it was successful. If the call failed, this method will abort the transaction.let result: ContractCallResult<u256> = erc20.total_supply(); let total_supply: u256 = result.get_result();
ContractCallEmptyResult struct
The ContractCallEmptyResult struct is used for cross contract calls that do not return any value. It provides a method to check if the call was successful:
-
succeded: Returnstrueif the cross contract call was successful, otherwise returnsfalse.let result: ContractCallEmptyResult = cross_contract_call.call_some_function(123); if (result.succeded()) { // Approval succeeded } else { // Handle the error }
Using Cross Contract Calls
To use the cross contract calls defined in the erc20call module, you need to create an instance of the ERC20 struct with the appropriate CrossContractCall configuration. Then, you can invoke the methods defined in the struct to interact with the target ERC-20 contract.
module book::erc20user;
use erc20call::erc20call::{Self};
use stylus::contract_calls as ccc;
entry fun balance_of_erc20(erc20_address: address, balance_address: address): u256 {
let erc20 = erc20call::new(ccc::new(erc20_address));
erc20.balance_of(balance_address).get_result()
}
entry fun total_supply(erc20_address: address): u256 {
let erc20 = erc20call::new(ccc::new(erc20_address));
erc20.total_supply().get_result()
}
Delegated Calls
In addition to direct cross contract calls, Move also supports delegated calls. A delegated call allows a contract to execute code in the context of another contract, effectively allowing it to “borrow” the functionality of that contract. This is useful for scenarios where you want to extend the functionality of an existing contract without modifying its code.
To illustrate how delegated calls work, let’s define four moodules:
-
delegated_counter: A simple contract that maintains a counter and provides functions to increment and get the counter value via delegate calls. -
delegated_counter_interface: An interface module that defines the cross contract call structure for thedelegated_countercontract (just like we did with the ERC-20 at the begginning of this chapter). -
counter_logic_a: A contract that contains logic to increment the counter by 1. -
counter_logic_b: A contract that contains logic to increment the counter by 2.
counter’s functions will be just proxy function using the delegate calls to call the logic defined in counter_logic_a and counter_logic_b.
Warning
Once a
CrossContractCallobject is configure to peform delegate calls, it cannot be undone and all the calls will be delegated.
Counter Module
module book::delegated_counter;
use stylus::{
tx_context::TxContext,
object::{Self, UID},
transfer::{Self},
contract_calls::{Self}
};
use book::delegated_counter_interface as dci;
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Create a new counter.
entry fun create(contract_logic: address, ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: 25,
contract_address: contract_logic,
});
}
/// Increment a counter.
entry fun increment(counter: &mut Counter) {
let delegated_counter = dci::new(
contract_calls::new(counter.contract_address)
.delegate()
);
let res = delegated_counter.increment(&mut counter.id);
assert!(res.succeded(), 33);
}
/// Read counter.
entry fun read(counter: &Counter): u64 {
counter.value
}
/// Change the address where the delegated calls are made.
entry fun change_logic(counter: &mut Counter, logic_address: address) {
counter.contract_address = logic_address;
}
In the increment function, we create a CrossContractCall object configured for delegate calls using the delegate() method. We then call the increment function defined in the logic contract.
Delegated Counter Interface Module
module book::delegated_counter_interface;
use stylus::{
contract_calls::{ContractCallEmptyResult, CrossContractCall},
object::UID
};
#[ext(external_call)]
public struct CounterCall(CrossContractCall) has drop;
public fun new(configuration: CrossContractCall): CounterCall {
CounterCall(configuration)
}
#[ext(external_call)]
public native fun increment(self: &CounterCall, counter: &mut UID): ContractCallEmptyResult;
Counter Logic A Module
module book::delegated_counter_logic_a;
use stylus::{
tx_context::TxContext,
object::UID
};
#[ext(external_struct(module_name = b"delegated_counter", address = @0x0))]
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Increment a counter by 1.
entry fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
Counter Logic B Module
module book::delegated_counter_logic_b;
use stylus::{
tx_context::TxContext,
object::UID
};
#[ext(external_struct(module_name = b"delegated_counter", address = @0x0))]
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Increment a counter by 2.
entry fun increment(counter: &mut Counter) {
counter.value = counter.value + 2;
}
Once you have defined these modules, you should deploy the delegated_counter_logic_a and delegated_counter_logic_b modules. Then, when creating a new counter using the create function in the delegated_counter module, you can specify which logic contract to use for incrementing the counter.
Later, you can change the logic contract by calling the change_logic function, allowing you to switch between different incrementing behaviors dynamically.
Warning
Since delegated calls execute code in the context of the calling contract, that means that the calling contract’s storage is the one that is modified.
When interacting with objects from a caller conntrct, you must specify the module name and address where the object is defined using the
#[ext(external_struct(module_name = ..., address = ...))]attribute. That is because objects with the same name can be defined in different modules. Those objects are different by definition and the Move compiler needs to know which one to use.If you don’t specify the module name and address, the object you are trying to interact will not be found and a runtime error will be thrown.
Constructor
The init function is a specialized entry point used to run logic exactly once. This is the primary way to initialize contract’s state, set up configurations, or mint initial objects.
Requirements
To be recognized as a constructor, a function must meet the following criteria:
- Naming: The function name must be exactly
init. - Visibility: The function must be
private. - Signature: It takes a single argument, a reference to the
TxContext, and has no return values.
Important
If any of the above requirements is not met, the compiler will throw an error.
Implementation Example
The following snippet demonstrates a classic init implementation. When the function is called, it creates and shares a Foo object.
module test::constructor;
use stylus::{
tx_context::TxContext,
object::{Self, UID},
transfer::{Self}
};
public struct Foo has key {
id: UID,
value: u64
}
entry fun init(ctx: &mut TxContext) {
let foo = Foo {
id: object::new(ctx),
value: 101,
};
transfer::share_object(foo);
}
Technical Enforcement
The framework ensures the “once-only” execution of the init function through a persistent storage flag.
-
During compilation, a deterministic storage slot is assigned to the initialization flag. This slot is calculated as the
Keccak256hash of the string “init_key”. -
When the function is executed, the runtime checks this storage slot. If the flag is
false, theinitfunction executes. Once successful, the flag is set totrue. -
This prevents anyone from re-triggering the initialization logic after the contract is live, ensuring the module’s setup and configuration remain immutable.
ABI Representation
In the generated ABI, the init function is represented as follows:
function constructor() external;
Stylus Framework
Stylus Framework provides EVM/Stylus specific feature such as cross-contract calls, events, storage functions, as well as high-level abstractions like the Peep API and Account module to facilitate smart contract development on the Stylus platform.
Exported address
The Stylus Framework is exported at address 0x2. It can also be used via the alias stylus.
Content
The Stylus Framework includes the following modules:
| Module | Description | Section |
|---|---|---|
stylus::account | Provides functionalities for managing Stylus accounts | - |
stylus::contract_calls | Implements functionalities to perform cross-contract calls. | Cross contract calls |
stylus::dynamic_fields | Implements dynamic fields for flexible data storage. | Dynamic Fields |
stylus::dynamic_fields_named_id | Implements dynamic fields for flexible data storage for NamedIds. | Dynamic Fields |
stylus::error | Provides error handling functionalities specific to EVM. | Errors |
stylus::events | Provides functionalities for emitting. | Events |
stylus::object | Provides utilities for working with Stylus objects model. | Object Model |
stylus::peep | Allows to read objects owned by other accountes. | Peep API |
stylus::sol_types | Contains types that map to Solidity types. | Solidity Types |
stylus::table | Provides a table data structure for key-value storage. | Dynamic Fields |
stylus::tx_context | Provides access to transaction context information. | Transaction Context |
Peep
The peep function enables cross-account storage reads. It allows you to inspect the fields of an object owned by a specific address, provided you know the object’s unique identifier.
module stylus::peep;
use stylus::object::UID;
public native fun peep<T: key>(owner_address: address, id: &UID): &T;
-
Immutable Access:
peepreturns an immutable reference (&T). This strictly enforces read-only access; you can inspect the data, but you cannot modify the object or move it out of the owner’s storage. -
Type Safety: The function is generic over type
T. The caller must specify the exact struct type expected in storage. If the data at that location does not match the layout ofT, we will get a runtime error.
Implementation Example
The following example demonstrates how one user can inspect another user’s storage.
- Creation: Alice calls
create_footo generate aFoostruct. This object is moved into her storage namespace. - Observation: Bob can then read Alice’s
Fooinstance by callingpeep_foo. He must provide Alice’s address and the specificUIDof the object he wishes to inspect.
module test::peep;
use stylus::{
peep as stylus_peep,
object::{Self, UID},
tx_context::TxContext,
transfer::transfer
};
public struct Foo has key, store {
id: UID,
secret: u32
}
/// Alice calls this to create and own her Foo object.
entry fun create_foo(ctx: &mut TxContext) {
let foo = Foo {
id: object::new(ctx),
secret: 42
};
transfer(foo, ctx.sender());
}
/// Bob (or anyone) calls this to read a Foo object
/// owned by a Alice.
entry fun peep_foo(owner: address, foo_id: &UID): &Foo {
stylus_peep::peep<Foo>(owner, foo_id)
}
Important
The Peep API is designed to explictly allow reading data from other accounts. When using it inside a contract, the programmer is responsible for ensuring that such cross-account reads are appropriate and do not violate any privacy or security considerations.
Dynamic Fields
The Dynamic Fields API makes it possible to attach objects to other objects. Its behavior resembles that of a Map in other programming languages. Dynamic fields allow objects of any type to be attached.
There is no restriction on the number of dynamic fields that can be linked to an object.
Definition
The Dynamic Fields API is defined in the stylus::dynamic_fields module. They are attached to object’s UID or NamedId via a name, and can be accessed using that name. There can be only one field with a given name attached to an object.
The definition of the Dynamic Field is as follows:
/// Internal object used for storing the field and value
public struct Field<Name: copy + drop + store, Value: store> has key {
/// Determined by the hash of the object ID, the field name value and it's type,
/// i.e. hash(parent.id || name || Name)
id: UID,
/// The value for the name of this field
name: Name,
/// The value bound to this field
value: Value,
}
As defined, dynamic fields are maintained within an internal Field object. Its UID is generated deterministically from the object ID, the field name, and the field type. The Field object stores both the field name and the associated value. The constraints on the Name and Value type parameters specify the abilities required for the key and value.
Note
Dynamic fields defined for
NamedIds implemented in thestylus::dynamic_fields_named_idmodule have the same structure, they just replaceUIDforNamedId.
Example
module book::dynamic_fields;
use stylus::dynamic_fields::{Self};
/// An example struct to be used as a dynamic field.
public struct Foo has key {
id: object::UID,
}
/// Creates a new `Foo` object and shares it.
entry fun create_foo(ctx: &mut TxContext) {
let foo = Foo { id: object::new(ctx) };
transfer::share_object(foo);
}
/// Attaches a dynamic field to the `Foo` object
/// The key is the `name` parameter of type `String`
/// The value is the `value` parameter of type `u64`.
entry fun attach_dynamic_field(foo: &mut Foo, name: String, value: u64) {
dynamic_field::add(&mut foo.id, name, value);
}
In this example, we define a struct Foo with a UID. We create an instance of Foo and share it. Then, we attach a dynamic field to the Foo object using the dynamic_field::add function, where the key is a String name and the value is a u64.
Usage
The methods provided for dynamic fields are simple: a field can be added using add, removed with remove, and accessed through borrow or borrow_mut. In addition, the exists_ method can be used to verify whether a field is present. For stricter type checks, the exists_with_type method is available.
Following the previous example, here are some additional functions that demonstrate how to read, check existence, mutate, and remove dynamic fields:
/// Reads a dynamic field from the `Foo` object by its name.
entry fun read_dynamic_field(foo: &Foo, name: String): &u64 {
dynamic_field::borrow(&foo.id, name)
}
/// Checks if a dynamic field exists in the `Foo` object by its name.
entry fun dynamic_field_exists(foo: &Foo, name: String): bool {
dynamic_field::exists_(&foo.id, name)
}
/// Mutates a dynamic field in the `Foo` object by its name.
entry fun mutate_dynamic_field(foo: &mut Foo, name: String) {
let val = dynamic_field::borrow_mut(&mut foo.id, name);
*val = *val + 1;
}
/// Removes a dynamic field from the `Foo` object by its name.
entry fun remove_dynamic_field(foo: &mut Foo, name: String): u64 {
let value = dynamic_field::remove(&mut foo.id, name);
value
}
In this example, we define functions to read, check existence, mutate, and remove dynamic fields from the Foo object using the provided methods from the stylus::dynamic_fields module.
Orphaned Dynamic Fields
The object::delete() function, which deletes an object with the provided UID from storage, does not track dynamic fields and therefore cannot prevent them from becoming orphaned. When the parent is deleted, its dynamic fields are not automatically removed. As a result, these dynamic fields remain stored but can no longer be accessed.
Custom Type as a Field Name
In the previous examples, primitive types were used as field names because they possess the necessary abilities. Dynamic fields become even more powerful when custom types are employed as field names. This approach provides a more structured method of organizing data and also helps safeguard field names from being accessed by external modules.
/// A custom type that includes fields.
public struct AccessoryKey has copy, drop, store { name: String }
/// An empty key, which can only be attached once.
public struct MetadataKey has copy, drop, store {}
The two field names defined earlier are AccessoryKey and MetadataKey. The AccessoryKey includes a String field, which allows it to be used multiple times with different name values. In contrast, the MetadataKey is an empty key and can only be attached once.
Exposing UID
Granting mutable access to a UID or NamedId poses a security risk. Allowing a UID or NamedId to be exposed as a mutable reference can result in unintended modifications or even the removal of an object’s dynamic fields. Therefore, it is essential to fully understand the consequences before exposing a UID as mutable.
Since dynamic fields are bound to UID or NamedId their usage in other modules depends on whether the UID or NamedId is accessible. By default, struct visibility safeguards the id field, preventing direct access from other modules. However, if a public accessor method returns a reference to the UID or NamedId, dynamic fields can be read externally.
/// Exposes the UID of the Foo struct, so that other modules can read
/// dynamic fields.
public fun uid(f: &Foo): &UID {
&f.id
}
In the example above, the UID of a Foo object is exposed. While this approach may be suitable for certain applications, it is important to keep in mind that exposing the UID permits access to any dynamic field attached to the object.
If UID exposure is required only within the package, consider using restrictive visibility such as public(package). An even safer option is to provide specific accessor methods that allow reading only designated fields.
/// Restrict UID access to modules within the same package.
public(package) fun uid_package(f: &Foo): &UID {
&f.id
}
/// Provide access to borrow dynamic fields from a character.
public fun borrow<Name: copy + store + drop, Value: store>(
c: &Character,
n: Name
): &Value {
stylus::dynamic_field::borrow(&c.id, n)
}
Dynamic Fields vs. Regular Fields
Dynamic fields are more costly than regular fields because they demand extra storage and incur higher access costs. While they offer greater flexibility, this comes at a price. It is therefore important to weigh the trade-offs carefully when deciding between dynamic fields and regular fields.
Account
The stylus::account module allows you to inspect account data, such as the amount of ETH held or whether an address contains smart contract code. This functions are direct wrappers to the account stylus host functions.
module stylus::account;
public fun get_account_code_size(account_address: address): u32 {
account_code_size(account_address)
}
public fun get_account_balance(account_address: address): u256 {
account_balance(account_address)
}
/// Gets the size of the code in bytes at the given address.
/// The semantics are equivalent to that of the EVM's [`EXT_CODESIZE`].
///
/// [`EXT_CODESIZE`]: https://www.evm.codes/#3B
native fun account_code_size(account_address: address): u32;
/// Gets the ETH balance in wei of the account at the given address.
/// The semantics are equivalent to that of the EVM's [`BALANCE`] opcode.
///
/// [`BALANCE`]: https://www.evm.codes/#31
native fun account_balance(account_address: address): u256;
Advanced Programmability
This section covers advanced topics in Move programming on the Stylus platform. It is intended for developers who are already familiar with the basics of Move and want to explore more complex concepts and techniques.
Pattern: Hot Potato
Within the abilities system, a struct that has no abilities is referred to as a hot potato. Such a struct cannot be stored (neither as an object nor as a field within another struct), and it cannot be copied or discarded. Therefore, once constructed, it must be properly unpacked by its defining module. If left unused, the compiler will throw an error due to the presence of a value without the drop ability.
The term originates from the children’s game in which a ball is passed rapidly among players, and no one wants to be the last to hold it when the music stops—otherwise, they are out of the game. This serves as the perfect analogy for the pattern: an instance of a hot-potato struct is passed between calls, and no module is allowed to retain it.
Defining a Hot Potato
A hot potato can be any struct without abilities. For instance, the following struct qualifies as a hot potato:
public struct Request {}
Since Request has no abilities and cannot be stored or ignored, the module must provide a function to unpack it. For example
/// Constructs a new `Request`.
public fun new_request(): Request {
Request {}
}
/// Unpacks the `Request`. Because of the hot potato pattern, this function
/// must be called to prevent the compiler from throwing an error due to an
/// unused value.
public fun confirm_request(request: Request) {
let Request {} = request;
}
Pattern: Capability
In programming, a capability is a specialized token that grants its owner the explicit right to perform a specific action or access a protected resource. This pattern is a powerful way to manage security and access control. Instead of checking a user’s name against a list (Identity-Based Access Control), the system simply checks if the caller is “holding” the required capability.
Capability as an Object
In the Object Model, capabilities are represented as objects. An owner of an object can pass this object to a function to prove that they have the right to perform a specific action. Due to strict typing, the function taking a capability as an argument can only be called with the correct capability.
In the example below, we define an AdminCap. Upon deployment, the init function creates this object and transfers it to the caller. Subsequently, only the holder of this unique AdminCap can execute admin_cap_fn.
module test::capability;
use stylus::{
transfer::{Self},
object::{Self, UID},
tx_context::{Self, TxContext}
};
public struct AdminCap has key { id: UID }
/// Create the AdminCap object on module deployment and transfer it to the caller
fun init(ctx: &mut TxContext) {
transfer::transfer(
AdminCap { id: object::new(ctx) },
ctx.sender()
)
}
entry fun admin_cap_fn(_: &AdminCap ) {}
Address Check vs Capability
Utilizing objects as capabilities is a relatively new concept in blockchain programming. And in other smart-contract languages, authorization is often performed by checking the address of the sender. This pattern is still viable on our framework, however, overall recommendation is to use capabilities for better security, discoverability, and code organization.
Using capabilities has several advantages over the address check:
- Migration of admin rights is easier with capabilities due to them being objects. In case of address, if the admin address changes, all the functions that check the address need to be updated - hence, require a package upgrade.
- Function signatures are more descriptive with capabilities.
- Object Capabilities don’t require extra checks in the function body, and hence, decrease the chance of a developer mistake.
- An owned Capability also serves in discovery.
However, the address approach has its own advantages. For example, if an address is multisig, and transaction building gets more complex, it might be easier to check the address. Also, if there’s a central object of the application that is used in every function, it can store the admin address, and this would simplify migration. The central object approach is also valuable for revocable capabilities, where the admin can revoke the capability from the user.
Pattern: Wrapper Type
Sometimes it is useful to define a new type that behaves like an existing one but with specific modifications or restrictions. For instance, you might design a collection type that functions like a vector but prevents modification of elements once they are inserted. The wrapper type pattern is a practical way to achieve this.
Definition
The wrapper type pattern is a design approach where a new type is created to wrap an existing type. While distinct from the original, the wrapper type can be converted to and from it.
In most cases, it is implemented as a positional struct containing a single field.
module book::stack;
/// Very simple stack implementation using the wrapper type pattern. Does not allow
/// accessing the elements unless they are popped.
public struct Stack<T>(vector<T>) has copy, store, drop;
/// Create a new instance by wrapping the value.
public fun new<T>(value: vector<T>): Stack<T> {
Stack(value)
}
/// Push an element to the stack.
public fun push_back<T>(v: &mut Stack<T>, el: T) {
v.0.push_back(el);
}
/// Pop an element from the stack. Unlike `vector`, this function won't
/// fail if the stack is empty and will return `None` instead.
public fun pop_back<T>(v: &mut Stack<T>): Option<T> {
if (v.0.length() == 0) option::none()
else option::some(v.0.pop_back())
}
/// Get the size of the stack.
public fun size<T>(v: &Stack<T>): u64 {
v.0.length()
}
Common Practices
When the goal is to extend the behavior of an existing type, it is common to provide accessors for the wrapped type. This approach allows users to interact with the underlying type directly when necessary.
For example, the following code defines the inner(), inner_mut(), and into_inner() methods for the Stack type:
/// Allows reading the contents of the `Stack`.
public fun inner<T>(v: &Stack<T>): &vector<T> {
&v.0
}
/// Allows mutable access to the contents of the `Stack`.
public fun inner_mut<T>(v: &mut Stack<T>): &mut vector<T> {
&mut v.0
}
/// Unpacks the `Stack` into the underlying `vector`.
public fun into_inner<T>(v: Stack<T>): vector<T> {
let Stack(inner) = v;
inner
}
Advantages
The wrapper type pattern provides several benefits:
- Custom Functions: Enables defining custom functions for an existing type.
- Robust Function Signatures: Restricts function signatures to the new type, making the code more reliable.
- Improved Readability: Offers clearer, more descriptive type names, enhancing code readability.
Disadvantages
The wrapper type pattern is particularly useful in two scenarios:
- When limiting the behavior of an existing type while exposing a custom interface.
- When extending the behavior of an existing type.
However, it comes with some drawbacks:
- Verbosity: Implementation can be verbose, especially if many methods of the wrapped type need to be exposed.
- Sparse Implementation: Often minimal, as it primarily forwards calls to the wrapped type.