Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This handbook provides a comprehensive guide for migrating Solana programs to Arbitrum Stylus smart contracts. This guide helps you understand the key differences and provides practical patterns for successful migration.

What Arbitrum Stylus offers

Arbitrum Stylus provides a smart contract platform that supports developers writing contracts in Rust, C, and C++. The platform maintains full Ethereum Virtual Machine (EVM) compatibility. Unlike most EVM chains that require programming contracts with DSLs like Solidity or Vyper, Stylus enables the use of languages that compile to WebAssembly (WASM), offering:

  • Performance: 10-100x faster execution than Solidity
  • Memory efficiency: More efficient memory usage and lower gas costs
  • Familiar languages: Use Rust, C, C++ and other mainstream languages and tooling instead of coping with Solidity
  • EVM compatibility: Full interoperability with existing Ethereum tooling

Why migrate from Solana to Stylus

Technical advantages

  • Shared Language: Both Solana and Stylus support Rust, reducing the learning curve and enabling code reuse of business logic, data structures, and algorithms.
  • Enhanced Interoperability: Stylus contracts can interact seamlessly with the broader Ethereum ecosystem, including DeFi protocols, bridges, and tooling.
  • Simplified Architecture: The EVM account model reduces complexity compared to Solana's account model in state management and cross-contract interactions.

Business benefits

  • Market Access: Tap into Ethereum's large user base and liquidity pools
  • Tooling Ecosystem: Leverage mature development tools and infrastructure
  • EVM Compatibility: Easy integration with existing Ethereum protocols and services

Key differences overview

AspectSolanaStylus
LanguageRust (native/Anchor)Rust + EVM compatibility
Account ModelExplicit accountsEVM account model
State StorageAccount dataContract storage
Function CallsInstructionsDirect method calls
Gas ModelCompute unitsWei/Gas
ConcurrencyHigh (parallel execution)Sequential (EVM)

Development environment setup

Before starting your migration, set up your development environment according to the official documentation.

Project structure comparison

Understanding the typical project structures helps you organize your migration:

Solana native project

solana-program/
├── Cargo.toml
├── src/
│   ├── lib.rs          # Program entrypoint
│   ├── processor.rs    # Instruction processing
│   ├── instruction.rs  # Instruction definitions
│   ├── state.rs        # Account state structures
│   └── error.rs        # Program errors
└── tests/
    └── integration.rs

Anchor project

anchor-program/
├── Anchor.toml
├── programs/
│   └── my-program/
│       ├── Cargo.toml
│       └── src/
│           └── lib.rs   # All-in-one program file
├── tests/
└── migrations/

Stylus project

stylus-contract/
├── Cargo.toml
├── src/
│   ├── lib.rs          # Contract implementation
│   └── main.rs         # ABI export entry point
└── tests/
    └── integration.rs

Migration strategy

This handbook follows a systematic approach:

  1. Program Structure Migration: Convert entry points and instruction dispatch
  2. State Storage Patterns: Transform account-based storage to contract storage
  3. Access Control Migration: Migrate signer checks and PDA patterns
  4. External Program Calls: Convert CPIs to contract interactions
  5. Native Token Operations: Handle receiving, holdings and transferring native tokens
  6. Fungible Token Handling: Convert SPL tokens usage to ERC20 contracts, extensions and interfaces
  7. Non-Fungible Token Handling: Convert Metaplex NFT metadata to the ERC721 standard
  8. Errors and Events: Map program errors to EVM reverts and events

Each chapter includes working examples that you can run, test, and build on.

This handbook assumes familiarity with Rust and basic blockchain concepts. If you are new to Solana or Arbitrum, review their respective documentation first.

Next steps

With your environment set up and understanding of the handbook structure, you can begin the migration process. The next chapter covers Program Structure Migration, where your Solana program's entry points and instruction handling transform into Stylus contract methods.

Program structure and instructions

This chapter explains how to translate Solana's instruction-dispatch model to Stylus contracts. The transformation involves converting instruction handlers into direct methods, mapping parameter and return types to ABI-encodable forms.

Solana program model

Native

When not using a framework, Solana programs require manual instruction deserialization, account validation and instruction handler routing.

#[derive(BorshSerialize, BorshDeserialize)]
pub struct CounterState {
    pub value: u64,
    pub authority: Pubkey,
}

#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instruction {
    Initialize { value: u64 },
    Increment,
    SetValue { new_value: u64 },
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let Ok(ix) = Instruction::try_from_slice(instruction_data) else {
        return Err(ProgramError::InvalidInstructionData);
    };

    match ix {
        Instruction::Initialize { value } => process_initialize(accounts, value),
        Instruction::Increment => process_increment(accounts),
        Instruction::SetValue { new_value } => process_set_value(accounts, new_value),
    }
}

fn process_initialize(accounts: &[AccountInfo], initial_value: u64) -> ProgramResult {
    let [counter_state_pda, authority, system_program] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    // Validate accounts, create PDA account & write initial state...

    Ok(())
}

fn process_increment(accounts: &[AccountInfo]) -> ProgramResult {
    let [counter_state_pda] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    // Validate accounts & write incremented value...

    Ok(())
}

fn process_set_value(accounts: &[AccountInfo], new_value: u64) -> ProgramResult {
    let [counter_state_pda, authority] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    // Validate accounts & write new value...

    Ok(())
}

Anchor

The Anchor framework abstracts the boilerplate for deserializing instruction data and function routing behind a combination of derive and procedural macros.

Some business logic, such as access control, developers can encode declaratively using attributes within the #[derive(Accounts)] macro.

use anchor_lang::prelude::*;

#[program]
pub mod counter {
    use super::*;
    
    pub fn initialize(ctx: Context<Initialize>, value: u64) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.value = value;
        counter.authority = ctx.accounts.authority.key();
        Ok(())
    }
    
    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.value += 1;
        Ok(())
    }
    
    pub fn set_value(ctx: Context<SetValue>, new_value: u64) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.value = new_value;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init_if_needed, payer = authority, space = 8 + 8 + 32)]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
}

#[derive(Accounts)]
pub struct SetValue<'info> {
    // Adds the constraint that the `authority` field in the `Counter` account
    // must match the `authority` key within this struct.
    #[account(mut, has_one = authority)]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
}

#[account]
pub struct Counter {
    pub value: u64,
    pub authority: Pubkey,
}

Stylus contract model

Stylus uses macros to abstract the boilerplate of decoding calldata and handler function selection.

Unlike Solana, state storage couples to business logic and the contract manages it solely. Functions that change the contract state must take &mut self. Developers conventionally add read-only view functions to access contract state that clients may require. These cost no gas for external callers. Any function that takes &self in a development block tagged with #[public] appears as an externally viewable view function.

The developer can create contracts with some initial state by using the #[constructor] attribute macro to mark the initialization function. This function runs automatically as part of the contract creation flow.

sol! {
    #[derive(Debug, PartialEq, Eq)]
    error Unauthorized(address caller);
}

#[derive(SolidityError, Debug, PartialEq, Eq)]
pub enum CounterError {
    Unauthorized(Unauthorized),
}

#[storage]
#[entrypoint]
pub struct Counter {
    value: StorageU256,
    authority: StorageAddress,
}

#[public]
impl Counter {
    #[constructor]
    pub fn constructor(&mut self, initial_value: U256) {
        let authority = self.vm().msg_sender();

        self.value.set(initial_value);
        self.authority.set(authority);
    }

    pub fn increment(&mut self) -> U256 {
        let new_value = self.value.get() + U256::ONE;

        self.value.set(new_value);

        new_value
    }

    pub fn set_value(&mut self, new_value: U256) -> Result<(), CounterError> {
        let caller = self.vm().msg_sender();

        // Only authority can set value
        if caller != self.authority.get() {
            return Err(CounterError::Unauthorized(Unauthorized { caller }));
        }

        self.value.set(new_value);

        Ok(())
    }

    // View functions
    pub fn get_value(&self) -> U256 {
        self.value.get()
    }

    pub fn get_authority(&self) -> Address {
        self.authority.get()
    }
}

Key transformation: Entry points

Coming from native Solana

A 1-to-1 mapping exists between instruction enum variants and Stylus' #[public] functions that can change state (that take &mut self).

Any fields associated with the instruction enum variants convert to ABI-encodable function parameters.

Coming from Anchor

Each #[program] function that takes a different Context<T> maps to a #[public] &mut self function in Stylus. Any parameters coming after ctx are also required.

Stylus idioms

Function return types

In both Native and Anchor-based Solana programs, most instruction handlers return a Result<(), ProgramError>, meaning no return data exists when no errors occur.

Stylus operates within the EVM ecosystem where successful function results commonly continue into further computation. This provides much more flexibility when it comes to return types.

Functions may return nothing at all, an infallible result (that just T) or a Result<T, E> where E supports SolidityError and T supports AbiType. The programmer decides the best approach.

View or pure computation functions typically return infallible results and functions that change state according to some business logic typically return a Result type.

The Errors and Events section covers error type definition in more detail.

Contract state initialization

In Solana programs, initialization appears as just another instruction or series of instructions, but where care must protect unauthorized use.

Stylus, like Solidity contracts, provides a specialized constructor function that runs during contract creation with parameters provided by the contract deployer. Developers commonly use this pattern to provide initial values such as initial authorized addresses and other contract state as parameters to this function.

Parameter types

The constraint on parameter types in Stylus contracts requires that they support AbiType. This trait parallels the BorshDeserialize and BorshSerialize traits in Solana programs. All primitive types and tuples of primitive types already support this trait.

The Stylus contract programmer can define more complex types such as enums and structs using the sol! macro. Those patterns fall outside the scope of this guide.

Next steps

Now that you understand program structure transformation, explore:

State storage

One of the most significant differences between Solana and Stylus involves how state storage and access work. This chapter covers the transformation from Solana's account-based storage model to Stylus's contract storage variables, including type mappings, access patterns, and cost considerations.

Solana account model

Solana stores each piece of state in a separate, dedicated account with predetermined size allocation. Accounts must maintain a rent-exempt balance proportional to their size to avoid automatic deallocation, though closing accounts refunds the Lamports. Programs can only access the accounts provided in the instruction, which is fully client-controlled. Extreme care must be taken to validate the accounts received when processing the instruction in order to prevent exploits.

Native

Solana programs can group related state together to be stored in accounts owned by the program. Native programs are required to explicitly create and initialize those accounts.

#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Eq)]
pub struct Data {
    pub bool: bool,
    pub uint8: u8,
    pub uint16: u16,
    pub uint32: u32,
    pub uint64: u64,
    pub uint128: u128,
    pub int8: i8,
    pub int16: i16,
    pub int32: i32,
    pub int64: i64,
    pub int128: i128,
    pub string: String,
    pub bytes: Vec<u8>,
    pub address: Pubkey,
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    if Data::try_from_slice(instruction_data).is_err() {
        return Err(ProgramError::InvalidInstructionData);
    };

    let [payer, data_account, system_program] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    let lamports_required = Rent::get()?.minimum_balance(instruction_data.len());

    invoke(
        &system_instruction::create_account(
            payer.key,
            data_account.key,
            lamports_required,
            instruction_data.len() as u64,
            program_id,
        ),
        &[payer.clone(), data_account.clone(), system_program.clone()],
    )?;

    let mut data_account_buffer = data_account.try_borrow_mut_data()?;

    data_account_buffer.copy_from_slice(instruction_data);

    Ok(())
}

State that is to be maintained against other accounts such as user EOAs or other programs stored in accounts associated with Program Derived Addresses (PDAs). Each PDA is derived from a set of seeds, which can be viewed as a prefixed key and a 'bump' byte. In native Solana programs, the program owning the PDA must be careful to create and verify those accounts against the canonical bump seed, as well as protect against re-initialization attacks.

pub static SEED_SEPARATOR: &[u8] = b"-";
pub static PLAYER_PDA_ACCOUNT_SEED: &[u8] = b"player";

pub const STARTING_LIVES: u8 = 10;

#[derive(BorshSerialize, BorshDeserialize)]
pub struct PlayerAccountState {
    pub lives: u8,
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let Ok(args) = PlayerAccountState::try_from_slice(instruction_data) else {
        return Err(ProgramError::InvalidInstructionData);
    };

    // ensure correct initial player state is provided
    if args.lives != STARTING_LIVES {
        return Err(ProgramError::InvalidInstructionData);
    }

    let [payer, player_pda_account, system_program] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    // ensure PDA has not already been initialized
    if !player_pda_account.data_is_empty()
        || player_pda_account.lamports() > 0
        || *player_pda_account.owner == ID
    {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    let (player_pda_account_key, bump) = Pubkey::find_program_address(
        &[PLAYER_PDA_ACCOUNT_SEED, SEED_SEPARATOR, payer.key.as_ref()],
        &ID,
    );

    if player_pda_account_key != *player_pda_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    let lamports_required = Rent::get()?.minimum_balance(instruction_data.len());

    invoke_signed(
        &system_instruction::create_account(
            payer.key,
            player_pda_account.key,
            lamports_required,
            instruction_data.len() as u64,
            program_id,
        ),
        &[
            payer.clone(),
            player_pda_account.clone(),
            system_program.clone(),
        ],
        &[&[
            PLAYER_PDA_ACCOUNT_SEED,
            SEED_SEPARATOR,
            payer.key.as_ref(),
            &[bump],
        ]],
    )?;

    let mut data_account_buffer = player_pda_account.try_borrow_mut_data()?;

    data_account_buffer.copy_from_slice(instruction_data);

    Ok(())
}

Anchor

When defining Solana program instructions using the Anchor framework, program-owned accounts can be created automatically using the #[account(init, ...)] attribute. This also implicitly adds checks for already initialized accounts and always uses the canonical bump seed unless otherwise specified.

#[derive(InitSpace)]
#[account]
pub struct Data {
    pub bool: bool,
    pub uint8: u8,
    pub uint16: u16,
    pub uint32: u32,
    pub uint64: u64,
    pub uint128: u128,
    pub int8: i8,
    pub int16: i16,
    pub int32: i32,
    pub int64: i64,
    pub int128: i128,
    #[max_len(200)]
    pub string: String,
    #[max_len(200)]
    pub bytes: Vec<u8>,
    pub address: Pubkey,
}

#[derive(Accounts)]
#[instruction(data: Data)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        init,
        payer = payer,
        space = 8 + Data::INIT_SPACE
    )]
    pub data_account: Account<'info, Data>,
    pub system_program: Program<'info, System>,
}

#[program]
pub mod data_storage {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, data: Data) -> Result<()> {
        *ctx.accounts.data_account = data;
        Ok(())
    }
}

The Anchor framework abstracts the boilerplate required to manually create PDA accounts and automatically checks for initialization as well as correct seeds.

#[derive(InitSpace)]
#[account]
pub struct PlayerAccountState {
    pub lives: u8,
}

#[derive(Accounts)]
#[instruction()]
pub struct CreatePlayerAccount<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        init,
        payer = payer,
        space = 8 + PlayerAccountState::INIT_SPACE,
        seeds = [PLAYER_PDA_ACCOUNT_SEED, SEED_SEPARATOR, payer.key().as_ref()],
        bump,
    )]
    pub player_account: Account<'info, PlayerAccountState>,
    pub system_program: Program<'info, System>,
}

#[program]
pub mod data_storage {
    use super::*;

    pub fn create_player_account(ctx: Context<CreatePlayerAccount>) -> Result<()> {
        ctx.accounts.player_account.lives = STARTING_LIVES;
        Ok(())
    }
}

Stylus storage model

Stylus stores all state within the contract's storage slots, allowing dynamic growth as needed within gas limits. Storage operations cost gas that users pay during transaction execution, with the smart contract execution VM automatically handling storage accessibility. State persists without ongoing rent requirements, as users only pay costs when writing data and for transaction calldata.

The #[storage] attribute macro can be used to implement StorageType for user-defined types, allowing state to be logically grouped together.

#[storage]
pub struct IntegerStore {
    uint8: StorageU8,
    uint16: StorageU16,
    uint32: StorageU32,
    uint64: StorageU64,
    uint128: StorageU128,
    uint256: StorageU256,
    int8: StorageI8,
    int16: StorageI16,
    int32: StorageI32,
    int64: StorageI64,
    int128: StorageI128,
    int256: StorageI256,
}

#[storage]
#[entrypoint]
pub struct DataStorage {
    // Types that implement `StorageType` can be nested
    // in order to namespace and organize related storage items
    integers: IntegerStore,
    bool: StorageBool,
    string: StorageString,
    bytes: StorageBytes,
    fixed_bytes: StorageFixedBytes<4>,
    vec: StorageVec<StorageU64>,
    address: StorageAddress,
}

#[public]
impl DataStorage {
    #[constructor]
    // for example purposes only, avoid using this many parameters to functions
    #[allow(clippy::too_many_arguments)]
    pub fn constructor(
        &mut self,
        bool: bool,
        uint8: U8,
        uint16: U16,
        uint32: U32,
        uint64: U64,
        uint128: U128,
        uint256: U256,
        int8: I8,
        int16: I16,
        int32: I32,
        int64: I64,
        int128: I128,
        int256: I256,
        string: String,
        bytes: Vec<u8>,
        fixed_bytes: FixedBytes<4>,
        vec: Vec<U64>,
        address: Address,
    ) {
        // unless explicitly set, all storage is initialized to the types respective zero-value
        self.bool.set(bool);
        self.integers.uint8.set(uint8);
        self.integers.uint16.set(uint16);
        self.integers.uint32.set(uint32);
        self.integers.uint64.set(uint64);
        self.integers.uint128.set(uint128);
        self.integers.uint256.set(uint256);
        self.integers.int8.set(int8);
        self.integers.int16.set(int16);
        self.integers.int32.set(int32);
        self.integers.int64.set(int64);
        self.integers.int128.set(int128);
        self.integers.int256.set(int256);
        self.string.set_str(string);
        self.bytes.set_bytes(bytes);
        self.fixed_bytes.set(fixed_bytes);

        for x in vec {
            self.vec.push(x);
        }

        self.address.set(address);
    }

    fn get_bool(&self) -> bool { self.bool.get() }
    fn get_uint8(&self) -> U8 { self.integers.uint8.get() }
    fn get_uint16(&self) -> U16 { self.integers.uint16.get() }
    fn get_uint32(&self) -> U32 { self.integers.uint32.get() }
    fn get_uint64(&self) -> U64 { self.integers.uint64.get() }
    fn get_uint128(&self) -> U128 { self.integers.uint128.get() }
    fn get_uint256(&self) -> U256 { self.integers.uint256.get() }
    fn get_int8(&self) -> I8 { self.integers.int8.get() }
    fn get_int16(&self) -> I16 { self.integers.int16.get() }
    fn get_int32(&self) -> I32 { self.integers.int32.get() }
    fn get_int64(&self) -> I64 { self.integers.int64.get() }
    fn get_int128(&self) -> I128 { self.integers.int128.get() }
    fn get_int256(&self) -> I256 { self.integers.int256.get() }
    fn get_string(&self) -> String { self.string.get_string() }
    fn get_bytes(&self) -> Vec<u8> { self.bytes.get_bytes() }
    fn get_fixed_bytes(&self) -> FixedBytes<4> { self.fixed_bytes.get() }
    fn get_address(&self) -> Address { self.address.get() }

    // Option<T> is not available as a return or a public function parameter type
    // as `None` cannot be EVM ABI-encoded, hence the use of (bool, T)
    fn get_vec_item(&self, idx: u32) -> (bool, U64) {
        self.vec.get(idx).map_or((false, U64::ZERO), |x| (true, x))
    }
}

The StorageMap type can be used to store state using keys that are calculated at runtime, for example the Address of the caller. Care needs to be taken as the mapped type's zero value will be returned if an entry does not exist for the provided key. For some data this is fine and expected, such as token balances or allowances.

#[storage]
#[entrypoint]
pub struct Mappings {
    player_lives: StorageMap<Address, StorageU8>,
    player_is_dead: StorageMap<Address, StorageBool>,
}

sol! {
    #[derive(Debug, PartialEq, Eq)]
    error PlayerAlreadyExists(address player);

    #[derive(Debug, PartialEq, Eq)]
    error PlayerNotFound(address player);
}

#[derive(SolidityError, Debug, PartialEq, Eq)]
pub enum ContractError {
    PlayerAlreadyExists(PlayerAlreadyExists),
    PlayerNotFound(PlayerNotFound),
}

impl Mappings {
    fn player_exists(&self, player: Address) -> bool {
        self.player_lives.get(player) > U8::ZERO || self.player_is_dead.get(player)
    }
}

#[public]
impl Mappings {
    pub fn create_player_account(&mut self) -> Result<(), ContractError> {
        let msg_sender = self.vm().msg_sender();

        if self.player_exists(msg_sender) {
            return Err(PlayerAlreadyExists { player: msg_sender }.into());
        }

        self.player_lives
            .insert(self.vm().msg_sender(), U8::from(STARTING_LIVES));

        Ok(())
    }

    pub fn get_is_dead(&self, player: Address) -> Result<bool, ContractError> {
        if !self.player_exists(player) {
            return Err(PlayerNotFound { player }.into());
        }

        Ok(self.player_is_dead.get(player))
    }

    pub fn get_lives(&self, player: Address) -> Result<U8, ContractError> {
        if !self.player_exists(player) {
            return Err(PlayerNotFound { player }.into());
        }

        Ok(self.player_lives.get(player))
    }
}

Solana to Stylus type mappings

Primitive types

Solana TypeStylus Storage TypeRust Parameter/Return TypeNotes
u8StorageU8U8Direct mapping
u16StorageU16U16Direct mapping
u32StorageU32U32Direct mapping
u64StorageU64 or StorageU256U64 or U256Use U256 where ERC standards expect it
u128StorageU128 or StorageU256U128 or U256Prefer U256 for interoperability
boolStorageBoolboolDirect mapping
PubkeyStorageAddressAddressUse Address for EOAs and other contracts
StringStorageStringStringDirect mapping
Vec<u8>StorageBytesVec<u8> or BytesDirect mapping
[u8; N]StorageFixedBytes<N>[u8; N] or FixedBytes<N>Direct mapping

More complex schemas

Solana PatternStylus Storage Pattern
Several PDAs with fixed seedsMultiple structs tagged with #[storage] nested under the struct marked #[entrypoint]
PDAs with dynamic seeds, like a user PubkeyUse StorageMap<K, V> where K consists of the dynamic seed component and V implements StorageType

Nested mappings

In cases where there are multiple dynamic components of a key, a nested StorageMap can be used.

use stylus_sdk::storage::{StorageMap, StorageU256, StorageBool};

#[storage]
pub struct Transaction {
    amount: StorageU256,
    timestamp: StorageU256,
    completed: StorageBool,
}

#[storage]
#[entrypoint]
pub struct TokenContract {
    allowances: StorageMap<Address, StorageMap<Address, StorageU256>>,
    user_transactions: StorageMap<Address, StorageMap<U256, Transaction>>,
}

#[public]
impl TokenContract {
    pub fn approve(&mut self, spender: Address, amount: U256) {
        self.allowances
            .setter(self.vm().msg_sender())
            .insert(spender, amount);
    }

    pub fn record_transaction(&mut self, tx_id: U256, amount: U256) {
        let block_time = self.vm().block_timestamp();

        // a nested `setter` cannot be called in a single expression
        let mut txs = self.user_transactions.setter(self.vm().msg_sender());
        let mut tx = txs.setter(tx_id);

        tx.amount.set(amount);
        tx.timestamp.set(U256::from(block_time));
        tx.completed.set(true);
    }

    pub fn allowance(&self, owner: Address, spender: Address) -> U256 {
        self.allowances.getter(owner).get(spender)
    }

    pub fn transaction(&self, address: Address, tx_id: U256) -> (U256, U256, bool) {
        let txs = self.user_transactions.getter(address);
        let tx = txs.get(tx_id);

        (tx.amount.get(), tx.timestamp.get(), tx.completed.get())
    }
}

Cost considerations

Solana costs

  • Account Creation: Rent-exempt balance amount (approximately 0.002 SOL per account)
  • Storage Rent: Ongoing cost for keeping accounts alive
  • No Cost for Reads: Reading account data requires no fee

Stylus costs

  • Storage Writes: Gas cost for storing data (approximately 20,000 gas per 32-byte slot)
  • Storage Reads: Much cheaper than writes (approximately 200 gas per read)
  • One-time Cost: Pay when writing, no ongoing costs

Optimization strategies

Pack Related Data:

// Instead of separate mappings
#[storage]
pub struct Inefficient {
    usernames: StorageMap<Address, StorageString>,
    emails: StorageMap<Address, StorageString>,
    created_at: StorageMap<Address, StorageU256>,
}

// Use a single struct
#[storage]
pub struct Efficient {
    profiles: StorageMap<Address, UserProfile>,
}

#[storage]
pub struct UserProfile {
    username: StorageString,
    email: StorageString,
    created_at: StorageU256,
}

Use Appropriate Types:

#[storage]
pub struct OptimizedStorage {
    // Don't waste space with oversized types
    small_counter: StorageU8,    // for values 0-255
    timestamp: StorageU32,       // sufficient for timestamps
    large_value: StorageU256,    // when needed
    
    // Pack booleans together
    flag1: StorageBool,
    flag2: StorageBool,
    flag3: StorageBool,
    // These will be packed into a single storage slot
}

Next steps

With storage patterns understood, the next chapter covers Access Control - transforming Solana's signer checks and PDA patterns to Stylus access control mechanisms.

Access control

This chapter demonstrates how to translate Solana's signer checks and PDA patterns to Stylus. Learn how to verify callers with MessageAccess::msg_sender, replace PDAs with contract-controlled state and authorization logic.

Solana authentication model

Solana's stateless program model requires verifying account relationships and signatures to enforce access control. The first step verifies which accounts have signed the instruction. Next, the program checks PDAs: both those derived from access-control configuration and those tied to the state requiring authenticated, mutable access. It then validates that the provided accounts match their expected seeds and the correct program owns them. Only after these checks can the program compare the signer keys with the authorized keys stored in the verified access-control configuration. This model often leads Solana programs to construct hierarchies of PDAs to guarantee that access-control logic applies consistently across all dependent state.

Native

#[derive(BorshSerialize, BorshDeserialize, BorshSchema)]
pub struct Config {
    pub authority: Pubkey,
    pub publisher: Pubkey,
}

#[derive(BorshSerialize, BorshDeserialize, BorshSchema)]
pub struct Price {
    pub base: u64,
    pub quote: u64,
    pub timestamp: i64,
}

#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instruction {
    InitializeConfig { publisher: Pubkey },
    UpdateConfig { publisher: Pubkey },
    PublishPrice { base: u64, quote: u64 },
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let instruction = Instruction::try_from_slice(instruction_data)
        .map_err(|_| ProgramError::InvalidInstructionData)?;

    match instruction {
        Instruction::InitializeConfig { publisher } => {
            process_initialize_config(program_id, accounts, publisher)
        }
        Instruction::UpdateConfig { publisher } => {
            process_update_config(program_id, accounts, publisher)
        }
        Instruction::PublishPrice { base, quote } => {
            process_publish_price(program_id, accounts, base, quote)
        }
    }
}

fn process_initialize_config(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    publisher: Pubkey,
) -> ProgramResult {
    let [config_account, authority_account, system_program] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !authority_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let (config_pda_key, config_bump) =
        Pubkey::find_program_address(&[CONFIG_PDA_SEED], program_id);

    if config_pda_key != *config_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    if !config_account.data_is_empty()
        || config_account.lamports() > 0
        || *config_account.owner == *program_id
    {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    let space_required = borsh::max_serialized_size::<Config>().expect("infallible");
    let lamports_required = Rent::get()?.minimum_balance(space_required);

    invoke_signed(
        &system_instruction::create_account(
            authority_account.key,
            config_account.key,
            lamports_required,
            space_required as u64,
            program_id,
        ),
        &[
            authority_account.clone(),
            config_account.clone(),
            system_program.clone(),
        ],
        &[&[CONFIG_PDA_SEED, &[config_bump]]],
    )?;

    let mut account_data = config_account.try_borrow_mut_data()?;

    Config {
        authority: *authority_account.key,
        publisher,
    }
    .serialize(&mut account_data.as_mut())?;

    Ok(())
}

fn process_update_config(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    publisher: Pubkey,
) -> ProgramResult {
    let [config_account, authority_account] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !authority_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let (config_pda_key, _) = Pubkey::find_program_address(&[CONFIG_PDA_SEED], program_id);
    if config_pda_key != *config_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    if *config_account.owner != *program_id {
        return Err(ProgramError::IncorrectProgramId);
    }

    let mut config_data = Config::try_from_slice(&config_account.data.borrow())
        .map_err(|_| ProgramError::InvalidAccountData)?;

    if config_data.authority != *authority_account.key {
        return Err(ProgramError::MissingRequiredSignature);
    }

    config_data.publisher = publisher;

    let mut account_data = config_account.try_borrow_mut_data()?;
    config_data.serialize(&mut account_data.as_mut())?;

    Ok(())
}

fn process_publish_price(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    base: u64,
    quote: u64,
) -> ProgramResult {
    let [config_account, last_price_account, publisher_account, system_program] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !publisher_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let (config_pda_key, _) = Pubkey::find_program_address(&[CONFIG_PDA_SEED], program_id);

    if config_pda_key != *config_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    if *config_account.owner != *program_id {
        return Err(ProgramError::IncorrectProgramId);
    }

    let config_data = Config::try_from_slice(&config_account.data.borrow())
        .map_err(|_| ProgramError::InvalidAccountData)?;

    if config_data.publisher != *publisher_account.key {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let (last_price_pda_key, last_price_bump) = Pubkey::find_program_address(
        &[LAST_PRICE_PDA_SEED, config_account.key.as_ref()],
        program_id,
    );

    if last_price_pda_key != *last_price_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    let needs_init = last_price_account.data_is_empty()
        || last_price_account.lamports() == 0
        || *last_price_account.owner != *program_id;

    if needs_init {
        let space_required = borsh::max_serialized_size::<Price>().expect("infallible");
        let lamports_required = Rent::get()?.minimum_balance(space_required);

        invoke_signed(
            &system_instruction::create_account(
                publisher_account.key,
                last_price_account.key,
                lamports_required,
                space_required as u64,
                program_id,
            ),
            &[
                publisher_account.clone(),
                last_price_account.clone(),
                system_program.clone(),
            ],
            &[&[
                LAST_PRICE_PDA_SEED,
                config_account.key.as_ref(),
                &[last_price_bump],
            ]],
        )?;
    }

    // Update price data
    let price_data = Price {
        base,
        quote,
        timestamp: Clock::get()?.unix_timestamp,
    };

    let mut account_data = last_price_account.try_borrow_mut_data()?;
    price_data.serialize(&mut account_data.as_mut())?;

    Ok(())
}

Anchor

#[program]
pub mod access_control {
    use super::*;

    pub fn initialize_config(ctx: Context<InitializeConfig>, publisher: Pubkey) -> Result<()> {
        let config = &mut ctx.accounts.config;
        config.authority = ctx.accounts.authority.key();
        config.publisher = publisher;
        Ok(())
    }

    pub fn update_config(ctx: Context<UpdateConfig>, publisher: Pubkey) -> Result<()> {
        let config = &mut ctx.accounts.config;
        config.publisher = publisher;
        Ok(())
    }

    pub fn publish_price(ctx: Context<PublishPrice>, base: u64, quote: u64) -> Result<()> {
        let last_price = &mut ctx.accounts.last_price;
        last_price.base = base;
        last_price.quote = quote;
        last_price.timestamp = Clock::get()?.unix_timestamp;
        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(publisher: Pubkey)]
pub struct InitializeConfig<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + Config::INIT_SPACE,
        seeds = [CONFIG_PDA_SEED],
        bump
    )]
    pub config: Account<'info, Config>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(publisher: Pubkey)]
pub struct UpdateConfig<'info> {
    #[account(mut, has_one = authority, seeds = [CONFIG_PDA_SEED], bump)]
    pub config: Account<'info, Config>,
    pub authority: Signer<'info>,
}

#[derive(Accounts)]
#[instruction(base: u64, quote: u64)]
pub struct PublishPrice<'info> {
    #[account(has_one = publisher, seeds = [CONFIG_PDA_SEED], bump)]
    pub config: Account<'info, Config>,
    #[account(
        init_if_needed,
        payer = publisher,
        space = 8 + Price::INIT_SPACE,
        seeds = [LAST_PRICE_PDA_SEED, config.key().as_ref()],
        bump
    )]
    pub last_price: Account<'info, Price>,
    #[account(mut)]
    pub publisher: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(InitSpace)]
#[account]
pub struct Config {
    pub authority: Pubkey,
    pub publisher: Pubkey,
}

#[derive(InitSpace)]
#[account]
pub struct Price {
    pub base: u64,
    pub quote: u64,
    pub timestamp: i64,
}

Stylus authentication model

Stylus contracts handle access control by checking the caller address relative to those stored in the contract's state. The contract obtains the caller address using the MessageAccess::msg_sender trait method.

#[storage]
pub struct Config {
    authority: StorageAddress,
    publisher: StorageAddress,
}

#[storage]
pub struct Price {
    base: StorageU256,
    quote: StorageU256,
    timestamp: StorageU64,
}

#[storage]
#[entrypoint]
pub struct AccessControl {
    config: Config,
    last_price: Price,
}

sol! {
    #[derive(Debug, PartialEq, Eq)]
    error InvalidAddress(address address);
    #[derive(Debug, PartialEq, Eq)]
    error Unauthorized();
}

#[derive(SolidityError, Debug, PartialEq, Eq)]
pub enum AccessControlError {
    InvalidAddress(InvalidAddress),
    Unauthorized(Unauthorized),
}

#[public]
impl AccessControl {
    #[constructor]
    pub fn constructor(&mut self, authority: Address, publisher: Address) {
        assert_ne!(
            authority,
            Address::ZERO,
            "authority cannot be a zero-address"
        );
        assert_ne!(
            publisher,
            Address::ZERO,
            "publisher cannot be a zero-address"
        );

        self.config.authority.set(authority);
        self.config.publisher.set(publisher);
    }

    pub fn update_config(&mut self, publisher: Address) -> Result<(), AccessControlError> {
        let sender = self.vm().msg_sender();

        if sender != self.config.authority.get() {
            return Err(AccessControlError::Unauthorized(Unauthorized {}));
        }

        if publisher == Address::ZERO {
            return Err(AccessControlError::InvalidAddress(InvalidAddress {
                address: publisher,
            }));
        }

        self.config.publisher.set(publisher);

        Ok(())
    }

    pub fn publish_price(&mut self, base: U256, quote: U256) -> Result<(), AccessControlError> {
        let sender = self.vm().msg_sender();

        if sender != self.config.publisher.get() {
            return Err(AccessControlError::Unauthorized(Unauthorized {}));
        }

        let timestamp = self.vm().block_timestamp();

        self.last_price.base.set(base);
        self.last_price.quote.set(quote);
        self.last_price.timestamp.set(U64::from(timestamp));

        Ok(())
    }

    pub fn get_authority(&self) -> Address {
        self.config.authority.get()
    }

    pub fn get_publisher(&self) -> Address {
        self.config.publisher.get()
    }

    pub fn get_last_price(&self) -> (U256, U256, U64) {
        (
            self.last_price.base.get(),
            self.last_price.quote.get(),
            self.last_price.timestamp.get(),
        )
    }
}

Standardized access control patterns

The most common access control pattern involves a contract having an admin or an owner. The account with the owner role can perform actions such as pausing and unpausing the contract or update the configuration.

OpenZeppelin develops many well-used and audited re-usable components for EVM-based contracts. They have ported many of those components from Solidity to Rust using Stylus' inheritance and state composition features.

For example, the Two-Step Ownership component implements ownership tracking and enables safe ownership transitions.

sol! {
    #[derive(Debug)]
    error ContractAlreadyPaused();
    #[derive(Debug)]
    error ContractAlreadyUnpaused();
}

#[derive(SolidityError, Debug)]
// In order to generate an ABI for the contract you need to manually wire
// up OpenZeppelin's error types defined with `sol!` rather than the their
// `ownable::Error` type which does not implement `SolError`
pub enum ContractError {
    InvalidOwner(ownable::OwnableInvalidOwner),
    Unauthorized(ownable::OwnableUnauthorizedAccount),
    AlreadyPaused(ContractAlreadyPaused),
    AlreadyUnpaused(ContractAlreadyUnpaused),
}

impl From<ownable::Error> for ContractError {
    fn from(value: ownable::Error) -> Self {
        match value {
            ownable::Error::UnauthorizedAccount(e) => Self::Unauthorized(e),
            ownable::Error::InvalidOwner(e) => Self::InvalidOwner(e),
        }
    }
}

#[storage]
#[entrypoint]
pub struct OwnableContract {
    // Nest the OpenZeppelin implementation within the contract
    ownable: Ownable2Step,
    is_paused: StorageBool,
}

#[public]
#[implements(IOwnable2Step<Error = ownable::Error>)]
impl OwnableContract {
    #[constructor]
    pub fn constructor(&mut self, owner: Address) -> Result<(), ContractError> {
        assert_ne!(owner, Address::ZERO, "owner cannot be a zero-address");

        self.ownable.constructor(owner)?;

        self.is_paused.set(true);

        Ok(())
    }

    pub fn pause_contract(&mut self) -> Result<(), ContractError> {
        // You can then use convenience methods on the nested implementation
        self.ownable.only_owner()?;

        if self.is_paused() {
            return Err(ContractAlreadyPaused {}.into());
        }

        self.is_paused.set(true);

        Ok(())
    }

    pub fn unpause_contract(&mut self) -> Result<(), ContractError> {
        self.ownable.only_owner()?;

        if !self.is_paused() {
            return Err(ContractAlreadyUnpaused {}.into());
        }

        self.is_paused.set(false);

        Ok(())
    }

    pub fn is_paused(&self) -> bool {
        self.is_paused.get()
    }
}

#[public]
// Wire everything up by delegating interface trait methods to the nested implementation.
// You could modify the standard behavior here if you wished.
impl IOwnable2Step for OwnableContract {
    type Error = ownable::Error;

    fn owner(&self) -> Address {
        self.ownable.owner()
    }

    fn pending_owner(&self) -> Address {
        self.ownable.pending_owner()
    }

    fn transfer_ownership(&mut self, new_owner: Address) -> Result<(), Self::Error> {
        self.ownable.transfer_ownership(new_owner)
    }

    fn accept_ownership(&mut self) -> Result<(), Self::Error> {
        self.ownable.accept_ownership()
    }

    fn renounce_ownership(&mut self) -> Result<(), Self::Error> {
        self.ownable.renounce_ownership()
    }
}

Next steps

With access control patterns established, the next chapter covers External Calls - converting Solana's Cross-Program Invocations (CPIs) to Stylus contract calls.

External Calls

This chapter demonstrates how to translate Solana CPIs into Stylus external calls.

Solana

Solana’s Cross-Program Invocation (CPI) model relies on instruction-based communication. Programs build instructions with the target program ID, required accounts, and instruction data. Unlike systems that allow direct state queries, Solana programs must receive all state through accounts passed in the transaction. CPIs are therefore used when a program needs to modify state owned by another program, with the caller explicitly providing all accounts the callee requires.

When a program controls a PDA that must sign for another program’s operation, it uses invoke_signed with the PDA’s seeds. The runtime verifies the seeds and grants signing authority.

Native

#[derive(BorshSerialize, BorshDeserialize)]
pub struct LastResultAccount {
    pub last_result: u128,
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    if AdderArgs::try_from_slice(instruction_data).is_err() {
        return Err(ProgramError::InvalidInstructionData);
    };

    let [payer, last_result_account, system_program, adder_program] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    if *adder_program.key != ADDER_PROGRAM_ID {
        return Err(ProgramError::InvalidAccountData);
    }

    // Find the expected PDA and bump
    let (expected_pda, bump) =
        Pubkey::find_program_address(&[LAST_RESULT_ACCOUNT_SEED], program_id);

    // Verify the provided account matches the expected PDA
    if last_result_account.key != &expected_pda {
        return Err(ProgramError::InvalidSeeds);
    }

    invoke(
        &solana_program::instruction::Instruction {
            program_id: cpi_to_external_call_solana_adder::ID,
            accounts: vec![],
            data: instruction_data.to_owned(),
        },
        &[adder_program.clone()],
    )?;

    let (invoked_program, data) = get_return_data().expect("return data is some after invoke");

    assert_eq!(
        invoked_program, ADDER_PROGRAM_ID,
        "expected return data from {ADDER_PROGRAM_ID}, received from {invoked_program}"
    );

    let Response { result } = Response::try_from_slice(&data)?;

    let last_result_account_data = borsh::to_vec(&LastResultAccount {
        last_result: result,
    })?;

    // Check if LastResult PDA Account needs to be created
    if last_result_account.owner != program_id {
        let rent = Rent::get()?;
        let required_lamports = rent.minimum_balance(last_result_account_data.len());

        invoke_signed(
            &system_instruction::create_account(
                payer.key,
                last_result_account.key,
                required_lamports,
                last_result_account_data.len() as u64,
                program_id,
            ),
            &[
                payer.clone(),
                last_result_account.clone(),
                system_program.clone(),
            ],
            &[&[LAST_RESULT_ACCOUNT_SEED, &[bump]]],
        )?;
    }

    last_result_account
        .try_borrow_mut_data()?
        .copy_from_slice(&last_result_account_data);

    Ok(())
}

Anchor

#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct Args {
    pub a: u64,
    pub b: u64,
}

#[derive(InitSpace)]
#[account]
pub struct LastResultAccount {
    pub last_result: u128,
}

#[derive(Accounts)]
#[instruction(data: Args)]
pub struct Add<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        init_if_needed,
        payer = payer,
        space = 8 + LastResultAccount::INIT_SPACE,
        seeds = [LAST_RESULT_ACCOUNT_SEED],
        bump,
    )]
    pub last_result: Account<'info, LastResultAccount>,
    pub system_program: Program<'info, System>,
    pub adder_program: UncheckedAccount<'info>,
}

#[program]
pub mod cpi {
    use super::*;

    pub fn add(ctx: Context<Add>, args: Args) -> Result<()> {
        if *ctx.accounts.adder_program.key != ADDER_PROGRAM_ID {
            return Err(ProgramError::InvalidAccountData.into());
        }

        let adder_instruction_data = ::borsh::to_vec(&AdderArgs {
            a: args.a,
            b: args.b,
        })
        .expect("infallible serialization");

        invoke(
            &Instruction {
                program_id: ADDER_PROGRAM_ID,
                accounts: vec![],
                data: adder_instruction_data,
            },
            &[ctx.accounts.adder_program.to_account_info()],
        )?;

        let (invoked_program, data) = get_return_data().expect("return data is some after invoke");

        assert_eq!(
            invoked_program, ADDER_PROGRAM_ID,
            "expected return data from {ADDER_PROGRAM_ID}, received from {invoked_program}"
        );

        let Response { result } = Response::try_from_slice(&data)?;

        ctx.accounts.last_result.last_result = result;

        Ok(())
    }
}

Stylus

Stylus contracts use an EVM ABI encoding model that supports calling both state queries and modification functions. Unlike Solana, where all state must be passed explicitly, Stylus contracts can directly call any other contracts, using static calls to read state or regular calls to modify it.

Call contexts are configured via the Call type, giving fine-grained control over gas limits and value transfers. Stylus provides two abstraction layers: high-level typed interfaces generated by sol_interface!, and low-level call, static_call, and RawCall methods for direct byte manipulation when needed.

Stylus contracts revert on reentrant calls by default, blocking an entire class of exploits. You can enable reentrancy with the reentrant feature flag, but this is highly dangerous and should only be done after expert review.
fn add_calldata(a: u64, b: u64) -> Vec<u8> {
    [
        [110u8, 44u8, 115u8, 45u8].as_slice(), // keccak(b"add(uint64,uint64)")[..4],
        abi::encode_params(&(a, b)).as_slice(),
    ]
    .concat()
}

// function add(uint64 a, uint64 b) external view returns (uint128);
// returns a big-endian u128 (16 bytes) padded to 32 bytes
fn parse_add_returndata(returndata: &[u8]) -> Option<u128> {
    if returndata.len() != 32 {
        return None;
    }

    returndata[16..].try_into().map(u128::from_be_bytes).ok()
}

#[storage]
#[entrypoint]
pub struct ExternalCaller {
    /// A negative value indicates no result has been obtained yet
    last_result: StorageI256,
    adder_address: StorageAddress,
}

#[public]
impl ExternalCaller {
    #[constructor]
    pub fn constructor(&mut self, adder_address: Address) {
        assert_ne!(
            adder_address,
            Address::ZERO,
            "adder_address cannot be a zero-address"
        );
        assert!(
            self.vm().code_size(adder_address) > 0,
            "adder_address must be a contract"
        );

        self.last_result.set(I256::MINUS_ONE);
        self.adder_address.set(adder_address);
    }

    pub fn add(&mut self, a: u64, b: u64) -> u128 {
        // low-level static call used to allow unit testing
        // sol_interface! generated interfaces can only be tested in a WASM runtime
        // see: https://github.com/OffchainLabs/stylus-sdk-rs/issues/301
        let returndata = self
            .vm()
            .static_call(
                &calls::context::Call::new(),
                self.get_adder_address(),
                &add_calldata(a, b),
            )
            .expect("valid contract call");

        let result = parse_add_returndata(&returndata).expect("valid return data");

        self.last_result.set(I256::unchecked_from(result));

        result
    }

    pub fn get_adder_address(&self) -> Address {
        self.adder_address.get()
    }

    pub fn get_last_result(&self) -> I256 {
        self.last_result.get()
    }
}

Next Steps

With external calls mastered, you're ready to explore:

Native Token Handling

This chapter maps handling native SOL to ETH in Stylus: payable functions, internal balance accounting, safe withdrawals, and the key behavioral differences between Lamports and Wei.

Solana

Solana's native token SOL is handled through the System Program. Programs transfer Lamports via CPIs, check balances through account fields, and receive SOL by accepting transfers to program-owned accounts. Each account maintains a lamports field that tracks its SOL balance, and rent requirements mean accounts must maintain minimum balances. Programs use PDAs to escrow SOL and manage program-owned funds separately from user accounts.

Native

#[derive(BorshSerialize, BorshDeserialize)]
pub struct WithdrawAllLamports {}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    if WithdrawAllLamports::try_from_slice(instruction_data).is_err() {
        return Err(ProgramError::InvalidInstructionData);
    };

    let [payer, deposit_account, system_program] = accounts else {
        return Err(ProgramError::InvalidAccountData);
    };

    if !payer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if system_program.key != &solana_program::system_program::ID {
        return Err(ProgramError::IncorrectProgramId);
    }

    if deposit_account.owner != &solana_program::system_program::ID {
        return Err(ProgramError::IllegalOwner);
    }

    // Verify the PDA matches seeds
    let (expected_deposit_pda, bump) = Pubkey::find_program_address(
        &[DEPOSIT_PDA_ACCOUNT_SEED, SEED_SEPARATOR, payer.key.as_ref()],
        program_id,
    );

    if deposit_account.key != &expected_deposit_pda {
        return Err(ProgramError::InvalidSeeds);
    }

    let ix =
        system_instruction::transfer(deposit_account.key, payer.key, deposit_account.lamports());

    let signer_seeds: &[&[&[u8]]] = &[&[
        DEPOSIT_PDA_ACCOUNT_SEED,
        SEED_SEPARATOR,
        payer.key.as_ref(),
        &[bump],
    ]];

    invoke_signed(
        &ix,
        &[
            deposit_account.clone(),
            payer.clone(),
            system_program.clone(),
        ],
        signer_seeds,
    )?;

    Ok(())
}

Anchor

#[program]
pub mod native_token_handling {
    use super::*;

    pub fn withdraw_all_lamports(ctx: Context<WithdrawAllLamports>) -> Result<()> {
        system_program::transfer(
            CpiContext::new(
                ctx.accounts.system_program.to_account_info(),
                system_program::Transfer {
                    from: ctx.accounts.deposit_account.to_account_info(),
                    to: ctx.accounts.payer.to_account_info(),
                },
            )
            .with_signer(&[&[
                DEPOSIT_PDA_ACCOUNT_SEED,
                SEED_SEPARATOR,
                ctx.accounts.payer.key.as_ref(),
                &[ctx.bumps.deposit_account],
            ]]),
            ctx.accounts.deposit_account.lamports(),
        )?;

        Ok(())
    }
}

#[derive(Accounts)]
#[instruction()]
pub struct WithdrawAllLamports<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        seeds = [DEPOSIT_PDA_ACCOUNT_SEED, SEED_SEPARATOR, payer.key().as_ref()],
        bump,
    )]
    pub deposit_account: SystemAccount<'info>,
    pub system_program: Program<'info, System>,
}

Stylus

Stylus contracts receive ETH by marking functions as #[payable], check received amounts via MessageAccess::msg_value, and transfer ETH using ValueTransfer::transfer_eth. Unlike Solana's account model, contracts maintain internal mappings for user balances and implement withdrawal patterns.

#[storage]
#[entrypoint]
pub struct NativeTokenHandling {
    deposits: StorageMap<Address, StorageU256>,
}

sol! {
    #[derive(Debug, PartialEq)]
    error ZeroDeposit();
    #[derive(Debug, PartialEq)]
    error BalanceOverflow(address address, uint existing_balance, uint deposit);
    #[derive(Debug, PartialEq)]
    error DepositNotFound(address address);
    #[derive(Debug, PartialEq)]
    error TransferFailed(address to, uint amount, bytes error);
}

#[derive(SolidityError, Debug, PartialEq)]
pub enum ContractError {
    ZeroDeposit(ZeroDeposit),
    BalanceOverflow(BalanceOverflow),
    DepositNotFound(DepositNotFound),
    TransferFailed(TransferFailed),
}

#[public]
impl NativeTokenHandling {
    #[payable]
    pub fn deposit(&mut self) -> Result<(), ContractError> {
        let sender = self.vm().msg_sender();

        let amount = self.vm().msg_value();

        if amount.is_zero() {
            return Err(ZeroDeposit {}.into());
        }

        let existing_balance = self.balance(sender);

        let new_balance = existing_balance
            .checked_add(amount)
            .ok_or(BalanceOverflow {
                address: sender,
                existing_balance,
                deposit: amount,
            })?;

        self.deposits.insert(sender, new_balance);

        Ok(())
    }

    pub fn withdraw_all(&mut self) -> Result<(), ContractError> {
        let sender = self.vm().msg_sender();

        let balance = self.deposits.take(sender);

        if balance.is_zero() {
            return Err(DepositNotFound { address: sender }.into());
        }

        self.vm()
            .transfer_eth(sender, balance)
            .map_err(Bytes::from)
            .map_err(|error| TransferFailed {
                to: sender,
                amount: balance,
                error,
            })?;

        Ok(())
    }

    pub fn balance(&self, address: Address) -> U256 {
        self.deposits.get(address)
    }
}

Next Steps

With native token handling covered, the next chapter explores Fungible Token Handling - migrating SPL Token operations to ERC-20 patterns in Stylus contracts.

Fungible Token Handling

SPL Tokens provide fundamental standardized fungible token functionality for Solana applications. This chapter covers migrating SPL Token operations to ERC-20 patterns in Stylus, including instantiating, minting, transfers, and allowance mechanisms.

To illustrate a range of token operations in a concise example, we will implement a contract that creates a stakeable token with a capped supply.

Solana

Solana separates token logic from user programs: the SPL Token program owns all mint and token accounts, requiring programs to use CPIs for any token operations. Each token needs a mint account (storing decimals, supply, authorities) and separate token accounts per holder. Programs manage PDAs for both their own state and any token accounts they control, never directly manipulating token balances. The mint authority controls token creation, while freeze authorities handle compliance. Token-2022 adds extensions like transfer fees and metadata while maintaining the same architectural model.

Native

#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instruction {
    Initialize,
    Stake { amount: u64 },
    Unstake { amount: u64 },
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let instruction = Instruction::try_from_slice(instruction_data)
        .map_err(|_| ProgramError::InvalidInstructionData)?;

    match instruction {
        Instruction::Initialize => process_initialize(program_id, accounts),
        Instruction::Stake { amount } => process_stake(program_id, accounts, amount),
        Instruction::Unstake { amount } => process_unstake(program_id, accounts, amount),
    }
}

fn process_initialize(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    let [mint_account, mint_supply_to_account, signer_account, token_program, associated_token_program, system_program, rent_sysvar] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !signer_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if *token_program.key != spl_token_2022::id()
        || *associated_token_program.key != spl_associated_token_account::id()
        || *system_program.key != system_program::id()
        || *rent_sysvar.key != rent::sysvar::id()
    {
        return Err(ProgramError::IncorrectProgramId);
    }

    let (mint_pda_key, mint_bump) = Pubkey::find_program_address(&[MINT_PDA_SEED], program_id);

    if mint_pda_key != *mint_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    if !mint_account.data_is_empty()
        || mint_account.lamports() > 0
        || *mint_account.owner == spl_token_2022::id()
    {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    if !mint_supply_to_account.data_is_empty()
        || mint_supply_to_account.lamports() > 0
        || *mint_supply_to_account.owner == spl_token_2022::id()
    {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    // Derive the expected associated token account address
    let expected_ata = spl_associated_token_account::get_associated_token_address_with_program_id(
        signer_account.key,
        &mint_pda_key,
        &spl_token_2022::id(),
    );

    if expected_ata != *mint_supply_to_account.key {
        return Err(ProgramError::InvalidAccountData);
    }

    // Create mint account
    let space_required = Mint::get_packed_len();
    let lamports_required = Rent::get()?.minimum_balance(space_required);

    invoke_signed(
        &system_instruction::create_account(
            signer_account.key,
            mint_account.key,
            lamports_required,
            space_required as u64,
            &spl_token_2022::id(),
        ),
        &[
            signer_account.clone(),
            mint_account.clone(),
            system_program.clone(),
        ],
        &[&[MINT_PDA_SEED, &[mint_bump]]],
    )?;

    // Initialize mint
    invoke_signed(
        &token_instruction::initialize_mint(
            &spl_token_2022::id(),
            mint_account.key,
            mint_account.key,
            Some(mint_account.key),
            DECIMALS,
        )?,
        &[mint_account.clone(), rent_sysvar.clone()],
        &[&[MINT_PDA_SEED, &[mint_bump]]],
    )?;

    // Create associated token account
    invoke_signed(
        &associated_token_instruction::create_associated_token_account(
            signer_account.key,
            signer_account.key,
            mint_account.key,
            &spl_token_2022::id(),
        ),
        &[
            signer_account.clone(),
            mint_supply_to_account.clone(),
            signer_account.clone(),
            mint_account.clone(),
            system_program.clone(),
            token_program.clone(),
            associated_token_program.clone(),
        ],
        &[&[MINT_PDA_SEED, &[mint_bump]]],
    )?;

    // Mint total supply to the associated token account
    invoke_signed(
        &token_instruction::mint_to(
            &spl_token_2022::id(),
            mint_account.key,
            mint_supply_to_account.key,
            mint_account.key,
            &[],
            TOTAL_SUPPLY,
        )?,
        &[
            mint_account.clone(),
            mint_supply_to_account.clone(),
            mint_account.clone(),
        ],
        &[&[MINT_PDA_SEED, &[mint_bump]]],
    )?;

    Ok(())
}

fn process_stake(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let [stake_account, from_account, signer_account, mint_account, token_program, system_program] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !signer_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if *token_program.key != spl_token_2022::id() || *system_program.key != system_program::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Verify stake PDA
    let (stake_pda_key, stake_bump) =
        Pubkey::find_program_address(&[STAKE_PDA_SEED, signer_account.key.as_ref()], program_id);

    if stake_pda_key != *stake_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Create stake account if it doesn't exist
    if stake_account.data_is_empty() || *stake_account.owner != spl_token_2022::id() {
        let space_required = TokenAccount::get_packed_len();
        let lamports_required = Rent::get()?.minimum_balance(space_required);

        invoke_signed(
            &system_instruction::create_account(
                signer_account.key,
                stake_account.key,
                lamports_required,
                space_required as u64,
                &spl_token_2022::id(),
            ),
            &[
                signer_account.clone(),
                stake_account.clone(),
                system_program.clone(),
            ],
            &[&[STAKE_PDA_SEED, signer_account.key.as_ref(), &[stake_bump]]],
        )?;

        // Initialize the stake token account
        invoke_signed(
            &token_instruction::initialize_account3(
                &spl_token_2022::id(),
                stake_account.key,
                mint_account.key,
                stake_account.key,
            )?,
            &[stake_account.clone(), mint_account.clone()],
            &[&[STAKE_PDA_SEED, signer_account.key.as_ref(), &[stake_bump]]],
        )?;
    }

    // Transfer tokens from user's account to stake account
    invoke(
        &token_instruction::transfer_checked(
            &spl_token_2022::id(),
            from_account.key,
            mint_account.key,
            stake_account.key,
            signer_account.key,
            &[],
            amount,
            DECIMALS,
        )?,
        &[
            from_account.clone(),
            mint_account.clone(),
            stake_account.clone(),
            signer_account.clone(),
        ],
    )?;

    Ok(())
}

fn process_unstake(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let [stake_account, unstake_to_account, signer_account, mint_account, token_program, system_program] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !signer_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if *token_program.key != spl_token_2022::id() || *system_program.key != system_program::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Verify stake PDA
    let (stake_pda_key, stake_bump) =
        Pubkey::find_program_address(&[STAKE_PDA_SEED, signer_account.key.as_ref()], program_id);

    if stake_pda_key != *stake_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Transfer tokens from stake account to user's account
    invoke_signed(
        &token_instruction::transfer_checked(
            &spl_token_2022::id(),
            stake_account.key,
            mint_account.key,
            unstake_to_account.key,
            stake_account.key,
            &[],
            amount,
            DECIMALS,
        )?,
        &[
            stake_account.clone(),
            mint_account.clone(),
            unstake_to_account.clone(),
            stake_account.clone(),
        ],
        &[&[STAKE_PDA_SEED, signer_account.key.as_ref(), &[stake_bump]]],
    )?;

    Ok(())
}

Anchor

#[program]
pub mod fungible_tokens {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        mint_to(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                MintTo {
                    mint: ctx.accounts.mint.to_account_info(),
                    to: ctx.accounts.mint_supply_to.to_account_info(),
                    authority: ctx.accounts.mint.to_account_info(),
                },
                &[&[MINT_PDA_SEED, &[ctx.bumps.mint]]],
            ),
            TOTAL_SUPPLY,
        )?;

        Ok(())
    }

    pub fn stake(ctx: Context<Stake>, amount: u64) -> Result<()> {
        transfer_checked(
            CpiContext::new(
                ctx.accounts.token_program.to_account_info(),
                TransferChecked {
                    from: ctx.accounts.from_account.to_account_info(),
                    mint: ctx.accounts.mint.to_account_info(),
                    to: ctx.accounts.stake_account.to_account_info(),
                    authority: ctx.accounts.signer.to_account_info(),
                },
            ),
            amount,
            DECIMALS,
        )?;

        Ok(())
    }

    pub fn unstake(ctx: Context<Unstake>, amount: u64) -> Result<()> {
        transfer_checked(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                TransferChecked {
                    from: ctx.accounts.stake_account.to_account_info(),
                    mint: ctx.accounts.mint.to_account_info(),
                    to: ctx.accounts.unstake_to_account.to_account_info(),
                    authority: ctx.accounts.signer.to_account_info(),
                },
                &[&[STAKE_PDA_SEED, &[ctx.bumps.stake_account]]],
            ),
            amount,
            DECIMALS,
        )?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = signer,
        mint::decimals = DECIMALS,
        mint::authority = mint.key(),
        mint::freeze_authority = mint.key(),
        seeds = [MINT_PDA_SEED],
        bump
    )]
    pub mint: InterfaceAccount<'info, Mint>,
    #[account(
        init,
        payer = signer,
        associated_token::mint = mint,
        associated_token::authority = signer,
        associated_token::token_program = token_program,
    )]
    pub mint_supply_to: InterfaceAccount<'info, TokenAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub token_program: Interface<'info, TokenInterface>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Stake<'info> {
    #[account(
        init_if_needed,
        payer = signer,
        token::mint = mint,
        token::authority = stake_account,
        token::token_program = token_program,
        seeds = [b"stake", signer.key.as_ref()],
        bump
    )]
    pub stake_account: InterfaceAccount<'info, TokenAccount>,
    #[account(mut)]
    pub from_account: InterfaceAccount<'info, TokenAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub mint: InterfaceAccount<'info, Mint>,
    pub token_program: Interface<'info, TokenInterface>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Unstake<'info> {
    #[account(
        mut,
        seeds = [b"stake", signer.key.as_ref()],
        bump
    )]
    pub stake_account: InterfaceAccount<'info, TokenAccount>,
    #[account(mut)]
    pub unstake_to_account: InterfaceAccount<'info, TokenAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub mint: InterfaceAccount<'info, Mint>,
    pub token_program: Interface<'info, TokenInterface>,
    pub system_program: Program<'info, System>,
}

Stylus

Stylus tokens follow the ERC-20 standard: each token is a self-contained contract storing balances in mappings and implementing transfer logic directly. Token operations are direct method calls to the contract. The standard interface - transfer, approve, transferFrom - enables application composability, while contracts extend functionality through inheritance. OpenZeppelin's Stylus implementations provide components for minting caps, contract pausing, and access control. Before implementing custom token functionality, it is best practice to check if an existing standard or their extensions fits the use case.

use openzeppelin_stylus::token::erc20::{Erc20, Error as Erc20Error, IErc20};

sol! {
    #[derive(Debug)]
    error InsufficientStakedBalance(address account, uint256 staked_balance);
}

#[derive(SolidityError, Debug)]
pub enum ContractError {
    InsufficientStakedBalance(InsufficientStakedBalance),
}

#[storage]
#[entrypoint]
pub struct FungibleTokenContract {
    erc20: Erc20,
    staked_balance: StorageMap<Address, StorageU256>,
}

#[public]
#[implements(IErc20<Error = Erc20Error>)]
impl FungibleTokenContract {
    #[constructor]
    pub fn constructor(&mut self, mint_to: Address) -> Result<(), Erc20Error> {
        assert_ne!(mint_to, Address::ZERO, "mint_to cannot be a zero-address");

        self.erc20._mint(mint_to, U256::from(TOTAL_SUPPLY))?;

        Ok(())
    }

    pub fn stake(&mut self, amount: U256) -> Result<(), Erc20Error> {
        let msg_sender = self.vm().msg_sender();

        let staked_balance = self.staked_balance_of(msg_sender);

        // Overflow not possible:
        // `amount` + `staked_balance` <= `total_supply` < `U256::MAX`
        self.staked_balance
            .setter(msg_sender)
            .set(staked_balance + amount);

        // Returns `ERC20InsufficientBalance` if `from_balance` < `amount`
        self.erc20
            ._update(msg_sender, self.vm().contract_address(), amount)
    }

    pub fn unstake(&mut self, amount: U256) -> Result<(), ContractError> {
        let msg_sender = self.vm().msg_sender();

        let staked_balance = self.staked_balance_of(msg_sender);

        if staked_balance < amount {
            return Err(InsufficientStakedBalance {
                account: msg_sender,
                staked_balance,
            }
            .into());
        }

        // Overflow not possible:
        // `amount` <= `staked_balance`
        self.staked_balance
            .setter(msg_sender)
            .set(staked_balance - amount);

        self.erc20
            ._update(self.vm().contract_address(), msg_sender, amount)
            .expect("amount <= staked_balance");

        Ok(())
    }

    pub fn staked_balance_of(&self, account: Address) -> U256 {
        self.staked_balance.get(account)
    }

    pub fn decimals(&self) -> U8 {
        U8::from(DECIMALS)
    }
}

#[public]
impl IErc20 for FungibleTokenContract {
    type Error = Erc20Error;

    fn total_supply(&self) -> U256 {
        self.erc20.total_supply()
    }

    fn balance_of(&self, account: Address) -> U256 {
        self.erc20.balance_of(account)
    }

    fn transfer(&mut self, to: Address, value: U256) -> Result<bool, Self::Error> {
        self.erc20.transfer(to, value)
    }

    fn allowance(&self, owner: Address, spender: Address) -> U256 {
        self.erc20.allowance(owner, spender)
    }

    fn approve(&mut self, spender: Address, value: U256) -> Result<bool, Self::Error> {
        self.erc20.approve(spender, value)
    }

    fn transfer_from(
        &mut self,
        from: Address,
        to: Address,
        value: U256,
    ) -> Result<bool, Self::Error> {
        self.erc20.transfer_from(from, to, value)
    }
}

Allowance system

The allowance mechanism is central to ERC-20: users approve contracts to spend tokens on their behalf, then contracts pull tokens using IERC20::transfer_from. This pull-based model is fundamental to DeFi composability on EVM chains.

In order for Stylus contracts to receive ERC20 tokens from a user, the user must first grant them an allowance to transfer a pre-determined maximum amount of tokens.

sol! {
    #[derive(Debug)]
    error InsufficientStakedBalance(address account, uint256 staked_balance);
}

#[derive(SolidityError, Debug)]
pub enum ContractError {
    InsufficientStakedBalance(InsufficientStakedBalance),
}

#[storage]
#[entrypoint]
pub struct StakeErc20Contract {
    stake_token: StorageAddress,
    staked_balance: StorageMap<Address, StorageU256>,
}

impl StakeErc20Contract {
    fn stake_token(&self) -> Erc20Interface {
        Erc20Interface::new(self.stake_token.get())
    }
}

#[public]
impl StakeErc20Contract {
    #[constructor]
    pub fn constructor(&mut self, stake_token: Address) {
        self.stake_token.set(stake_token);
    }

    pub fn stake(&mut self, amount: U256) -> Result<(), Vec<u8>> {
        let msg_sender = self.vm().msg_sender();

        let staked_balance = self.staked_balance_of(msg_sender);

        // Overflow not possible:
        // `amount` + `staked_balance` <= `total_supply` < `U256::MAX`
        self.staked_balance
            .setter(msg_sender)
            .set(staked_balance + amount);

        // Reverts with `ERC20InsufficientBalance` if `from_balance` < `amount` or
        // `ERC20InsufficientAllowance` if `contract_allowance` < `amount`
        let contract_addr = self.vm().contract_address();
        self.stake_token()
            .transfer_from(self, msg_sender, contract_addr, amount)?;

        Ok(())
    }

    pub fn unstake(&mut self, amount: U256) -> Result<(), ContractError> {
        let msg_sender = self.vm().msg_sender();

        let staked_balance = self.staked_balance_of(msg_sender);

        if staked_balance < amount {
            return Err(InsufficientStakedBalance {
                account: msg_sender,
                staked_balance,
            }
            .into());
        }

        // Overflow not possible:
        // `amount` <= `staked_balance`
        self.staked_balance
            .setter(msg_sender)
            .set(staked_balance - amount);

        self.stake_token()
            .transfer(self, msg_sender, amount)
            .expect("amount <= staked_balance");

        Ok(())
    }

    pub fn staked_balance_of(&self, account: Address) -> U256 {
        self.staked_balance.get(account)
    }
}

Next Steps

With fungible tokens covered, the next chapter explores Non-Fungible Token Handling - migrating from Metaplex NFTs to ERC-721 patterns in Stylus.

Non-Fungible Token Handling

Solana's NFT ecosystem primarily uses the Metaplex Token Metadata standard for non-fungible tokens. This chapter covers migrating Metaplex NFT operations to ERC-721 patterns in Stylus, including minting, transfers, approvals, and metadata management.

To illustrate NFT operations comprehensively, we will implement a contract that creates a complete NFT collection with minting, metadata, and transfer capabilities.

Solana

Solana NFTs use the Metaplex Token Metadata Program built on top of SPL Tokens. Each NFT requires three accounts: a mint account (SPL Token with supply of 1), a metadata account (storing name, symbol, URI), and optionally a master edition account (marking it as an NFT). Programs interact with NFTs through CPIs to both the SPL Token and Metaplex programs. Collections use a collection NFT that individual NFTs reference. Creators and royalties are stored on-chain in the metadata. Token Metadata v2 adds programmable NFTs with rule sets for transfer restrictions and utility uses.

Native

#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub enum Instruction {
    CreateNameCollection,
    MintNameNft { name: String },
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let instruction = Instruction::try_from_slice(instruction_data)
        .map_err(|_| ProgramError::InvalidInstructionData)?;

    match instruction {
        Instruction::CreateNameCollection => process_create_name_collection(program_id, accounts),
        Instruction::MintNameNft { name } => process_mint_name_nft(program_id, accounts, name),
    }
}

fn process_create_name_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    msg!("Create Name Collection");

    let [authority, collection_mint, collection_metadata, collection_master_edition, collection_token, system_program, token_program, associated_token_program, token_metadata_program, rent_sysvar] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !authority.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Verify program IDs
    if *system_program.key != system_program::id()
        || *token_program.key != spl_token_2022::id()
        || *associated_token_program.key != spl_associated_token_account::id()
        || *token_metadata_program.key != mpl_token_metadata::ID
        || *rent_sysvar.key != rent::sysvar::id()
    {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Derive and verify collection mint PDA
    let (collection_mint_key, collection_bump) =
        Pubkey::find_program_address(&[COLLECTION_SEED], program_id);

    if collection_mint_key != *collection_mint.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Check if collection mint already exists
    if !collection_mint.data_is_empty() || *collection_mint.owner == spl_token_2022::id() {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    // Verify collection token account
    let expected_collection_token =
        spl_associated_token_account::get_associated_token_address_with_program_id(
            authority.key,
            &collection_mint_key,
            &spl_token_2022::id(),
        );

    if expected_collection_token != *collection_token.key {
        return Err(ProgramError::InvalidAccountData);
    }

    // Verify metadata account
    let (expected_metadata, _) = Pubkey::find_program_address(
        &[
            b"metadata",
            &mpl_token_metadata::ID.to_bytes(),
            &collection_mint_key.to_bytes(),
        ],
        &mpl_token_metadata::ID,
    );

    if expected_metadata != *collection_metadata.key {
        return Err(ProgramError::InvalidAccountData);
    }

    // Verify master edition account
    let (expected_edition, _) = Pubkey::find_program_address(
        &[
            b"metadata",
            &mpl_token_metadata::ID.to_bytes(),
            &collection_mint_key.to_bytes(),
            b"edition",
        ],
        &mpl_token_metadata::ID,
    );

    if expected_edition != *collection_master_edition.key {
        return Err(ProgramError::InvalidAccountData);
    }

    let signer_seeds = &[COLLECTION_SEED, &[collection_bump]];

    // Create mint account
    let mint_space = Mint::get_packed_len();
    let mint_lamports = Rent::get()?.minimum_balance(mint_space);

    invoke_signed(
        &system_instruction::create_account(
            authority.key,
            collection_mint.key,
            mint_lamports,
            mint_space as u64,
            &spl_token_2022::id(),
        ),
        &[
            authority.clone(),
            collection_mint.clone(),
            system_program.clone(),
        ],
        &[signer_seeds],
    )?;

    msg!("Created Name Collection Mint Account");

    // Initialize mint with 0 decimals for NFT
    invoke_signed(
        &token_instruction::initialize_mint(
            &spl_token_2022::id(),
            collection_mint.key,
            collection_mint.key,
            Some(collection_mint.key),
            0,
        )?,
        &[collection_mint.clone(), rent_sysvar.clone()],
        &[signer_seeds],
    )?;

    msg!("Intitialized Name Collection Mint");

    // Create associated token account
    invoke(
        &associated_token_instruction::create_associated_token_account(
            authority.key,
            authority.key,
            collection_mint.key,
            &spl_token_2022::id(),
        ),
        &[
            authority.clone(),
            collection_token.clone(),
            authority.clone(),
            collection_mint.clone(),
            system_program.clone(),
            token_program.clone(),
            associated_token_program.clone(),
        ],
    )?;

    msg!("Created Name Collection ATA");

    // Mint 1 token to the collection token account
    invoke_signed(
        &token_instruction::mint_to(
            &spl_token_2022::id(),
            collection_mint.key,
            collection_token.key,
            collection_mint.key,
            &[],
            1,
        )?,
        &[
            collection_mint.clone(),
            collection_token.clone(),
            collection_mint.clone(),
        ],
        &[signer_seeds],
    )?;

    msg!("Minted Collection to ATA");

    // Create metadata account
    let creators = vec![Creator {
        address: *collection_mint.key,
        verified: true,
        share: 100,
    }];

    let create_metadata_ix = CreateMetadataAccountV3Builder::new()
        .metadata(*collection_metadata.key)
        .mint(*collection_mint.key)
        .mint_authority(*collection_mint.key)
        .payer(*authority.key)
        .update_authority(*collection_mint.key, true)
        .system_program(*system_program.key)
        .data(DataV2 {
            name: "Mock Name Service".to_string(),
            symbol: "MNS".to_string(),
            uri: String::new(),
            seller_fee_basis_points: 0,
            creators: Some(creators),
            collection: None,
            uses: None,
        })
        .is_mutable(true)
        .collection_details(CollectionDetails::V1 { size: 0 })
        .instruction();

    invoke_signed(
        &create_metadata_ix,
        &[
            collection_metadata.clone(),
            collection_mint.clone(),
            collection_mint.clone(),
            authority.clone(),
            collection_mint.clone(),
            system_program.clone(),
        ],
        &[signer_seeds],
    )?;

    msg!("Created Name Collection Metadata");

    // Create master edition
    let create_edition_ix = CreateMasterEditionV3Builder::new()
        .edition(*collection_master_edition.key)
        .update_authority(*collection_mint.key)
        .mint_authority(*collection_mint.key)
        .mint(*collection_mint.key)
        .payer(*authority.key)
        .metadata(*collection_metadata.key)
        .token_program(*token_program.key)
        .system_program(*system_program.key)
        .max_supply(0)
        .instruction();

    invoke_signed(
        &create_edition_ix,
        &[
            collection_master_edition.clone(),
            collection_mint.clone(),
            collection_mint.clone(),
            collection_mint.clone(),
            authority.clone(),
            collection_metadata.clone(),
            token_program.clone(),
            system_program.clone(),
        ],
        &[signer_seeds],
    )?;

    msg!("Created Name Collection Master Edition");

    Ok(())
}

fn process_mint_name_nft(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    name: String,
) -> ProgramResult {
    let [owner, name_mint, name_token, name_metadata, name_master_edition, collection_mint, collection_metadata, collection_master_edition, system_program, token_program, associated_token_program, token_metadata_program, sysvar_instruction, rent_sysvar] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !owner.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Validate name
    if name.is_empty() || name.len() > MAX_NAME_LENGTH {
        return Err(ProgramError::InvalidArgument);
    }

    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
        return Err(ProgramError::InvalidArgument);
    }

    // Verify program IDs
    if *system_program.key != system_program::id()
        || *token_program.key != spl_token_2022::id()
        || *associated_token_program.key != spl_associated_token_account::id()
        || *token_metadata_program.key != mpl_token_metadata::ID
        || *sysvar_instruction.key != solana_sdk_ids::sysvar::instructions::id()
    {
        return Err(ProgramError::IncorrectProgramId);
    }

    // Derive and verify name mint PDA
    let (name_mint_key, name_bump) =
        Pubkey::find_program_address(&[MINT_SEED, name.as_bytes()], program_id);

    if name_mint_key != *name_mint.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Check if name mint already exists
    if !name_mint.data_is_empty() || *name_mint.owner == spl_token_2022::id() {
        return Err(ProgramError::AccountAlreadyInitialized);
    }

    // Verify collection mint PDA
    let (collection_mint_key, collection_bump) =
        Pubkey::find_program_address(&[COLLECTION_SEED], program_id);

    if collection_mint_key != *collection_mint.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Verify name token account
    let expected_name_token =
        spl_associated_token_account::get_associated_token_address_with_program_id(
            owner.key,
            &name_mint_key,
            &spl_token_2022::id(),
        );

    if expected_name_token != *name_token.key {
        return Err(ProgramError::InvalidAccountData);
    }

    // Verify metadata accounts
    let (expected_name_metadata, _) = Pubkey::find_program_address(
        &[
            b"metadata",
            &mpl_token_metadata::ID.to_bytes(),
            &name_mint_key.to_bytes(),
        ],
        &mpl_token_metadata::ID,
    );

    if expected_name_metadata != *name_metadata.key {
        return Err(ProgramError::InvalidAccountData);
    }

    let (expected_name_edition, _) = Pubkey::find_program_address(
        &[
            b"metadata",
            &mpl_token_metadata::ID.to_bytes(),
            &name_mint_key.to_bytes(),
            b"edition",
        ],
        &mpl_token_metadata::ID,
    );

    if expected_name_edition != *name_master_edition.key {
        return Err(ProgramError::InvalidAccountData);
    }

    let collection_signer_seeds = &[COLLECTION_SEED, &[collection_bump]];

    // Create name mint account
    let mint_space = Mint::get_packed_len();
    let mint_lamports = Rent::get()?.minimum_balance(mint_space);

    invoke_signed(
        &system_instruction::create_account(
            owner.key,
            name_mint.key,
            mint_lamports,
            mint_space as u64,
            &spl_token_2022::id(),
        ),
        &[owner.clone(), name_mint.clone(), system_program.clone()],
        &[&[MINT_SEED, &[name_bump]]],
    )?;

    // Initialize name mint with collection mint as authority
    invoke_signed(
        &token_instruction::initialize_mint(
            &spl_token_2022::id(),
            name_mint.key,
            collection_mint.key,
            Some(collection_mint.key),
            0,
        )?,
        &[name_mint.clone(), rent_sysvar.clone()],
        &[collection_signer_seeds],
    )?;

    // Create associated token account for name NFT
    invoke(
        &associated_token_instruction::create_associated_token_account(
            owner.key,
            owner.key,
            name_mint.key,
            &spl_token_2022::id(),
        ),
        &[
            owner.clone(),
            name_token.clone(),
            owner.clone(),
            name_mint.clone(),
            system_program.clone(),
            token_program.clone(),
            associated_token_program.clone(),
        ],
    )?;

    // Mint 1 token
    invoke_signed(
        &token_instruction::mint_to(
            &spl_token_2022::id(),
            name_mint.key,
            name_token.key,
            collection_mint.key,
            &[],
            1,
        )?,
        &[
            name_mint.clone(),
            name_token.clone(),
            collection_mint.clone(),
        ],
        &[collection_signer_seeds],
    )?;

    // Create metadata for name NFT
    let creators = vec![Creator {
        address: *collection_mint.key,
        verified: true,
        share: 100,
    }];

    let create_metadata_ix = CreateMetadataAccountV3Builder::new()
        .metadata(*name_metadata.key)
        .mint(*name_mint.key)
        .mint_authority(*collection_mint.key)
        .payer(*owner.key)
        .update_authority(*collection_mint.key, true)
        .system_program(*system_program.key)
        .data(DataV2 {
            name: name.clone(),
            symbol: "MSN".to_owned(),
            uri: String::new(),
            seller_fee_basis_points: 0,
            creators: Some(creators),
            collection: Some(Collection {
                verified: false,
                key: *collection_mint.key,
            }),
            uses: None,
        })
        .is_mutable(true)
        .instruction();

    invoke_signed(
        &create_metadata_ix,
        &[
            name_metadata.clone(),
            name_mint.clone(),
            collection_mint.clone(),
            owner.clone(),
            collection_mint.clone(),
            system_program.clone(),
        ],
        &[collection_signer_seeds],
    )?;

    // Create master edition for name NFT
    let create_edition_ix = CreateMasterEditionV3Builder::new()
        .edition(*name_master_edition.key)
        .update_authority(*collection_mint.key)
        .mint_authority(*collection_mint.key)
        .mint(*name_mint.key)
        .payer(*owner.key)
        .metadata(*name_metadata.key)
        .token_program(*token_program.key)
        .system_program(*system_program.key)
        .max_supply(1)
        .instruction();

    invoke_signed(
        &create_edition_ix,
        &[
            name_master_edition.clone(),
            collection_mint.clone(),
            collection_mint.clone(),
            name_mint.clone(),
            owner.clone(),
            name_metadata.clone(),
            token_program.clone(),
            system_program.clone(),
        ],
        &[collection_signer_seeds],
    )?;

    // Verify collection membership
    let verify_collection_ix = VerifyCollectionV1Builder::new()
        .authority(*collection_mint.key)
        .metadata(*name_metadata.key)
        .collection_mint(*collection_mint.key)
        .collection_metadata(Some(*collection_metadata.key))
        .collection_master_edition(Some(*collection_master_edition.key))
        .system_program(*system_program.key)
        .sysvar_instructions(*sysvar_instruction.key)
        .instruction();

    invoke_signed(
        &verify_collection_ix,
        &[
            collection_mint.clone(),
            name_metadata.clone(),
            collection_mint.clone(),
            collection_metadata.clone(),
            collection_master_edition.clone(),
            system_program.clone(),
            sysvar_instruction.clone(),
        ],
        &[collection_signer_seeds],
    )?;

    Ok(())
}

Anchor

#[program]
pub mod non_fungible_tokens {
    use super::*;

    pub fn create_name_collection(ctx: Context<CreateNameCollection>) -> Result<()> {
        // Mint the collection NFT
        let seeds = &[COLLECTION_SEED, &[ctx.bumps.collection_mint]];
        let signer_seeds = &[&seeds[..]];

        mint_to(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                MintTo {
                    mint: ctx.accounts.collection_mint.to_account_info(),
                    to: ctx.accounts.collection_token.to_account_info(),
                    authority: ctx.accounts.collection_mint.to_account_info(),
                },
                signer_seeds,
            ),
            1,
        )?;
        msg!("Name Collection NFT minted!");

        // Create metadata account for the collection
        let creator = vec![Creator {
            address: ctx.accounts.collection_mint.key(),
            verified: true,
            share: 100,
        }];

        CreateMetadataAccountV3Cpi::new(
            &ctx.accounts.token_metadata_program.to_account_info(),
            CreateMetadataAccountV3CpiAccounts {
                metadata: &ctx.accounts.collection_metadata.to_account_info(),
                mint: &ctx.accounts.collection_mint.to_account_info(),
                mint_authority: &ctx.accounts.collection_mint.to_account_info(),
                payer: &ctx.accounts.authority.to_account_info(),
                update_authority: (&ctx.accounts.collection_mint.to_account_info(), true),
                system_program: &ctx.accounts.system_program.to_account_info(),
                rent: None,
            },
            CreateMetadataAccountV3InstructionArgs {
                data: DataV2 {
                    name: "Mock Name Service".to_owned(),
                    symbol: "MNS".to_owned(),
                    uri: String::new(),
                    seller_fee_basis_points: 0,
                    creators: Some(creator),
                    collection: None,
                    uses: None,
                },
                is_mutable: true,
                collection_details: Some(CollectionDetails::V1 { size: 0 }),
            },
        )
        .invoke_signed(signer_seeds)?;

        // Create master edition for collection
        CreateMasterEditionV3Cpi::new(
            &ctx.accounts.token_metadata_program.to_account_info(),
            CreateMasterEditionV3CpiAccounts {
                edition: &ctx.accounts.collection_master_edition.to_account_info(),
                update_authority: &ctx.accounts.collection_mint.to_account_info(),
                mint_authority: &ctx.accounts.collection_mint.to_account_info(),
                mint: &ctx.accounts.collection_mint.to_account_info(),
                payer: &ctx.accounts.authority.to_account_info(),
                metadata: &ctx.accounts.collection_metadata.to_account_info(),
                token_program: &ctx.accounts.token_program.to_account_info(),
                system_program: &ctx.accounts.system_program.to_account_info(),
                rent: None,
            },
            CreateMasterEditionV3InstructionArgs {
                max_supply: Some(0),
            },
        )
        .invoke_signed(signer_seeds)?;

        Ok(())
    }

    pub fn mint_name_nft(ctx: Context<MintNameNFT>, name: String) -> Result<()> {
        require!(
            !name.is_empty() && name.len() <= MAX_NAME_LENGTH,
            ErrorCode::InvalidNameLength
        );
        require!(
            name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
            ErrorCode::InvalidNameCharacters
        );

        let collection_seeds = &[COLLECTION_SEED, &[ctx.bumps.collection_mint]];
        let collection_signer_seeds = &[&collection_seeds[..]];

        // Mint the Name NFT
        mint_to(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                MintTo {
                    mint: ctx.accounts.name_mint.to_account_info(),
                    to: ctx.accounts.name_token.to_account_info(),
                    authority: ctx.accounts.collection_mint.to_account_info(),
                },
                collection_signer_seeds,
            ),
            1,
        )?;

        // Create metadata with the name
        let creator = vec![Creator {
            address: ctx.accounts.collection_mint.key(),
            verified: true,
            share: 100,
        }];

        CreateMetadataAccountV3Cpi::new(
            &ctx.accounts.token_metadata_program.to_account_info(),
            CreateMetadataAccountV3CpiAccounts {
                metadata: &ctx.accounts.name_metadata.to_account_info(),
                mint: &ctx.accounts.name_mint.to_account_info(),
                mint_authority: &ctx.accounts.collection_mint.to_account_info(),
                payer: &ctx.accounts.owner.to_account_info(),
                update_authority: (&ctx.accounts.collection_mint.to_account_info(), true),
                system_program: &ctx.accounts.system_program.to_account_info(),
                rent: None,
            },
            CreateMetadataAccountV3InstructionArgs {
                data: DataV2 {
                    name,
                    symbol: "MSN".to_owned(),
                    uri: String::new(),
                    seller_fee_basis_points: 0,
                    creators: Some(creator),
                    collection: Some(Collection {
                        verified: false,
                        key: ctx.accounts.collection_mint.key(),
                    }),
                    uses: None,
                },
                is_mutable: true,
                collection_details: None,
            },
        )
        .invoke_signed(collection_signer_seeds)?;

        // Create master edition for the name NFT
        CreateMasterEditionV3Cpi::new(
            &ctx.accounts.token_metadata_program.to_account_info(),
            CreateMasterEditionV3CpiAccounts {
                edition: &ctx.accounts.name_master_edition.to_account_info(),
                update_authority: &ctx.accounts.collection_mint.to_account_info(),
                mint_authority: &ctx.accounts.collection_mint.to_account_info(),
                mint: &ctx.accounts.name_mint.to_account_info(),
                payer: &ctx.accounts.owner.to_account_info(),
                metadata: &ctx.accounts.name_metadata.to_account_info(),
                token_program: &ctx.accounts.token_program.to_account_info(),
                system_program: &ctx.accounts.system_program.to_account_info(),
                rent: None,
            },
            CreateMasterEditionV3InstructionArgs {
                max_supply: Some(0),
            },
        )
        .invoke_signed(collection_signer_seeds)?;

        // Verify collection membership
        VerifyCollectionV1Cpi::new(
            &ctx.accounts.token_metadata_program.to_account_info(),
            VerifyCollectionV1CpiAccounts {
                authority: &ctx.accounts.collection_mint.to_account_info(),
                delegate_record: None,
                metadata: &ctx.accounts.name_metadata.to_account_info(),
                collection_mint: &ctx.accounts.collection_mint.to_account_info(),
                collection_metadata: Some(&ctx.accounts.collection_metadata.to_account_info()),
                collection_master_edition: Some(
                    &ctx.accounts.collection_master_edition.to_account_info(),
                ),
                system_program: &ctx.accounts.system_program.to_account_info(),
                sysvar_instructions: &ctx.accounts.sysvar_instruction.to_account_info(),
            },
        )
        .invoke_signed(collection_signer_seeds)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateNameCollection<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(
        init,
        payer = authority,
        mint::decimals = 0,
        mint::authority = collection_mint.key(),
        mint::freeze_authority = collection_mint.key(),
        seeds = [COLLECTION_SEED],
        bump,
    )]
    pub collection_mint: Account<'info, Mint>,

    #[account(mut)]
    /// CHECK: This account will be initialized by the metaplex program
    pub collection_metadata: UncheckedAccount<'info>,

    #[account(mut)]
    /// CHECK: This account will be initialized by the metaplex program
    pub collection_master_edition: UncheckedAccount<'info>,

    #[account(
        init,
        payer = authority,
        associated_token::mint = collection_mint,
        associated_token::authority = authority
    )]
    pub collection_token: Account<'info, TokenAccount>,

    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub token_metadata_program: Program<'info, Metadata>,
}

#[derive(Accounts)]
#[instruction(name: String)]
pub struct MintNameNFT<'info> {
    #[account(mut)]
    pub owner: Signer<'info>,

    #[account(
        init,
        payer = owner,
        mint::decimals = 0,
        mint::authority = collection_mint,
        mint::freeze_authority = collection_mint,
        seeds = [MINT_SEED, name.as_bytes()],
        bump,
    )]
    pub name_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = owner,
        associated_token::mint = name_mint,
        associated_token::authority = owner
    )]
    pub name_token: Account<'info, TokenAccount>,

    #[account(mut)]
    /// CHECK: This account will be initialized by the metaplex program
    pub name_metadata: UncheckedAccount<'info>,

    #[account(mut)]
    /// CHECK: This account will be initialized by the metaplex program
    pub name_master_edition: UncheckedAccount<'info>,

    // Collection accounts for verification
    #[account(
        mut,
        seeds = [COLLECTION_SEED],
        bump,
    )]
    pub collection_mint: Account<'info, Mint>,

    #[account(mut)]
    pub collection_metadata: Account<'info, MetadataAccount>,

    pub collection_master_edition: Account<'info, MasterEditionAccount>,

    // System accounts
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub token_metadata_program: Program<'info, Metadata>,

    #[account(address = solana_sdk_ids::sysvar::instructions::ID)]
    /// CHECK: Sysvar instruction account that is being checked with an address constraint
    pub sysvar_instruction: UncheckedAccount<'info>,
}

Stylus

Stylus NFTs follow the ERC-721 standard: each collection is a single contract managing all tokens through internal mappings. Token ownership, approvals, and metadata are stored directly in contract storage. The standard interface - ownerOf, approve, transferFrom - ensures marketplace compatibility. Contracts extend base functionality through modular patterns. OpenZeppelin's Stylus implementations provide components for enumeration, metadata URIs, and royalties. The single-contract model simplifies collection management compared to Solana's multi-account approach.

sol! {
    #[derive(Debug)]
    error InvalidNameLength();

    #[derive(Debug)]
    error InvalidNameCharacters();

    #[derive(Debug)]
    error NameAlreadyMinted();
}

#[derive(SolidityError, Debug)]
pub enum ContractError {
    InvalidNameLength(InvalidNameLength),
    InvalidNameCharacters(InvalidNameCharacters),
    NameAlreadyMinted(NameAlreadyMinted),
    Erc721(erc721::Error),
}

#[storage]
#[entrypoint]
pub struct NameCollectionContract {
    erc721: Erc721,
    metadata: Erc721Metadata,
    // Map names to token ID
    minted_names: StorageMap<String, StorageU256>,
    // Map token ID to name
    token_names: StorageMap<U256, StorageString>,
    // track supply
    next_token_id: StorageU256,
}

#[public]
#[implements(IErc721<Error = erc721::Error>, IErc721Metadata<Error = erc721::Error>, IErc165)]
impl NameCollectionContract {
    #[constructor]
    pub fn constructor(&mut self) -> Result<(), ContractError> {
        // Initialize the collection metadata
        self.metadata
            .constructor("Mock Name Service".into(), "MNS".into());
        self.next_token_id.set(U256::ONE);
        Ok(())
    }

    pub fn mint_name_nft(&mut self, to: Address, name: String) -> Result<U256, ContractError> {
        // Validate name length
        if name.is_empty() || name.len() > MAX_NAME_LENGTH {
            return Err(ContractError::InvalidNameLength(InvalidNameLength {}));
        }

        // Validate name characters (alphanumeric and underscore only)
        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
            return Err(ContractError::InvalidNameCharacters(
                InvalidNameCharacters {},
            ));
        }

        // Check if name is already minted
        if self.is_name_minted(name.clone()) {
            return Err(NameAlreadyMinted {}.into());
        }

        // Get next token ID
        let token_id = self.next_token_id.get();

        // Mint the NFT
        self.erc721._mint(to, token_id)?;

        // Set the bi-directional name mapping
        self.minted_names.setter(name.clone()).set(token_id);
        self.token_names.setter(token_id).set_str(&name);

        // Increment token ID for next mint
        self.next_token_id.set(token_id + U256::from(1));

        Ok(token_id)
    }

    pub fn get_token_id_by_name(&self, name: String) -> U256 {
        self.minted_names.get(name)
    }

    pub fn get_name_by_token_id(&self, token_id: U256) -> String {
        self.token_names.getter(token_id).get_string()
    }

    pub fn is_name_minted(&self, name: String) -> bool {
        self.minted_names.get(name) > U256::ZERO
    }

    pub fn total_minted(&self) -> U256 {
        self.next_token_id.get() - U256::ONE
    }
}

#[public]
impl IErc721 for NameCollectionContract {
    type Error = erc721::Error;

    fn balance_of(&self, owner: Address) -> Result<U256, Self::Error> {
        self.erc721.balance_of(owner)
    }

    fn owner_of(&self, token_id: U256) -> Result<Address, Self::Error> {
        self.erc721.owner_of(token_id)
    }

    fn safe_transfer_from(
        &mut self,
        from: Address,
        to: Address,
        token_id: U256,
    ) -> Result<(), Self::Error> {
        self.erc721.safe_transfer_from(from, to, token_id)
    }

    fn safe_transfer_from_with_data(
        &mut self,
        from: Address,
        to: Address,
        token_id: U256,
        data: Bytes,
    ) -> Result<(), Self::Error> {
        self.erc721
            .safe_transfer_from_with_data(from, to, token_id, data)
    }

    fn transfer_from(
        &mut self,
        from: Address,
        to: Address,
        token_id: U256,
    ) -> Result<(), Self::Error> {
        self.erc721.transfer_from(from, to, token_id)
    }

    fn approve(&mut self, to: Address, token_id: U256) -> Result<(), Self::Error> {
        self.erc721.approve(to, token_id)
    }

    fn set_approval_for_all(&mut self, to: Address, approved: bool) -> Result<(), Self::Error> {
        self.erc721.set_approval_for_all(to, approved)
    }

    fn get_approved(&self, token_id: U256) -> Result<Address, Self::Error> {
        self.erc721.get_approved(token_id)
    }

    fn is_approved_for_all(&self, owner: Address, operator: Address) -> bool {
        self.erc721.is_approved_for_all(owner, operator)
    }
}

#[public]
impl IErc721Metadata for NameCollectionContract {
    type Error = erc721::Error;

    fn name(&self) -> String {
        self.metadata.name()
    }

    fn symbol(&self) -> String {
        self.metadata.symbol()
    }

    /// unused
    fn token_uri(&self, _token_id: U256) -> Result<String, Self::Error> {
        Ok(String::new())
    }
}

#[public]
impl IErc165 for NameCollectionContract {
    fn supports_interface(&self, interface_id: B32) -> bool {
        self.erc721.supports_interface(interface_id)
            || <Self as IErc721Metadata>::interface_id() == interface_id
    }
}

Next Steps

With non-fungible tokens covered, the next chapter explores Errors and Events - migrating Solana's logging and error patterns to Stylus structured events and custom errors.

Chapter 8: Errors and Events

Proper error reporting and event emission are crucial for robust smart contracts and user experience. This chapter covers migrating from Solana's msg!() logging and ProgramError type to Stylus's structured events and custom error types.

Errors

Solana

The error type used for all Solana programs is solana_program::program_error::ProgramError which is defined as:

pub enum ProgramError {
    /// Allows on-chain programs to implement program-specific error types and see them returned
    /// by the Solana runtime. A program-specific error may be any type that is represented as
    /// or serialized to a u32 integer.
    Custom(u32),
    InvalidArgument,
    InvalidInstructionData,
    InvalidAccountData,
    AccountDataTooSmall,
    InsufficientFunds,
    IncorrectProgramId,
    MissingRequiredSignature,
    AccountAlreadyInitialized,
    UninitializedAccount,
    NotEnoughAccountKeys,
    AccountBorrowFailed,
    MaxSeedLengthExceeded,
    InvalidSeeds,
    BorshIoError(String),
    AccountNotRentExempt,
    UnsupportedSysvar,
    IllegalOwner,
    MaxAccountsDataAllocationsExceeded,
    InvalidRealloc,
    MaxInstructionTraceLengthExceeded,
    BuiltinProgramsMustConsumeComputeUnits,
    InvalidAccountOwner,
    ArithmeticOverflow,
    Immutable,
    IncorrectAuthority,
}

Many of these generic variants can be returned during account and instruction validation. The Custom variant can be used to return program-specific errors such as those arising from business logic. The user simply needs to be able to convert their custom error to a u32 integer.

In native Solana programs, this is done like so:

#[derive(BorshSerialize, BorshDeserialize)]
pub enum Instruction {
    InvalidAmount {},
    Unauthorized {},
}

#[derive(Debug, Clone, Copy)]
// allows casting to u32 for value enums (no associated data)
#[repr(u32)]
pub enum ErrorCode {
    InvalidAmount,
    Unauthorized,
}

impl From<ErrorCode> for ProgramError {
    fn from(value: ErrorCode) -> Self {
        Self::Custom(value as _)
    }
}

pub fn process_instruction(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    if !check_id(program_id) {
        return Err(ProgramError::IncorrectProgramId);
    }

    let Ok(ix) = Instruction::try_from_slice(instruction_data) else {
        return Err(ProgramError::InvalidInstructionData);
    };

    match ix {
        Instruction::InvalidAmount {} => process_invalid_value(accounts),
        Instruction::Unauthorized {} => process_unauthorized(accounts),
    }
}

fn process_invalid_value(_accounts: &[AccountInfo]) -> ProgramResult {
    Err(ErrorCode::InvalidAmount.into())
}

fn process_unauthorized(_accounts: &[AccountInfo]) -> ProgramResult {
    Err(ErrorCode::Unauthorized.into())
}

If we expand the entrypoint! macro, we can see that ultimately the program returns a u64 integer after processing an instruction:

#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
    let (program_id, accounts, instruction_data) = unsafe {
        ::solana_program_entrypoint::deserialize(input)
    };
    match process_instruction(program_id, &accounts, instruction_data) {
        // returns 0 for success
        Ok(()) => ::solana_program_entrypoint::SUCCESS,
        // returns solana_program::program_error::ProgramError converted to u64
        // Every variant apart from Custom(_) is mapped to a value > u32::MAX + 1
        // Custom(0) is converted to 1 << 32, ensuring that every custom error: 0 < error_code <= u32::MAX + 1
        Err(error) => error.into(),
    }
}

Anchor provides the #[error_code] macro to reduce the boilerplate required to setup custom errors. Custom errors can also be specified within constraint rules:

#[program]
pub mod errors_events {
    use super::*;

    pub fn invalid_amount(_ctx: Context<InvalidAmount>) -> Result<()> {
        Err(ErrorCode::InvalidAmount.into())
    }

    pub fn unauthorized(_ctx: Context<Unauthorized>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InvalidAmount<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Unauthorized<'info> {
    #[account(mut, constraint = false @ ErrorCode::Unauthorized)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Invalid amount: amount must be greater than 0")]
    InvalidAmount,
    #[msg("Unauthorized")]
    Unauthorized,
}

The #[error_code] macro expands to:

#[repr(u32)]
pub enum ErrorCode {
    InvalidAmount,
    Unauthorized,
}

impl ErrorCode {
    /// Gets the name of this [#enum_name].
    pub fn name(&self) -> String {
        match self {
            ErrorCode::InvalidAmount => "InvalidAmount".to_string(),
            ErrorCode::Unauthorized => "Unauthorized".to_string(),
        }
    }
}

impl From<ErrorCode> for u32 {
    fn from(e: ErrorCode) -> u32 {
        e as u32 + anchor_lang::error::ERROR_CODE_OFFSET
    }
}

impl From<ErrorCode> for anchor_lang::error::Error {
    fn from(error_code: ErrorCode) -> anchor_lang::error::Error {
        anchor_lang::error::Error::from(anchor_lang::error::AnchorError {
            error_name: error_code.name(),
            error_code_number: error_code.into(),
            error_msg: error_code.to_string(),
            error_origin: None,
            compared_values: None,
        })
    }
}

impl std::fmt::Display for ErrorCode {
    fn fmt(
        &self,
        fmt: &mut std::fmt::Formatter<'_>,
    ) -> std::result::Result<(), std::fmt::Error> {
        match self {
            ErrorCode::InvalidAmount => {
                fmt.write_fmt(
                    format_args!("Invalid amount: amount must be greater than 0"),
                )
            }
            ErrorCode::Unauthorized => fmt.write_fmt(format_args!("Unauthorized")),
        }
    }
}

Note that anchor_lang::error::ERROR_CODE_OFFSET is used to reserve space for Anchor's own custom errors.

Each instruction handler returns Result<T, anchor_lang::error::Error>. If a handler returns Err(anchor_lang::error::Error), it is converted first to a solana_program::error::ProgramError before ultimately being returned as an integer, as shown in the macro expansion below:

#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
    let (program_id, accounts, instruction_data) = unsafe {
        ::solana_program_entrypoint::deserialize(input)
    };
    match entry(program_id, &accounts, instruction_data) {
        Ok(()) => ::solana_program_entrypoint::SUCCESS,
        Err(error) => error.into(),
    }
}

pub fn entry<'info>(
    program_id: &Pubkey,
    accounts: &'info [AccountInfo<'info>],
    data: &[u8],
) -> anchor_lang::solana_program::entrypoint::ProgramResult {
    try_entry(program_id, accounts, data)
        .map_err(|e| {
            e.log();
            e.into()
        })
}

fn try_entry<'info>(
    program_id: &Pubkey,
    accounts: &'info [AccountInfo<'info>],
    data: &[u8],
) -> Result<(), > {
    if *program_id != ID {
        return Err(anchor_lang::error::ErrorCode::DeclaredProgramIdMismatch.into());
    }
    dispatch(program_id, accounts, data)
}

Stylus

In contrast to Solana programs, a Stylus contract entrypoint always returns either zero or one, where zero denotes a successful call and one signifies an error occurred. For a contract function with returns, Result<T, E>, the error type E is converted to a byte array and written to the return data buffer:

#[no_mangle]
pub extern "C" fn user_entrypoint(len: usize) -> usize {
    let host = stylus_sdk::host::VM(stylus_sdk::host::WasmVM {});
    if host.msg_reentrant() {
        return 1;
    }
    host.pay_for_memory_grow(0);
    let input = host.read_args(len);
    // Calls the stylus_sdk::abi::router_entrypoint function returning ArbResult aka Result<Vec<u8>, Vec<u8>>
    let (data, status) = match __stylus_struct_entrypoint(input, host.clone()) {
        Ok(data) => (data, 0),
        Err(data) => (data, 1),
    };
    host.flush_cache(false);
    host.write_result(&data);
    status
}

The SolidityError derive macro can be used to implement From<E> for Vec<u8> for the contract defined error type E:

sol! {
    error InvalidAmount(uint256 expected, uint256 received);
    error Unauthorized(address account);
}

#[derive(SolidityError)]
pub enum ContractError {
    InvalidAmount(InvalidAmount),
    Unauthorized(Unauthorized),
}

Note that there is not also a trait with the name SolidityError like most Rust derive macros, instead it expands to the following:

impl From<InvalidAmount> for ContractError {
    fn from(value: InvalidAmount) -> Self {
        ContractError::InvalidAmount(value)
    }
}

impl From<Unauthorized> for ContractError {
    fn from(value: Unauthorized) -> Self {
        ContractError::Unauthorized(value)
    }
}

impl From<ContractError> for alloc::vec::Vec<u8> {
    fn from(err: ContractError) -> Self {
        match err {
            ContractError::InvalidAmount(e) => stylus_sdk::call::MethodError::encode(e),
            ContractError::Unauthorized(e) => stylus_sdk::call::MethodError::encode(e),
        }
    }
}

The derive macro expects an enum consisting on one or more unit variants containing a single type implementing the stylus_sdk::call::MethodError trait. The stylus_sdk::call::MethodError has a blanket implementation for any type which also implements alloy_sol_types::SolError. The sol! macro is the easiest way to define types that implement SolError.

The above mechanisms can be combined to allow Stylus contracts to return structured custom errors:

#[storage]
#[entrypoint]
pub struct ErrorsEvents {}

sol! {
    error InvalidAmount(uint256 expected, uint256 received);
    error Unauthorized(address account);
}

#[derive(SolidityError)]
pub enum ContractError {
    InvalidAmount(InvalidAmount),
    Unauthorized(Unauthorized),
}

#[public]
impl ErrorsEvents {
    pub fn invalid_amount(&mut self, expected: U256, received: U256) -> Result<(), ContractError> {
        Err(InvalidAmount { expected, received }.into())
    }

    pub fn unauthorized(&mut self) -> Result<(), ContractError> {
        Err(Unauthorized {
            account: self.vm().msg_sender(),
        }
        .into())
    }
}

Logging and events

Solana

Logging in Solana is in the form of lines of free text. Due to the lack of standardized ABI for function selection and all errors being reduced to integers, Solana program logs are an important part of instruction execution auditing and tracking. Additionally, they are frequently used for debugging programs during the development process.

The following excerpt from the spl-token-2022 illustrates the convention of logging the name of the instruction being executed:

 pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
        if let Ok(instruction_type) = decode_instruction_type(input) {
            match instruction_type {
                PodTokenInstruction::InitializeMint => {
                    msg!("Instruction: InitializeMint");
                    let (data, freeze_authority) =
                        decode_instruction_data_with_coption_pubkey::<InitializeMintData>(input)?;
                    Self::process_initialize_mint(
                        accounts,
                        data.decimals,
                        &data.mint_authority,
                        freeze_authority,
                    )
                }
                PodTokenInstruction::InitializeMint2 => {
                    msg!("Instruction: InitializeMint2");
                    let (data, freeze_authority) =
                        decode_instruction_data_with_coption_pubkey::<InitializeMintData>(input)?;
                    Self::process_initialize_mint2(
                        accounts,
                        data.decimals,
                        &data.mint_authority,
                        freeze_authority,
                    )
                }
                PodTokenInstruction::InitializeAccount => {
                    msg!("Instruction: InitializeAccount");
                    Self::process_initialize_account(accounts)
                }
                // ...
                PodTokenInstruction::PausableExtension => {
                    msg!("Instruction: PausableExtension");
                    pausable::processor::process_instruction(program_id, accounts, &input[1..])
                }
            }
        } else if let Ok(instruction) = TokenMetadataInstruction::unpack(input) {
            token_metadata::processor::process_instruction(program_id, accounts, instruction)
        } else if let Ok(instruction) = TokenGroupInstruction::unpack(input) {
            token_group::processor::process_instruction(program_id, accounts, instruction)
        } else {
            Err(TokenError::InvalidInstruction.into())
        }
    }
}

Another common use is to provide additional context before returning errors, as can be seen in the metaplex-token-metadata program:

pub(crate) fn validate_mint(
    mint: &AccountInfo,
    metadata: &AccountInfo,
    token_standard: TokenStandard,
) -> Result<Mint, ProgramError> {
let mint_data = &mint.data.borrow();
    let mint = StateWithExtensions::<Mint>::unpack(mint_data)?;

    if !mint.base.is_initialized() {
        return Err(MetadataError::Uninitialized.into());
    }

    if matches!(
        token_standard,
        TokenStandard::NonFungible | TokenStandard::ProgrammableNonFungible
    ) {
        // validates the mint extensions
        mint.get_extension_types()?
            .iter()
            .try_for_each(|extension_type| {
                if !NON_FUNGIBLE_MINT_EXTENSIONS.contains(extension_type) {
                    msg!("Invalid mint extension: {:?}", extension_type);
                    return Err(MetadataError::InvalidMintExtensionType);
                }
                Ok(())
            })?;
    }

    // For all token standards:
    //
    // 1) if the mint close authority extension is enabled, it must
    //    be set to be the metadata account; and
    if let Ok(extension) = mint.get_extension::<MintCloseAuthority>() {
        let close_authority: Option<Pubkey> = extension.close_authority.into();
        if close_authority.is_none() || close_authority != Some(*metadata.key) {
            return Err(MetadataError::InvalidMintCloseAuthority.into());
        }
    }

    // 2) if the metadata pointer extension is enabled, it must be set
    //    to the metadata account address
    if let Ok(extension) = mint.get_extension::<MetadataPointer>() {
        let authority: Option<Pubkey> = extension.authority.into();
        let metadata_address: Option<Pubkey> = extension.metadata_address.into();

        if authority.is_some() {
            msg!("Metadata pointer extension: authority must be None");
            return Err(MetadataError::InvalidMetadataPointer.into());
        }

        if metadata_address != Some(*metadata.key) {
            msg!("Metadata pointer extension: metadata address mismatch");
            return Err(MetadataError::InvalidMetadataPointer.into());
        }
    }

    Ok(mint.base)
}

In addition to the msg! macro providing string logging with formatting, the solana::log module provides a number of other options:

fn process_log(accounts: &[AccountInfo]) -> ProgramResult {
    log::sol_log("just a regular string");
    log::sol_log_64(1, 2, 3, 4, 5);
    log::sol_log_compute_units();
    log::sol_log_data(&[b"some", b"serialized", b"structures", b"as base64"]);
    log::sol_log_params(accounts, &[]);
    log::sol_log_slice(b"some bytes as hex");
    Ok(())
}

The program log from executing the above instruction handler is:

# sol_log:
Program log: just a regular string

# sol_log_u64:
Program log: 0x1, 0x2, 0x3, 0x4, 0x5

# sol_log_compute_units:
Program consumption: 1399140 units remaining

# sol_log_data:
Program data: c29tZQ== c2VyaWFsaXplZA== c3RydWN0dXJlcw== YXMgYmFzZTY0

# sol_log_params:
Program log: AccountInfo
Program log: 0x0, 0x0, 0x0, 0x0, 0x0
Program log: - Is signer
Program log: 0x0, 0x0, 0x0, 0x0, 0x1
Program log: - Key
Program log: 11157t3sqMV725NVRLrVQbAu98Jjfk1uCKehJnXXQs
Program log: - Lamports
Program log: 0x0, 0x0, 0x0, 0x0, 0x5f5e100
Program log: - Account data length
Program log: 0x0, 0x0, 0x0, 0x0, 0x0
Program log: - Owner
Program log: 11111111111111111111111111111111
Program log: AccountInfo
Program log: 0x0, 0x0, 0x0, 0x0, 0x1
Program log: - Is signer
Program log: 0x0, 0x0, 0x0, 0x0, 0x0
Program log: - Key
Program log: 11111111111111111111111111111111
Program log: - Lamports
Program log: 0x0, 0x0, 0x0, 0x0, 0xf14a0
Program log: - Account data length
Program log: 0x0, 0x0, 0x0, 0x0, 0xe
Program log: - Owner
Program log: NativeLoader1111111111111111111111111111111
Program log: Instruction data
Program log: 0x0, 0x0, 0x0, 0x0, 0x69
Program log: 0x0, 0x0, 0x0, 0x1, 0x6e
Program log: 0x0, 0x0, 0x0, 0x2, 0x73
Program log: 0x0, 0x0, 0x0, 0x3, 0x74
Program log: 0x0, 0x0, 0x0, 0x4, 0x72
Program log: 0x0, 0x0, 0x0, 0x5, 0x75
Program log: 0x0, 0x0, 0x0, 0x6, 0x63
Program log: 0x0, 0x0, 0x0, 0x7, 0x74
Program log: 0x0, 0x0, 0x0, 0x8, 0x69
Program log: 0x0, 0x0, 0x0, 0x9, 0x6f
Program log: 0x0, 0x0, 0x0, 0xa, 0x6e
Program log: 0x0, 0x0, 0x0, 0xb, 0x20
Program log: 0x0, 0x0, 0x0, 0xc, 0x64
Program log: 0x0, 0x0, 0x0, 0xd, 0x61
Program log: 0x0, 0x0, 0x0, 0xe, 0x74
Program log: 0x0, 0x0, 0x0, 0xf, 0x61

# sol_log_slice:
Program log: 0x0, 0x0, 0x0, 0x0, 0x73
Program log: 0x0, 0x0, 0x0, 0x1, 0x6f
Program log: 0x0, 0x0, 0x0, 0x2, 0x6d
Program log: 0x0, 0x0, 0x0, 0x3, 0x65
Program log: 0x0, 0x0, 0x0, 0x4, 0x20
Program log: 0x0, 0x0, 0x0, 0x5, 0x62
Program log: 0x0, 0x0, 0x0, 0x6, 0x79
Program log: 0x0, 0x0, 0x0, 0x7, 0x74
Program log: 0x0, 0x0, 0x0, 0x8, 0x65
Program log: 0x0, 0x0, 0x0, 0x9, 0x73
Program log: 0x0, 0x0, 0x0, 0xa, 0x20
Program log: 0x0, 0x0, 0x0, 0xb, 0x61
Program log: 0x0, 0x0, 0x0, 0xc, 0x73
Program log: 0x0, 0x0, 0x0, 0xd, 0x20
Program log: 0x0, 0x0, 0x0, 0xe, 0x68
Program log: 0x0, 0x0, 0x0, 0xf, 0x65
Program log: 0x0, 0x0, 0x0, 0x10, 0x78

In addition to the logging facilities provided by solana_program::log, Anchor provides macros to reduce the boilerplate in emitting structured events via the underlying sol_log_data function:

#[event]
pub struct TaggedEvent {
    you_are_it: Pubkey,
}

#[program]
pub mod errors_events {
    use super::*;

    // ...

    pub fn emit_event(ctx: Context<EmitEvent>) -> Result<()> {
        emit!(TaggedEvent {
            you_are_it: *ctx.accounts.signer.key
        });

        Ok(())
    }
}

#[derive(Accounts)]
pub struct EmitEvent<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Executing the EmitEvent instruction results in the following program log:

Program JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG invoke [1]
Program log: Instruction: EmitEvent
Program data: hwVrfRWeHl0AAAABkHB7w+8lvcmO11y3DWHIsQbcJI2O9h4dHbHKQA==
Program JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG consumed 1038 of 1400000 compute units
Program JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG success

Note how Anchor automatically inserts the Instruction: EmitEvent log message.

Stylus

For Stylus contracts, emitting structured events is considered best practice whenever contract state changes. Similar to errors, events are defined using the sol! macro and then emitted using the log function:

sol! {
    event ItChanged(address previous_it, address current_it);
}

#[storage]
#[entrypoint]
pub struct ErrorsEvents {
    it: StorageAddress,
}

#[public]
impl ErrorsEvents {
    /// Tags the caller as "it", emitting an event for the state change
    pub fn tag(&mut self) {
        let msg_sender = self.vm().msg_sender();

        let previous_it = self.it.get();

        self.it.set(msg_sender);

        log(
            self.vm(),
            ItChanged {
                previous_it,
                current_it: msg_sender,
            },
        );
    }
}

Next Steps

With error handling and events covered, you've completed the core migration concepts.

Continue to Case Study - Migrating Bonafida's Token Vesting to Stylus to see these concepts applied in a complete program migration.

Case Study - Migrating Bonafida's Token Vesting to Stylus

In this chapter, we will walk through the complete migration of an audited token vesting program, built by Bonafida with native Solana, to Arbitrum Stylus. This case study demonstrates the practical application of the concepts we have covered in following chapters:

The program allows any account to setup a token escrow where amounts of a the token will be released to a single destination account according to a pre-defined schedule.

Once the token escrow is established, it cannot be cancelled. Additionally, any account is able to trigger token unlocks.

Migration Strategy

We will migrate the program to Stylus phases:

  1. Program Structure: Convert necessary instructions to #[public] functions.
  2. State Storage: Assess the data structures stored in accounts and the use of PDAs, then convert to idiomatic Stylus state management.
  3. Business Logic: Once state and token operations are setup, port the platform-agnostic business logic from instruction handlers to the equivalent functions.
  4. View Functions: Unlike Solana, view functions need to be added to allow users and clients to easily read the contract storage.
  5. Events: It is best practice to emit an event when the contract state changes.
  6. Testing: Ensure feature parity with automated testing.

Phase 1: Program Structure

The token vesting program defines the following instructions:

pub enum VestingInstruction {
    /// Initializes an empty program account for the token_vesting program
    ///
    /// Accounts expected by this instruction:
    ///
    ///   * Single owner
    ///   0. `[]` The system program account
    ///   1. `[]` The sysvar Rent account
    ///   1. `[signer]` The fee payer account
    ///   1. `[]` The vesting account
    Init {
        // The seed used to derive the vesting accounts address
        seeds: [u8; 32],
        // The number of release schedules for this contract to hold
        number_of_schedules: u32,
    },

    /// Creates a new vesting schedule contract
    ///
    /// Accounts expected by this instruction:
    ///
    ///   * Single owner
    ///   0. `[]` The spl-token program account
    ///   1. `[writable]` The vesting account
    ///   2. `[writable]` The vesting spl-token account
    ///   3. `[signer]` The source spl-token account owner
    ///   4. `[writable]` The source spl-token account
    Create {
        seeds: [u8; 32],
        mint_address: Pubkey,
        destination_token_address: Pubkey,
        schedules: Vec<Schedule>,
    },

    /// Unlocks a simple vesting contract (SVC) - can only be invoked by the program itself
    /// Accounts expected by this instruction:
    ///
    ///   * Single owner
    ///   0. `[]` The spl-token program account
    ///   1. `[]` The clock sysvar account
    ///   1. `[writable]` The vesting account
    ///   2. `[writable]` The vesting spl-token account
    ///   3. `[writable]` The destination spl-token account
    Unlock { seeds: [u8; 32] },

    /// Change the destination account of a given simple vesting contract (SVC)
    /// - can only be invoked by the present destination address of the contract.
    ///
    /// Accounts expected by this instruction:
    ///
    ///   * Single owner
    ///   0. `[]` The vesting account
    ///   1. `[]` The current destination token account
    ///   2. `[signer]` The destination spl-token account owner
    ///   3. `[]` The new destination spl-token account
    ChangeDestination { seeds: [u8; 32] },
}

We can see that there are three core functions that users of the program can perform:

  • Create: sets up the token escrow and specifies the release schedule.
  • Unlock: check the schedule and send any newly unlocked funds to the associated destination.
  • ChangeDestination: the owner of the destination account can elect to change the destination. Note: this also potentially changes the owner.

The Init instruction is specific to Solana state management as Stylus contract manage their own state which can grow as required. The seeds parameter for each instruction is used to create a unique identifier for the vesting schedule in the form of a PDA assigned to the vesting schedule state account.

The instructions can be converted to Stylus functions as follows:

#[derive(SolidityError, Debug)]
pub enum ContractError {
    // TODO: declare error variants
}

#[storage]
#[entrypoint]
pub struct TokenVestingContract {
    // TODO: declare storage schema
}

#[public]
impl TokenVestingContract {
    /// Create a vesting schedule for the specified `token` and initial `destination`, returning the schedule identifier.
    ///
    /// # Errors
    /// - TBD
    pub fn create(
        &mut self,
        token: Address,
        owner: Address,
        destination: Address,
        schedule: Vec<(U64, U256)>,
    ) -> Result<U256, ContractError> {
        todo!()
    }

    /// Unlock any vested tokens associated with the `schedule_id`.
    ///
    /// # Errors
    /// - TBD
    pub fn unlock(&mut self, schedule_id: U256) -> Result<(), ContractError> {
        todo!()
    }

    /// Change the `destination` associated with the `schedule_id`, this can only be called by the associated `owner`.
    ///
    /// # Errors
    /// - TBD
    pub fn change_destination(
        &mut self,
        schedule_id: U256,
        destination: Address,
    ) -> Result<(), ContractError> {
        todo!()
    }

    /// Change the `owner` associated with the `schedule_id`, this can only be called by the current `owner`.
    ///
    /// # Errors
    /// - TBD
    pub fn change_owner(&mut self, schedule_id: U256, owner: Address) -> Result<(), ContractError> {
        todo!()
    }
}

Phase 2: State Storage

Aside from the escrowed token balance which is stored in the associated token account, the vesting schedule state is represented in the following form:

pub struct VestingSchedule {
    pub release_time: u64,
    pub amount: u64,
}

pub struct VestingScheduleHeader {
    pub destination_address: Pubkey,
    pub mint_address: Pubkey,
    pub is_initialized: bool,
}

The PDA derived from the seeds is associated with a data account arranged in the following packed format where N is set in the Init instruction:

[Header: 65 bytes] [Schedule 0: 16 bytes] [Schedule 1: 16 bytes] ... [Schedule N: 16 bytes]

Header (65 bytes):
[0..32] destination_address | [32..64] mint_address | [64] is_initialized

Schedule (16 bytes each):
[0..8] release_time (u64 LE) | [8..16] amount (u64 LE)

When porting this state management to Stylus, it is idiomatic to store each element in a StorageMap using the schedule identifier as the key:

#[storage]
pub struct Schedule {
    /// Timestamp after which tokens are unlocked
    timestamp: StorageU64,
    /// Amount of tokens unlocked (set to zero afterwards)
    amount: StorageU256,
}

#[storage]
#[entrypoint]
pub struct TokenVestingContract {
    /// Incremented to determine the schedule identifier
    schedule_count: StorageU256,
    /// Token vested by the schedule
    token: StorageMap<U256, StorageAddress>,
    /// Owner and benefactor of the schedule
    owner: StorageMap<U256, StorageAddress>,
    /// Destination address for unlocked tokens
    destination: StorageMap<U256, StorageAddress>,
    /// Scheduled token unlocks
    schedule: StorageMap<U256, StorageVec<Schedule>>,
}

Phase 3: Business Logic

Create token vesting schedule

The Create instruction handler from the native Solana program is as follows:

pub fn process_create(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    seeds: [u8; 32],
    mint_address: &Pubkey,
    destination_token_address: &Pubkey,
    schedules: Vec<Schedule>,
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();

    let spl_token_account = next_account_info(accounts_iter)?;
    let vesting_account = next_account_info(accounts_iter)?;
    let vesting_token_account = next_account_info(accounts_iter)?;
    let source_token_account_owner = next_account_info(accounts_iter)?;
    let source_token_account = next_account_info(accounts_iter)?;

    let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?;
    if vesting_account_key != *vesting_account.key {
        msg!("Provided vesting account is invalid");
        return Err(ProgramError::InvalidArgument);
    }

    if !source_token_account_owner.is_signer {
        msg!("Source token account owner should be a signer.");
        return Err(ProgramError::InvalidArgument);
    }

    if *vesting_account.owner != *program_id {
        msg!("Program should own vesting account");
        return Err(ProgramError::InvalidArgument);
    }

    // Verifying that no SVC was already created with this seed
    let is_initialized =
        vesting_account.try_borrow_data()?[VestingScheduleHeader::LEN - 1] == 1;

    if is_initialized {
        msg!("Cannot overwrite an existing vesting contract.");
        return Err(ProgramError::InvalidArgument);
    }

    let vesting_token_account_data = Account::unpack(&vesting_token_account.data.borrow())?;

    if vesting_token_account_data.owner != vesting_account_key {
        msg!("The vesting token account should be owned by the vesting account.");
        return Err(ProgramError::InvalidArgument);
    }

    if vesting_token_account_data.delegate.is_some() {
        msg!("The vesting token account should not have a delegate authority");
        return Err(ProgramError::InvalidAccountData);
    }

    if vesting_token_account_data.close_authority.is_some() {
        msg!("The vesting token account should not have a close authority");
        return Err(ProgramError::InvalidAccountData);
    }

    let state_header = VestingScheduleHeader {
        destination_address: *destination_token_address,
        mint_address: *mint_address,
        is_initialized: true,
    };

    let mut data = vesting_account.data.borrow_mut();
    if data.len() != VestingScheduleHeader::LEN + schedules.len() * VestingSchedule::LEN {
        return Err(ProgramError::InvalidAccountData)
    }
    state_header.pack_into_slice(&mut data);

    let mut offset = VestingScheduleHeader::LEN;
    let mut total_amount: u64 = 0;

    for s in schedules.iter() {
        let state_schedule = VestingSchedule {
            release_time: s.release_time,
            amount: s.amount,
        };
        state_schedule.pack_into_slice(&mut data[offset..]);
        let delta = total_amount.checked_add(s.amount);
        match delta {
            Some(n) => total_amount = n,
            None => return Err(ProgramError::InvalidInstructionData), // Total amount overflows u64
        }
        offset += SCHEDULE_SIZE;
    }
    
    if Account::unpack(&source_token_account.data.borrow())?.amount < total_amount {
        msg!("The source token account has insufficient funds.");
        return Err(ProgramError::InsufficientFunds)
    };

    let transfer_tokens_to_vesting_account = transfer(
        spl_token_account.key,
        source_token_account.key,
        vesting_token_account.key,
        source_token_account_owner.key,
        &[],
        total_amount,
    )?;

    invoke(
        &transfer_tokens_to_vesting_account,
        &[
            source_token_account.clone(),
            vesting_token_account.clone(),
            spl_token_account.clone(),
            source_token_account_owner.clone(),
        ],
    )?;
    Ok(())
}

This can be boiled down the following steps:

  1. Validate inputs such as token and destination accounts.
  2. Compute total amount to be held in escrow, return an error if the schedule is empty.
  3. Write schedule state to storage.
  4. Transfer the computed total token amount to the escrow account, reverting on transfer failure.
sol! {
    #[derive(Debug)]
    error InvalidToken();
    #[derive(Debug)]
    error InvalidDestination();
    #[derive(Debug)]
    error InvalidSchedule();
    #[derive(Debug)]
    error TokenDepositTransferFailed();
}

#[derive(SolidityError, Debug)]
pub enum ContractError {
    InvalidToken(InvalidToken),
    InvalidDestination(InvalidDestination),
    InvalidSchedule(InvalidSchedule),
    TokenDepositFailed(TokenDepositTransferFailed),
}

#[public]
impl TokenVestingContract {
    /// Create a vesting schedule for the specified `token` and initial `destination`, returning the schedule identifier.
    /// Attempts to transfer the total amount of tokens scheduled from the sender to this contract.
    ///
    /// Note: setting a zero address for `owner` means the `destination` is immutable.
    ///
    /// # Errors
    /// - InvalidToken: if the provided token address is zero
    /// - InvalidDestination: if the provided destination address is zero
    /// - InvalidSchedule: if the provided schedule is empty, contains a zero amount, is not ordered chronologically or the total amount overflows 256 bits.
    /// - TokenDepositTransferFailed: if there is an error transferring the total vesting amount from the sender to the contract
    pub fn create(
        &mut self,
        token: Address,
        owner: Address,
        destination: Address,
        schedule: Vec<(u64, U256)>,
    ) -> Result<U256, ContractError> {
        if token == Address::ZERO {
            return Err(InvalidToken {}.into());
        }

        if destination == Address::ZERO {
            return Err(InvalidDestination {}.into());
        }

        if schedule.is_empty() {
            return Err(InvalidSchedule {}.into());
        }

        let schedule_id = self.schedule_count.get() + U256::ONE;

        let mut schedule_store = self.schedule.setter(schedule_id);
        let mut total_vested_amount = U256::ZERO;
        let mut last_timestamp = 0u64;
        let mut timestamps = Vec::with_capacity(schedule.len());
        let mut amounts = Vec::with_capacity(schedule.len());
        for (timestamp, amount) in schedule {
            if amount.is_zero() || timestamp < last_timestamp {
                return Err(InvalidSchedule {}.into());
            }

            last_timestamp = timestamp;
            total_vested_amount = total_vested_amount
                .checked_add(amount)
                .ok_or(InvalidSchedule {})?;

            timestamps.push(timestamp);
            amounts.push(amount);

            let mut schedule_item = schedule_store.grow();
            schedule_item.timestamp.set(U64::from(timestamp));
            schedule_item.amount.set(amount);
        }

        self.schedule_count.set(schedule_id);
        self.token.insert(schedule_id, token);
        self.owner.insert(schedule_id, owner);
        self.destination.insert(schedule_id, destination);

        let contract_addr = self.vm().contract_address();
        let sender = self.vm().msg_sender();
        Erc20Interface::new(token)
            .transfer_from(self, sender, contract_addr, total_vested_amount)
            .map_err(|_| TokenDepositTransferFailed {})?;

        Ok(schedule_id)
    }


    // ...
}

Unlock tokens

The Unlock instruction handler is implemented as follows:

pub fn process_unlock(
    program_id: &Pubkey,
    _accounts: &[AccountInfo],
    seeds: [u8; 32],
) -> ProgramResult {
    let accounts_iter = &mut _accounts.iter();

    let spl_token_account = next_account_info(accounts_iter)?;
    let clock_sysvar_account = next_account_info(accounts_iter)?;
    let vesting_account = next_account_info(accounts_iter)?;
    let vesting_token_account = next_account_info(accounts_iter)?;
    let destination_token_account = next_account_info(accounts_iter)?;

    let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?;
    if vesting_account_key != *vesting_account.key {
        msg!("Invalid vesting account key");
        return Err(ProgramError::InvalidArgument);
    }

    if spl_token_account.key != &spl_token::id() {
        msg!("The provided spl token program account is invalid");
        return Err(ProgramError::InvalidArgument)
    }

    let packed_state = &vesting_account.data;
    let header_state =
        VestingScheduleHeader::unpack(&packed_state.borrow()[..VestingScheduleHeader::LEN])?;

    if header_state.destination_address != *destination_token_account.key {
        msg!("Contract destination account does not matched provided account");
        return Err(ProgramError::InvalidArgument);
    }

    let vesting_token_account_data = Account::unpack(&vesting_token_account.data.borrow())?;

    if vesting_token_account_data.owner != vesting_account_key {
        msg!("The vesting token account should be owned by the vesting account.");
        return Err(ProgramError::InvalidArgument);
    }

    // Unlock the schedules that have reached maturity
    let clock = Clock::from_account_info(&clock_sysvar_account)?;
    let mut total_amount_to_transfer = 0;
    let mut schedules = unpack_schedules(&packed_state.borrow()[VestingScheduleHeader::LEN..])?;

    for s in schedules.iter_mut() {
        if clock.unix_timestamp as u64 >= s.release_time {
            total_amount_to_transfer += s.amount;
            s.amount = 0;
        }
    }
    if total_amount_to_transfer == 0 {
        msg!("Vesting contract has not yet reached release time");
        return Err(ProgramError::InvalidArgument);
    }

    let transfer_tokens_from_vesting_account = transfer(
        &spl_token_account.key,
        &vesting_token_account.key,
        destination_token_account.key,
        &vesting_account_key,
        &[],
        total_amount_to_transfer,
    )?;

    invoke_signed(
        &transfer_tokens_from_vesting_account,
        &[
            spl_token_account.clone(),
            vesting_token_account.clone(),
            destination_token_account.clone(),
            vesting_account.clone(),
        ],
        &[&[&seeds]],
    )?;

    // Reset released amounts to 0. This makes the simple unlock safe with complex scheduling contracts
    pack_schedules_into_slice(
        schedules,
        &mut packed_state.borrow_mut()[VestingScheduleHeader::LEN..],
    );

    Ok(())
}

Looking past the Solana-specific account validation and deserialization logic, the handler needs to do the following:

  1. Check that the specified schedule exists.
  2. Iterate over the schedule unlocks, summing the unlocked token amount and zeroing newly unlocked tokens.
  3. Check that a non-zero amount of tokens needs to be transferred to the destination.
  4. Transfer the unlocked amount from the escrow account to the current destination account.

Additionally, the handler needs to take care that a user is not locked out of claiming their tokens due to gas exhaustion when looping over the schedule. Pagination parameters should be added to allow the caller to limit the iterations.

Invariant: The escrow account MUST have enough tokens to complete the transfer.

Implemented in Stylus, it can look like this:

sol! {
    // ...
    #[derive(Debug)]
    error ScheduleNotFound();
    #[derive(Debug)]
    error NoUnlocksAvailable();
}

#[derive(SolidityError, Debug)]
pub enum ContractError {
    // ...
    ScheduleNotFound(ScheduleNotFound),
    NoUnlocksAvailable(NoUnlocksAvailable),
}

#[public]
impl TokenVestingContract {
    // ...

    /// Unlock any vested tokens in tranches `start_idx` up to and including `end_idx` associated with the `schedule_id` and transfers them to the set `destination`
    ///
    /// # Errors
    /// - ScheduleNotFound: if the provided `schedule_id` is not associated with a schedule
    /// - NoUnlocksAvailable: if there a zero unlocked tokens to transfer
    pub fn unlock(
        &mut self,
        schedule_id: U256,
        start_idx: u32,
        end_idx: u32,
    ) -> Result<(), ContractError> {
        // Step 1: Check that the schedule exits
        let token = self.token.get(schedule_id);

        if token.is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        // Step 2: Determine unlocked token amount & zero newly unlocked amounts, respecting the iteration limits
        let now = U64::from(self.vm().block_timestamp());

        let mut schedule = self.schedule.setter(schedule_id);
        let mut idx = start_idx;
        let mut unlocked_token_amount = U256::ZERO;
        while idx <= end_idx {
            let Some(mut schedule_item) = schedule.setter(idx) else {
                break;
            };

            idx += 1;

            if schedule_item.timestamp.get() > now {
                break;
            }

            let amount = schedule_item.amount.get();

            if amount.is_zero() {
                continue;
            }

            schedule_item.amount.set(U256::ZERO);

            // Overflow not possible because: escrow total <= U256::MAX checked during creation
            unlocked_token_amount += amount;
        }

        // Step 3: Check that unlocks are available
        if unlocked_token_amount.is_zero() {
            return Err(NoUnlocksAvailable {}.into());
        }

        let destination = self.destination.get(schedule_id);

        log(
            self.vm(),
            TokensUnlocked {
                schedule_id,
                destination,
                unlocked_token_amount,
            },
        );

        // Step 4: Transfer the unlocked amount to the current destination account
        Erc20Interface::new(token)
            .transfer(self, destination, unlocked_token_amount)
            .expect("Invariant: the contract always has sufficient balance to satisfy unlocks");

        Ok(())
    }

    // ...
}

Change destination

The ChangeDestination instruction handler looks like:

pub fn process_change_destination(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    seeds: [u8; 32],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();

    let vesting_account = next_account_info(accounts_iter)?;
    let destination_token_account = next_account_info(accounts_iter)?;
    let destination_token_account_owner = next_account_info(accounts_iter)?;
    let new_destination_token_account = next_account_info(accounts_iter)?;

    if vesting_account.data.borrow().len() < VestingScheduleHeader::LEN {
        return Err(ProgramError::InvalidAccountData)
    }
    let vesting_account_key = Pubkey::create_program_address(&[&seeds], program_id)?;
    let state = VestingScheduleHeader::unpack(
        &vesting_account.data.borrow()[..VestingScheduleHeader::LEN],
    )?;

    if vesting_account_key != *vesting_account.key {
        msg!("Invalid vesting account key");
        return Err(ProgramError::InvalidArgument);
    }

    if state.destination_address != *destination_token_account.key {
        msg!("Contract destination account does not matched provided account");
        return Err(ProgramError::InvalidArgument);
    }

    if !destination_token_account_owner.is_signer {
        msg!("Destination token account owner should be a signer.");
        return Err(ProgramError::InvalidArgument);
    }

    let destination_token_account = Account::unpack(&destination_token_account.data.borrow())?;

    if destination_token_account.owner != *destination_token_account_owner.key {
        msg!("The current destination token account isn't owned by the provided owner");
        return Err(ProgramError::InvalidArgument);
    }

    let mut new_state = state;
    new_state.destination_address = *new_destination_token_account.key;
    new_state
        .pack_into_slice(&mut vesting_account.data.borrow_mut()[..VestingScheduleHeader::LEN]);

    Ok(())
}

This boils down to:

  1. Check the proposed destination is valid
  2. Check the schedule exits
  3. Check the caller is the owner
  4. Overwrite the existing destination

As mentioned in Phase 1, as the owner is determined by checking the owner of the destination associated token account, the ChangeDestination instruction also potentially changes the owner. In order to have feature parity, a separate change_owner function is added to the Stylus implementation.

#[public]
impl TokenVestingContract {
    // ...

    /// Change the `destination` associated with the `schedule_id`, this can only be called by the associated `owner`.
    ///
    /// # Errors
    /// - ScheduleNotFound: if the provided `schedule_id` is not associated with a schedule
    /// - InvalidDestination: if the provided destination address is zero
    /// - Unauthorized: if the caller is not the owner of the schedule
    pub fn change_destination(
        &mut self,
        schedule_id: U256,
        destination: Address,
    ) -> Result<(), ContractError> {
        // Step 1: Check that the proposed destination is valid
        if destination == Address::ZERO {
            return Err(InvalidDestination {}.into());
        }

        // Step 2: Check that the schedule exists
        if self.token.get(schedule_id).is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        // Step 3: Check that the caller is the current owner
        if self.vm().msg_sender() != self.owner.get(schedule_id) {
            return Err(Unauthorized {}.into());
        }

        // Step 4: Overwrite the stored destination
        self.destination.insert(schedule_id, destination);

        Ok(())
    }

    /// Change the `owner` associated with the `schedule_id`, this can only be called by the current `owner`.
    ///
    /// Note: setting a zero address for `owner` means the `destination` is now immutable.
    ///
    /// # Errors
    /// - ScheduleNotFound: if the provided `schedule_id` is not associated with a schedule
    /// - Unauthorized: if the caller is not the owner of the schedule
    pub fn change_owner(&mut self, schedule_id: U256, owner: Address) -> Result<(), ContractError> {
        // Step 1: Check that the schedule exists
        if self.token.get(schedule_id).is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        // Step 2: Check that the caller is the current owner
        if self.vm().msg_sender() != self.owner.get(schedule_id) {
            return Err(Unauthorized {}.into());
        }

        // Step 3: Overwrite the stored owner
        self.owner.insert(schedule_id, owner);

        Ok(())
    }

    // ...
}

Phase 4: View Functions

It is conventional to add state accessor functions to Stylus contracts:

#[public]
impl TokenVestingContract {
    // ...
    
    // View functions
    fn schedule_count(&self) -> U256 {
        self.schedule_count.get()
    }

    fn token(&self, schedule_id: U256) -> Address {
        self.token.get(schedule_id)
    }

    fn destination(&self, schedule_id: U256) -> Address {
        self.destination.get(schedule_id)
    }

    fn owner(&self, schedule_id: U256) -> Address {
        self.owner.get(schedule_id)
    }

    fn schedule(&self, schedule_id: U256) -> Vec<(U64, U256)> {
        if self.token(schedule_id).is_zero() {
            return vec![];
        }

        let schedule_store = self.schedule.getter(schedule_id);

        let mut schedule = vec![];
        let mut idx = 0;
        while let Some(schedule_item) = schedule_store.getter(idx) {
            schedule.push((schedule_item.timestamp.get(), schedule_item.amount.get()));
            idx += 1;
        }

        schedule
    }
}

Phase 5: Events

To allow efficient auditing and state tracking for clients, it is best practice to emit a structured event whenever the contract state is updated:

sol! {
    // ...

    event ScheduleCreated(
        uint256 schedule_id,
        address token,
        address owner,
        address destination,
        uint64[] timestamps,
        uint256[] amounts
    );

    event TokensUnlocked(
        uint256 schedule_id,
        address destination,
        uint256 unlocked_token_amount,
    );

    event DestinationChanged(
        uint256 schedule_id,
        address old_destination,
        address new_destination,
    );

    event OwnerChanged(
        uint256 schedule_id,
        address old_owner,
        address new_owner,
    );
}


#[public]
impl TokenVestingContract {
    // ...

    pub fn create(
        &mut self,
        token: Address,
        owner: Address,
        destination: Address,
        schedule: Vec<(u64, U256)>,
    ) -> Result<U256, ContractError> {
        if token == Address::ZERO {
            return Err(InvalidToken {}.into());
        }

        if source == Address::ZERO {
            return Err(InvalidSource {}.into());
        }

        if destination == Address::ZERO {
            return Err(InvalidDestination {}.into());
        }

        if schedule.is_empty() {
            return Err(InvalidSchedule {}.into());
        }

        let schedule_id = self.schedule_count.get() + U256::ONE;

        let mut schedule_store = self.schedule.setter(schedule_id);
        let mut total_vested_amount = U256::ZERO;
        let mut last_timestamp = 0u64;
        let mut timestamps = Vec::with_capacity(schedule.len());
        let mut amounts = Vec::with_capacity(schedule.len());
        for (timestamp, amount) in schedule {
            if amount.is_zero() || timestamp <= last_timestamp {
                return Err(InvalidSchedule {}.into());
            }

            last_timestamp = timestamp;
            total_vested_amount = total_vested_amount
                .checked_add(amount)
                .ok_or(InvalidSchedule {})?;

            timestamps.push(timestamp);
            amounts.push(amount);

            let mut schedule_item = schedule_store.grow();
            schedule_item.timestamp.set(U64::from(timestamp));
            schedule_item.amount.set(amount);
        }

        self.schedule_count.set(schedule_id);
        self.token.insert(schedule_id, token);
        self.owner.insert(schedule_id, owner);
        self.destination.insert(schedule_id, destination);

        log(
            self.vm(),
            ScheduleCreated {
                schedule_id,
                token,
                owner,
                destination,
                timestamps,
                amounts,
            },
        );

        let contract_addr = self.vm().contract_address();
        Erc20Interface::new(token)
            .transfer_from(self, source, contract_addr, total_vested_amount)
            .map_err(|_| TokenDepositTransferFailed {})?;

        Ok(schedule_id)
    }

    pub fn unlock(&mut self, schedule_id: U256) -> Result<(), ContractError> {
        let token = self.token.get(schedule_id);

        if token.is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        let now = U64::from(self.vm().block_timestamp());

        let mut schedule = self.schedule.setter(schedule_id);
        let mut idx = 0;
        let mut unlocked_token_amount = U256::ZERO;
        loop {
            let Some(mut schedule_item) = schedule.setter(idx) else {
                break;
            };

            idx += 1;

            if schedule_item.timestamp.get() > now {
                break;
            }

            let amount = schedule_item.amount.get();

            if amount.is_zero() {
                continue;
            }

            schedule_item.amount.set(U256::ZERO);

            // Overflow not possible because: escrow total <= U256::MAX checked during creation
            unlocked_token_amount += amount;
        }

        if unlocked_token_amount.is_zero() {
            return Err(NoUnlocksAvailable {}.into());
        }

        let destination = self.destination.get(schedule_id);

        log(
            self.vm(),
            TokensUnlocked {
                schedule_id,
                destination,
                unlocked_token_amount,
            },
        );

        Erc20Interface::new(token)
            .transfer(self, destination, unlocked_token_amount)
            .expect("Invariant: the contract always has sufficient balance to satisfy unlocks");

        Ok(())
    }

    pub fn change_destination(
        &mut self,
        schedule_id: U256,
        new_destination: Address,
    ) -> Result<(), ContractError> {
        if new_destination == Address::ZERO {
            return Err(InvalidDestination {}.into());
        }

        if self.token.get(schedule_id).is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        if self.vm().msg_sender() != self.owner.get(schedule_id) {
            return Err(Unauthorized {}.into());
        }

        let old_destination = self.destination.replace(schedule_id, new_destination);

        log(
            self.vm(),
            DestinationChanged {
                schedule_id,
                old_destination,
                new_destination,
            },
        );

        Ok(())
    }

    pub fn change_owner(
        &mut self,
        schedule_id: U256,
        new_owner: Address,
    ) -> Result<(), ContractError> {
        if self.token.get(schedule_id).is_zero() {
            return Err(ScheduleNotFound {}.into());
        }

        if self.vm().msg_sender() != self.owner.get(schedule_id) {
            return Err(Unauthorized {}.into());
        }

        let old_owner = self.owner.replace(schedule_id, new_owner);

        log(
            self.vm(),
            OwnerChanged {
                schedule_id,
                old_owner,
                new_owner,
            },
        );

        Ok(())
    }
    // ...
}

Phase 6: Testing

Bonafida's Token Vesting repository has a single functional test and a series of fuzzing tests. As fuzzing is beyond the scope of this case study, we will focus on achieving functional unit testing parity.

Core Operations Tested

1. Vesting Schedule Creation

let schedules = vec![
    Schedule {amount: 20, release_time: 0},
    Schedule {amount: 20, release_time: 2},
    Schedule {amount: 20, release_time: 5}
];

create(
    &program_id,
    &spl_token::id(),
    &vesting_account_key,
    &vesting_token_account.pubkey(),
    &source_account.pubkey(),
    &source_token_account.pubkey(),
    &destination_token_account.pubkey(),
    &mint.pubkey(),
    schedules,
    seeds.clone()
)

Creates a vesting contract that locks 60 tokens total with three release points.

2. Token Unlocking

unlock(
    &program_id,
    &spl_token::id(),
    &sysvar::clock::id(),
    &vesting_account_key,
    &vesting_token_account.pubkey(),
    &destination_token_account.pubkey(),
    seeds.clone()
)

Attempts to unlock vested tokens based on the current time.

3. Destination Change

change_destination(
    &program_id,
    &vesting_account_key,
    &destination_account.pubkey(),
    &destination_token_account.pubkey(),
    &new_destination_token_account.pubkey(),
    seeds.clone()
)

Changes where future unlocked tokens will be sent, requiring authorization from the current destination account owner.

Note that the test only ever executes the instructions and checks that no errors are returned. It does not verify that token balances or other account data has been updated correctly.

Using the motsu test harness library developed by OpenZeppelin, we can create a series of unit tests that verify the contract business logic and interactions with the provided ERC20 token.

For the first test, verify that creating a schedule works as expected:

#[cfg(test)]
mod tests {
    use super::*;

    use alloy_primitives::{Address, U256, U64};
    use motsu::prelude::*;
    use openzeppelin_stylus::token::erc20::{Erc20, IErc20};

    pub const TOTAL_SUPPLY: u64 = 1_000_000;

    fn setup_env(token: &Contract<Erc20>, source: Address) {
        // Environment always starts at timestamp 1 for simplicity
        VM::context().set_block_timestamp(1);

        // Mint total supply of tokens to source account
        token
            .sender(source)
            ._mint(source, U256::from(TOTAL_SUPPLY))
            .motsu_unwrap();
    }

    #[motsu::test]
    fn test_create_vesting_schedule(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        source: Address,
        destination: Address,
    ) {
        setup_env(&token, source);

        // Approve vesting contract to transfer tokens
        let vesting_amount = U256::from(60u64);
        token
            .sender(source)
            .approve(vesting.address(), vesting_amount)
            .motsu_unwrap();

        // Create vesting schedule with 3 unlocks
        let schedule = vec![
            (0u64, U256::from(20u64)),   // Immediate unlock
            (100u64, U256::from(20u64)), // After timestamp 100
            (200u64, U256::from(20u64)), // After timestamp 200
        ];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule.clone())
            .motsu_unwrap();

        // Verify schedule was created
        assert_eq!(schedule_id, U256::from(1u64));
        assert_eq!(vesting.sender(source).schedule_count(), U256::from(1u64));
        assert_eq!(vesting.sender(source).token(schedule_id), token.address());
        assert_eq!(vesting.sender(source).owner(schedule_id), owner);
        assert_eq!(vesting.sender(source).destination(schedule_id), destination);

        // Verify schedule details
        let stored_schedule = vesting.sender(source).schedule(schedule_id);
        assert_eq!(stored_schedule.len(), 3);
        assert_eq!(stored_schedule[0], (U64::from(0u64), U256::from(20u64)));
        assert_eq!(stored_schedule[1], (U64::from(100u64), U256::from(20u64)));
        assert_eq!(stored_schedule[2], (U64::from(200u64), U256::from(20u64)));

        // Verify tokens were transferred to vesting contract
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            vesting_amount
        );
        assert_eq!(
            token.sender(source).balance_of(source),
            U256::from(TOTAL_SUPPLY) - vesting_amount
        );
    }

Next, verify that each tranche can be unlocked:

#[cfg(test)]
mod tests {
    // ...
    
    #[motsu::test]
    fn test_unlock_tokens(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        source: Address,
        destination: Address,
    ) {
        setup_env(&token, source);

        let vesting_amount = U256::from(60u64);
        token
            .sender(source)
            .approve(vesting.address(), vesting_amount)
            .motsu_unwrap();

        let schedule = vec![
            (0u64, U256::from(20u64)),
            (100u64, U256::from(20u64)),
            (200u64, U256::from(20u64)),
        ];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule)
            .motsu_unwrap();

        // Test 1: Unlock at timestamp 1 (immediate unlock for first tranche)
        vesting
            .sender(source)
            .unlock(schedule_id, 0, 2)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(20u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::from(40u64)
        );

        // Verify first unlock is now zero in schedule
        let stored_schedule = vesting.sender(source).schedule(schedule_id);
        assert_eq!(stored_schedule[0].1, U256::ZERO);

        // Test 2: Try to unlock again at same timestamp (should fail - no unlocks available)
        let err = vesting
            .sender(source)
            .unlock(schedule_id, 0, 2)
            .motsu_unwrap_err();
        assert!(matches!(err, ContractError::NoUnlocksAvailable(_)));

        // Test 3: Unlock at timestamp 150 (should unlock second tranche)
        VM::context().set_block_timestamp(150);
        vesting
            .sender(source)
            .unlock(schedule_id, 0, 2)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(40u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::from(20u64)
        );

        // Test 4: Unlock at timestamp 250 (should unlock final tranche)
        VM::context().set_block_timestamp(250);
        vesting
            .sender(source)
            .unlock(schedule_id, 0, 2)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(60u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::ZERO
        );

        // All tokens should be unlocked now
        let final_schedule = vesting.sender(source).schedule(schedule_id);
        assert!(final_schedule.iter().all(|(_, amount)| amount.is_zero()));
    }

    #[motsu::test]
    fn test_unlock_multiple_at_once(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        source: Address,
        destination: Address,
    ) {
        setup_env(&token, source);

        let vesting_amount = U256::from(60u64);
        token
            .sender(source)
            .approve(vesting.address(), vesting_amount)
            .motsu_unwrap();

        let schedule = vec![
            (50u64, U256::from(20u64)),
            (100u64, U256::from(20u64)),
            (150u64, U256::from(20u64)),
        ];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule)
            .motsu_unwrap();

        // Jump to timestamp 120 - should unlock first two tranches at once
        VM::context().set_block_timestamp(120);
        vesting
            .sender(source)
            .unlock(schedule_id, 0, 2)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(40u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::from(20u64)
        );
    }

    #[motsu::test]
    fn test_unlock_multiple_out_of_order(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        source: Address,
        destination: Address,
    ) {
        setup_env(&token, source);

        let vesting_amount = U256::from(80u64);
        token
            .sender(source)
            .approve(vesting.address(), vesting_amount)
            .motsu_unwrap();

        let schedule = vec![
            (50u64, U256::from(20u64)),
            (100u64, U256::from(20u64)),
            (150u64, U256::from(20u64)),
            (200u64, U256::from(20u64)),
        ];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule)
            .motsu_unwrap();

        // Jump to timestamp 250 - all tranches unlocked
        VM::context().set_block_timestamp(250);

        // unlock middle tranches
        vesting
            .sender(source)
            .unlock(schedule_id, 1, 2)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(40u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::from(40u64)
        );

        // unlock rest of tranches
        vesting
            .sender(source)
            .unlock(schedule_id, 0, 3)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(destination),
            U256::from(80u64)
        );
        assert_eq!(
            token.sender(source).balance_of(vesting.address()),
            U256::ZERO
        );
    }
}

Test the access control logic for the permissioned functions:

#[cfg(test)]
mod tests {
    // ...

    #[motsu::test]
    fn test_change_destination(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        source: Address,
        destination: Address,
        new_destination: Address,
    ) {
        setup_env(&token, source);

        let vesting_amount = U256::from(40u64);
        token
            .sender(source)
            .approve(vesting.address(), vesting_amount)
            .motsu_unwrap();

        let schedule = vec![(100u64, U256::from(20u64)), (200u64, U256::from(20u64))];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule)
            .motsu_unwrap();

        // Test 1: Unauthorized change (not owner)
        let err = vesting
            .sender(source)
            .change_destination(schedule_id, new_destination)
            .motsu_unwrap_err();
        assert!(matches!(err, ContractError::Unauthorized(_)));

        // Test 2: Authorized change by owner
        vesting
            .sender(owner)
            .change_destination(schedule_id, new_destination)
            .motsu_unwrap();

        assert_eq!(
            vesting.sender(owner).destination(schedule_id),
            new_destination
        );

        // Test 3: Unlock tokens to new destination
        VM::context().set_block_timestamp(150);
        vesting
            .sender(owner)
            .unlock(schedule_id, 0, 1)
            .motsu_unwrap();

        assert_eq!(
            token.sender(source).balance_of(new_destination),
            U256::from(20u64)
        );
        assert_eq!(token.sender(source).balance_of(destination), U256::ZERO);
    }

    #[motsu::test]
    fn test_change_owner(
        token: Contract<Erc20>,
        vesting: Contract<TokenVestingContract>,
        owner: Address,
        new_owner: Address,
        source: Address,
        destination: Address,
    ) {
        setup_env(&token, source);

        token
            .sender(source)
            .approve(vesting.address(), U256::from(20u64))
            .motsu_unwrap();

        let schedule = vec![(100u64, U256::from(20u64))];

        let schedule_id = vesting
            .sender(source)
            .create(token.address(), owner, destination, schedule)
            .motsu_unwrap();

        // Test 1: Unauthorized change
        let err = vesting
            .sender(source)
            .change_owner(schedule_id, new_owner)
            .motsu_unwrap_err();
        assert!(matches!(err, ContractError::Unauthorized(_)));

        // Test 2: Authorized change by current owner
        vesting
            .sender(owner)
            .change_owner(schedule_id, new_owner)
            .motsu_unwrap();

        assert_eq!(vesting.sender(new_owner).owner(schedule_id), new_owner);

        // Test 3: New owner can now change destination
        let another_destination = Address::from([5u8; 20]);
        vesting
            .sender(new_owner)
            .change_destination(schedule_id, another_destination)
            .motsu_unwrap();

        assert_eq!(
            vesting.sender(new_owner).destination(schedule_id),
            another_destination
        );
    }
}    

Additional tests include exercising schedule creation input validation, state isolation of schedules and correct handling of non-existant schedule identifiers:

#[cfg(test)]
mod tests {
   // ...

   #[motsu::test]
   fn test_create_validation_errors(
       token: Contract<Erc20>,
       vesting: Contract<TokenVestingContract>,
       owner: Address,
       source: Address,
       destination: Address,
   ) {
       setup_env(&token, source);

       // Test 1: Invalid token (zero address)
       let err = vesting
           .sender(source)
           .create(
               Address::ZERO,
               owner,
               destination,
               vec![(100u64, U256::from(20u64))],
           )
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::InvalidToken(_)));

       // Test 3: Invalid destination (zero address)
       let err = vesting
           .sender(source)
           .create(
               token.address(),
               owner,
               Address::ZERO,
               vec![(100u64, U256::from(20u64))],
           )
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::InvalidDestination(_)));

       // Test 4: Empty schedule
       let err = vesting
           .sender(source)
           .create(token.address(), owner, destination, vec![])
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::InvalidSchedule(_)));

       // Test 5: Zero amount in schedule
       let err = vesting
           .sender(source)
           .create(
               token.address(),
               owner,
               destination,
               vec![(100u64, U256::ZERO)],
           )
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::InvalidSchedule(_)));

       // Test 6: Non-chronological schedule
       let err = vesting
           .sender(source)
           .create(
               token.address(),
               owner,
               destination,
               vec![
                   (200u64, U256::from(10u64)),
                   (100u64, U256::from(10u64)), // Earlier timestamp after later one
               ],
           )
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::InvalidSchedule(_)));

       // Test 7: Insufficient allowance
       token
           .sender(source)
           .approve(vesting.address(), U256::from(10u64))
           .motsu_unwrap();

       let err = vesting
           .sender(source)
           .create(
               token.address(),
               owner,
               destination,
               vec![(100u64, U256::from(20u64))], // Needs 20 but only approved 10
           )
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::TokenDepositFailed(_)));
   }

   #[motsu::test]
   fn test_multiple_schedules(
       token: Contract<Erc20>,
       vesting: Contract<TokenVestingContract>,
       owner1: Address,
       owner2: Address,
       source: Address,
       destination1: Address,
       destination2: Address,
   ) {
       setup_env(&token, source);

       // Create first schedule
       token
           .sender(source)
           .approve(vesting.address(), U256::from(30u64))
           .motsu_unwrap();

       let schedule_id1 = vesting
           .sender(source)
           .create(
               token.address(),
               owner1,
               destination1,
               vec![(100u64, U256::from(30u64))],
           )
           .motsu_unwrap();

       // Create second schedule
       token
           .sender(source)
           .approve(vesting.address(), U256::from(50u64))
           .motsu_unwrap();

       let schedule_id2 = vesting
           .sender(source)
           .create(
               token.address(),
               owner2,
               destination2,
               vec![(200u64, U256::from(50u64))],
           )
           .motsu_unwrap();

       // Verify separate schedule IDs
       assert_eq!(schedule_id1, U256::from(1u64));
       assert_eq!(schedule_id2, U256::from(2u64));
       assert_eq!(vesting.sender(source).schedule_count(), U256::from(2u64));

       // Verify schedules are independent
       assert_eq!(vesting.sender(source).owner(schedule_id1), owner1);
       assert_eq!(vesting.sender(source).owner(schedule_id2), owner2);
       assert_eq!(
           vesting.sender(source).destination(schedule_id1),
           destination1
       );
       assert_eq!(
           vesting.sender(source).destination(schedule_id2),
           destination2
       );

       // Unlock first schedule
       VM::context().set_block_timestamp(150);
       vesting
           .sender(source)
           .unlock(schedule_id1, 0, 1)
           .motsu_unwrap();
       assert_eq!(
           token.sender(source).balance_of(destination1),
           U256::from(30u64)
       );
       assert_eq!(token.sender(source).balance_of(destination2), U256::ZERO);

       // Unlock second schedule
       VM::context().set_block_timestamp(200);
       vesting
           .sender(source)
           .unlock(schedule_id2, 0, 1)
           .motsu_unwrap();
       assert_eq!(
           token.sender(source).balance_of(destination1),
           U256::from(30u64)
       );
       assert_eq!(
           token.sender(source).balance_of(destination2),
           U256::from(50u64)
       );
   }

   #[motsu::test]
   fn test_nonexistent_schedule_operations(
       vesting: Contract<TokenVestingContract>,
       caller: Address,
       new_destination: Address,
       new_owner: Address,
   ) {
       let nonexistent_id = U256::from(999u64);

       // Test unlock on nonexistent schedule
       let err = vesting
           .sender(caller)
           .unlock(nonexistent_id, 0, 1)
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::ScheduleNotFound(_)));

       // Test change_destination on nonexistent schedule
       let err = vesting
           .sender(caller)
           .change_destination(nonexistent_id, new_destination)
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::ScheduleNotFound(_)));

       // Test change_owner on nonexistent schedule
       let err = vesting
           .sender(caller)
           .change_owner(nonexistent_id, new_owner)
           .motsu_unwrap_err();
       assert!(matches!(err, ContractError::ScheduleNotFound(_)));

       // Test view functions return sensible defaults
       assert_eq!(vesting.sender(caller).token(nonexistent_id), Address::ZERO);
       assert_eq!(vesting.sender(caller).owner(nonexistent_id), Address::ZERO);
       assert_eq!(
           vesting.sender(caller).destination(nonexistent_id),
           Address::ZERO
       );
       assert_eq!(vesting.sender(caller).schedule(nonexistent_id), vec![]);
   }
}    

Testing and Debugging

Unit Testing

Test harnesses for Solana programs, such as LiteSVM or Mollusk, require loading the target program binary, as well any dependency program binaries, into a cut down implementation of the Solana Virtual Machine (SVM).

In contrast, Stylus contracts can be tested by instantiating the contract with a mock Host trait implementation without needing to first build the WASM binary.

Setup

In order for the contract to be instantiated from a test Host implementation, the stylus-test feature must be enabled for the stylus-sdk dependency. This ensures that the #[storage] attribute macro, applied to the top-level contract struct, generates the From<&HostImpl> implementation.

Note: The coupling of the test setup implementation with the #[storage] macro allows a contract to be split into sub-components that can be independently instantiated and tested. This pattern is used extensively by OpenZeppelin, an example of which is the Ownable component tests.

While the TestVM provided in the stylys_sdk::testing module is sufficient for simple contracts, versions up to 0.9.0 do not support the use of interfaces to call external contracts.

The motsu test harness library, developed by OpenZeppelin, allows for the use of interfaces and the testing of the interaction between multiple contracts, as well as improved test setup ergonomics. This is particularly useful if the contract under test uses ERC20 or ERC721 tokens.

[package]
# ...

[dependencies]
alloy-primitives = "=0.8.20"
alloy-sol-types = "=0.8.20"
stylus-sdk = "0.9.0"

[dev-dependencies]
alloy-primitives = { version = "=0.8.20" features = [ "tiny-keccak" ] }
# required for motsu
arbitrary = { version = "1.4.2", features = [ "derive" ] } 
motsu = "0.10.0"

Note: Adding motsu to dev-dependencies implicitly enables the stylus-test feature via an indirect dependency.

Example

The following test shows how motsu can be used to test contracts that accept ERC20 deposits, such as the ERC20 Allowance example.

#[cfg(test)]
mod tests {
    use super::*;

    use alloy_primitives::U256;
    use motsu::prelude::*;
    use openzeppelin_stylus::token::erc20::{
        ERC20InsufficientAllowance, Erc20, Error as Erc20Error, IErc20,
    };
    use stylus_sdk::call::MethodError;

    pub const TOTAL_SUPPLY: u64 = 1_000_000_000_000_000;

    #[motsu::test]
    fn test_contract(
        stake_token: Contract<Erc20>,
        stake_contract: Contract<StakeErc20Contract>,
        alice: Address,
    ) {
        stake_token
            .sender(alice)
            ._mint(alice, U256::from(TOTAL_SUPPLY))
            .motsu_unwrap();

        stake_contract
            .sender(alice)
            .constructor(stake_token.address());

        // Verify initial state
        assert_eq!(
            stake_token.sender(alice).total_supply(),
            U256::from(TOTAL_SUPPLY)
        );
        assert_eq!(
            stake_token.sender(alice).balance_of(alice),
            U256::from(TOTAL_SUPPLY)
        );
        assert_eq!(
            stake_contract.sender(alice).staked_balance_of(alice),
            U256::ZERO
        );
        assert_eq!(
            stake_token
                .sender(alice)
                .balance_of(stake_contract.address()),
            U256::ZERO
        );

        // Calculate stake amount (1/2 of total supply)
        let stake_amount = U256::from(TOTAL_SUPPLY / 2);
        let remaining_balance = U256::from(TOTAL_SUPPLY / 2);

        // Give stake contract allowance to transfer 1/2 of the total supply
        stake_token
            .sender(alice)
            .approve(stake_contract.address(), stake_amount)
            .motsu_unwrap();

        // Stake 1/2 of the total supply
        stake_contract
            .sender(alice)
            .stake(stake_amount)
            .motsu_unwrap();

        // Verify balances after staking
        assert_eq!(
            stake_token.sender(alice).balance_of(alice),
            remaining_balance
        );
        assert_eq!(
            stake_contract.sender(alice).staked_balance_of(alice),
            stake_amount
        );
        assert_eq!(
            stake_token
                .sender(alice)
                .balance_of(stake_contract.address()),
            stake_amount
        );

        // Attempt to stake more than available balance - should fail
        let err = stake_contract
            .sender(alice)
            .stake(stake_amount)
            .motsu_unwrap_err();
        assert_eq!(
            err,
            Erc20Error::InsufficientAllowance(ERC20InsufficientAllowance {
                spender: stake_contract.address(),
                allowance: U256::ZERO,
                needed: stake_amount
            })
            .encode()
        );

        // Unstake the full staked amount
        stake_contract
            .sender(alice)
            .unstake(stake_amount)
            .motsu_unwrap();

        // Verify balances after unstaking
        assert_eq!(
            stake_token.sender(alice).balance_of(alice),
            U256::from(TOTAL_SUPPLY)
        );
        assert_eq!(
            stake_contract.sender(alice).staked_balance_of(alice),
            U256::ZERO
        );
        assert_eq!(
            stake_token
                .sender(alice)
                .balance_of(stake_contract.address()),
            U256::ZERO
        );

        // Attempt to unstake when no tokens are staked - should fail
        let err = stake_contract
            .sender(alice)
            .unstake(stake_amount)
            .motsu_unwrap_err();
        assert!(matches!(err, ContractError::InsufficientStakedBalance(_)));
    }
}

Debugging Techniques

Using the dbg! macro

As Stylus contracts are unit tested in the same fashion as conventional Rust code, not within a specialized VM like Solana programs, the standard library's dbg! macro can be inserted into the code under test to aid in debugging.

In Rust development, it is best practice to remove dbg! macro usage before committing code in version control.

Using the console! macro

Similar to the msg! logging macro in Solana programs, the console! macro can be used to add log messages within function execution. Messages emitted with console! will be readable in the testing node logs during integration testing.

The console! macro implementation is elided unless the debug feature is enabled in stylus-sdk. This means it is safe to commit code containing console! usage.

The following is an example of how to conditionally enable the debug feature:

[package]
# ...

[features]
debug = ["stylus-sdk/debug"]

[dependencies]
# ... 
stylus-sdk = "0.9.0"

To build a WASM artifact with console! logging enabled for integration testing, the following command structure can be used:

cargo build --features debug --release

Note: Additional WASM artifact size optimization may be required. Refer to the official Stylus documentation.

Gas optimization

This chapter covers strategies to reduce gas consumption with specific focus on how gas usage differs from Solana's compute unit model.

Compute Units vs Gas & Ink

The fundamental difference between Solana and Ethereum/Stylus fee models:

AspectSolanaStylus/Ethereum
UnitCompute Units (CU)Gas (and ink in Stylus VM)
PricingFixed: 5,000 Lamports (0.000005 SOL) per signatureVariable: Gas price fluctuates with network demand
LimitsPer-transaction: 1.4M CU maxPer-block gas limit: ~30M gas
MeasurementInstruction-based (each instruction deducts from CU budget)Operation-based (WASM opcodes measured in ink)
State AccessRent-exempt deposits (one-time, refundable)Per-operation gas cost (with SDK caching optimization)
Optimization FocusReduce CU usage and account sizeReduce storage operations; leverage compute efficiency

Stylus-Specific Concepts

Stylus introduces ink as a sub-gas unit for measuring WASM execution:

  • 1 gas = 10,000 ink (configurable exchange rate)
  • WASM opcodes are orders of magnitude faster than EVM opcodes, thus requiring fractional gas in the form of ink.

Cost Advantages in Stylus vs EVM:

  • Compute: 10-100x cheaper than EVM due to WASM efficiency and compiled Rust/C/C++ code quality
  • Memory: 100-500x cheaper with novel exponential pricing (vs. EVM's quadratic per-call model)
  • Storage: SLOAD/SSTORE cost the same as EVM, but Stylus SDK implements optimal caching to minimize operations

Stylus Storage Cache:

The Stylus VM implements an storage cache that dramatically reduces the cost of repeated storage access:

  • Storage reads: First 32 reads are free (0 gas), reads 33-128 cost 2 gas each, subsequent reads cost 10 gas each
  • Storage writes: First 8 writes are free (0 gas), writes 9-64 cost 7 gas each, subsequent writes cost 10 gas each
  • Cache mechanics: The StorageCache is used to track the value of accessed slots, with dirty writes batched and flushed to the host EVM
  • Per-transaction scope: Cache persists for the duration of a single transaction/call, resetting between calls

This caching strategy means repeatedly accessing the same storage slots within a transaction is nearly free after the initial access, unlike standard EVM where each warm SLOAD costs 100 gas.

Cost Comparison:

Solana:

  • Base transaction fee: 5,000 Lamports (0.000005 SOL) per signature
  • Simple transfer: ~300 CU (when optimized with SetComputeUnitLimit)
  • System program CPI: ~2,215 CU
  • Token transfer (direct): ~3,000 CU
  • Token transfer via CPI: ~4,100 CU (adds ~1,000 CU overhead)
  • Account creation requires rent-exempt deposit based on data size:
    • Empty account: ~890,880 Lamports (~0.00089 SOL)
    • 32-byte account: ~1,113,600 Lamports (~0.0011 SOL)
    • Deposits are fully refundable when accounts are closed

Note: Unlike EVM/Stylus the amount of compute units used does not affect the overall transaction fee but it does affect the block inclusion latency. The lower the compute unit usage, the higher the reward ratio for validators to include the transaction in a block based on the fixed base fee plus any proposed priority fee.

Stylus:

  • Simple I32Add: 70 ink = 0.007 gas
  • Simple I64Add: 100 ink = 0.01 gas
  • Keccak hash: 121,800 + 21,000w ink (w = EVM words)
  • Storage operations with SDK caching:
    • First 32 reads: 0 gas (cached)
    • First 8 writes: 0 gas (cached)
    • Subsequent cached reads: 2-10 gas
    • Subsequent cached writes: 7-10 gas
    • Cold SLOAD (first access, not in cache): ~2,100 gas (EVM standard)
    • Cold SSTORE (first write): ~20,000 gas new slot, ~5,000 gas update (EVM standard)
  • Host I/O call overhead: ~0.84 gas per host function invocation
  • External contract call: 128-2,048 gas base overhead
  • WASM contract entry: 128-2,048 gas per Stylus contract call

Note: due to the gas overhead of entering the WASM VM when calling a Stylus contract, it may be cheaper gas-wise to use Solidity for trivial contracts.

Optimization Techniques

With industry-leading compiler technology compiling contracts to WASM, assembly-level gas optimizations common in Solidity/EVM development are not required.

As a general principle, favor code readability and simplicity over premature optimization. However, field ordering matters when working with storage.

Storage Slot Packing

The Stylus SDK's #[storage] macro automatically packs storage fields efficiently, but it can only pack adjacent fields. The macro processes fields sequentially and cannot reorder them, so the order you declare fields determines the storage layout.

#[storage]
pub struct EfficientStorage {
    flag1: StorageBool,  // Slot 0, byte 0
    flag2: StorageBool,  // Slot 0, byte 1
    x: StorageU256,      // Slot 1, bytes 0-31
}

#[storage]
pub struct InefficientStorage {
    flag1: StorageBool,  // Slot 0, byte 0
    x: StorageU256,      // Slot 1, bytes 0-31
    flag2: StorageBool,  // Slot 2, byte 0 (wasted slot!)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_slot_usage() {
        assert_eq!(EfficientStorage::required_slots(), 2);
        assert_eq!(InefficientStorage::required_slots(), 3);
    }
}

Key insight: Group fields by size to fill 32-byte slots completely. When flag1 and flag2 are adjacent, they share a slot. When separated by StorageU256, each requires its own slot, wasting 31 bytes per slot.

Understanding the Macro Expansion

The #[storage] macro uses a greedy packing algorithm that tracks two variables:

  • space: Remaining bytes in the current slot (starts at 32)
  • slot: Current slot index (starts at 0)

For each field, the macro:

  1. Checks if the field fits in remaining space
  2. If not, moves to the next slot
  3. Allocates the field and updates tracking variables

Efficient Layout Expansion:

impl stylus_sdk::storage::StorageType for EfficientStorage {
    unsafe fn new(
        mut root: stylus_sdk::alloy_primitives::U256,
        offset: u8,
        host: stylus_sdk::host::VM,
    ) -> Self {
        let mut space: usize = 32;  // Available bytes in current slot
        let mut slot: usize = 0;    // Current slot index
        
        let accessor = Self {
            __stylus_host: host.clone(),
            
            flag1: {
                let bytes = <StorageBool as storage::StorageType>::SLOT_BYTES;      // = 1 byte
                let words = <StorageBool as storage::StorageType>::REQUIRED_SLOTS;  // = 0 (number of full slots required)
                
                if space < bytes {  // 32 < 1? → false, fits in current slot
                    space = 32;
                    slot += 1;
                }
                space -= bytes;  // 32 - 1 = 31 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 0
                let field = <StorageBool as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 31
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false, no full slots consumed
                    slot += words;
                    space = 32;
                }
                field
            },
            
            flag2: {
                let bytes = <StorageBool as storage::StorageType>::SLOT_BYTES;      // = 1 byte
                let words = <StorageBool as storage::StorageType>::REQUIRED_SLOTS;  // = 0
                
                if space < bytes {  // 31 < 1? → false, still fits
                    space = 32;
                    slot += 1;
                }
                space -= bytes;  // 31 - 1 = 30 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 0
                let field = <StorageBool as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 30
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false
                    slot += words;
                    space = 32;
                }
                field
            },
            
            x: {
                let bytes = <StorageU256 as storage::StorageType>::SLOT_BYTES;      // = 32 bytes
                let words = <StorageU256 as storage::StorageType>::REQUIRED_SLOTS;  // = 0
                
                if space < bytes {  // 30 < 32? → true, needs new slot
                    space = 32;
                    slot += 1;  // slot = 1
                }
                space -= bytes;  // 32 - 32 = 0 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 1
                let field = <StorageU256 as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 0
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false
                    slot += words;
                    space = 32;
                }
                field
            },
        };
        accessor
    }
}

Fields flag1 and flag2 both live in slot 0 at different byte offsets. x uses slot 1. Total: 2 slots.

Inefficient Layout Expansion:

impl stylus_sdk::storage::StorageType for InefficientStorage {
    unsafe fn new(
        mut root: stylus_sdk::alloy_primitives::U256,
        offset: u8,
        host: stylus_sdk::host::VM,
    ) -> Self {
        let mut space: usize = 32;  // Available bytes in current slot
        let mut slot: usize = 0;    // Current slot index

        let accessor = Self {
            __stylus_host: host.clone(),
            
            flag1: {
                let bytes = <StorageBool as storage::StorageType>::SLOT_BYTES;      // = 1 byte
                let words = <StorageBool as storage::StorageType>::REQUIRED_SLOTS;  // = 0
                
                if space < bytes {  // 32 < 1? → false, fits
                    space = 32;
                    slot += 1;
                }
                space -= bytes;  // 32 - 1 = 31 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 0
                let field = <StorageBool as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 31
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false
                    slot += words;
                    space = 32;
                }
                field
            },
            
            x: {
                let bytes = <StorageU256 as storage::StorageType>::SLOT_BYTES;      // = 32 bytes
                let words = <StorageU256 as storage::StorageType>::REQUIRED_SLOTS;  // = 0
                
                if space < bytes {  // 31 < 32? → true, needs new slot
                    space = 32;
                    slot += 1;  // slot = 1
                }
                space -= bytes;  // 32 - 32 = 0 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 1
                let field = <StorageU256 as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 0
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false
                    slot += words;
                    space = 32;
                }
                field
            },
            
            flag2: {
                let bytes = <StorageBool as storage::StorageType>::SLOT_BYTES;      // = 1 byte
                let words = <StorageBool as storage::StorageType>::REQUIRED_SLOTS;  // = 0
                
                if space < bytes {  // 0 < 1? → true, needs new slot
                    space = 32;
                    slot += 1;  // slot = 2
                }
                space -= bytes;  // 32 - 1 = 31 bytes remaining
                
                let root = root + alloy_primitives::U256::from(slot);  // slot = 2
                let field = <StorageBool as storage::StorageType>::new(
                    root,
                    space as u8,  // offset = 31
                    host.clone(),
                );
                
                if words > 0 {  // 0 > 0? → false
                    slot += words;
                    space = 32;
                }
                field
            },
        };
        accessor
    }
}

Field flag1 uses slot 0, x uses slot 1, flag2 uses slot 2. Total: 3 slots, with 31 wasted bytes in slots 0 and 2.

Best Practice: Group fields of similar sizes together to maximize slot utilization. The macro processes fields sequentially, so arrange them to minimize wasted space within each 32-byte slot. Small fields can appear before or after large fields, as long as they're grouped together to efficiently fill slots.

Security Considerations

Migrating from Solana to Stylus changes your threat model. The EVM environment exposes different attack surfaces and patterns. This chapter details the security checks and hardening steps for migrated contracts.

In stateless Solana programs, a potential attacker controls all of the input data that the program operates over. Extreme care has to be taken to verify keys, PDAs and accounts to ensure that an attacker cannot spoof their way into stealing program controlled funds or creating invalid account states.

By contrast, Stylus contracts solely control their own storage, which can be considered trusted. They must take care to verify sender access permissions and validate function parameters.

Contract reentrancy

Unlike EVM contracts, all Stylus contract entrypoints have reentrancy disabled by default. This is implemented in the code generated by the #[entrypoint] macro, which expands to:

#[no_mangle]
pub extern "C" fn user_entrypoint(len: usize) -> usize {
    let host = stylus_sdk::host::VM(stylus_sdk::host::WasmVM {});
    if host.msg_reentrant() {
        // Returning 1 indicates an error occured and the transaction will be reverted
        return 1;
    }
    host.pay_for_memory_grow(0);
    let input = host.read_args(len);
    let (data, status) = match __stylus_struct_entrypoint(input, host.clone()) {
        Ok(data) => (data, 0),
        Err(data) => (data, 1),
    };
    host.flush_cache(false);
    host.write_result(&data);
    status
}

Enabling the reentrant feature for stylus-sdk instructs the #[entrypoint] macro to not generate the blanket reentrancy check. In the rare occasion that reentrancy is required for functions, extreme care must be taken to ensure non-reentrant functions manually deny reentrant calls by using the MessageAccess::msg_reentrant check. Any reentrant function needs to ensure that any storage writes are performed and the storage cache is explicitly flushed before making the external call.

Integer Arithmetic Overflow

By far the most commonly discovered vulnerability in Stylus audits is the use of unchecked arithmetic functions (+, -, *, <<, >> and their associated trait methods in std::ops) on integer types, which silently wrap around in release builds.

It is best practice to used the explicitly checked variants of arithmetic functions, by convention named checked_* where * is a placeholder for the operation. The contract should then either explicitly panic if an overflow should be impossible given the business logic invariants or otherwise return an error, reverting the transaction in both cases.

Examples of Stylus audit findings concerning integer arithmetic overflow include:

Sender Authorization

When employing access control patterns, ensure that MessageAccess::msg_sender is used when checking that the caller matches the stored authority address.

The exception to this rule is when you are retrieving the creator address of a contract within the constructor function, in this case MessageAccess::tx_origin should be used because MessageAccess::msg_sender returns the Stylus contract factory address in production.