Smart Contract Development Guide

Overview

Lichen smart contracts are compiled to WebAssembly (WASM) and executed inside a Wasmer sandbox. Contracts are written in Rust using #![no_std] and export extern "C" functions that the runtime invokes. The lichen-contract-sdk crate provides all host functions for storage, events, logging, cross-contract calls, and more.

Key Facts
  • Compile target: wasm32-unknown-unknown
  • Runtime: Wasmer sandbox with deterministic execution
  • Storage: Key-value byte store per contract
  • Entry points: #[no_mangle] pub extern "C" fn (named exports, recommended)
  • Legacy DEX contracts use opcode dispatch via call() — see Contract Reference for details
  • Return codes: 1 = success, 0 = failure

Deploy vs Token Create — When to Use Which

All tokens on Lichen are WASM contracts. There are three ways to deploy and register them:

molt deploy — Custom WASM Contract

Use when you need custom logic: DeFi protocols, NFT collections, games, DAOs, oracles, or tokens with non-standard behavior.

  • You write Rust, compile to WASM, deploy on-chain
  • Full access to all SDK features (storage, events, cross-calls, Token/NFT modules)
  • Deploy fee: 25.001 LICN (25 LICN deploy + 0.001 LICN base fee)
  • If deployment fails, the 25 LICN premium is refunded
  • Add --symbol, --name, --template to auto-register in the symbol registry
licn token create — Token Deploy Shortcut

Convenience wrapper for deploying a token WASM contract with template="token" preset.

  • Requires --wasm flag with a compiled WASM token contract
  • Automatically registers in symbol registry with template="token"
  • Same fee as molt deploy: 25.001 LICN
molt contract register — Retroactive Registration

Register an already-deployed contract in the symbol registry (e.g. if you forgot --symbol during deploy).

  • Must be called by the contract owner
  • Only 0.001 LICN base fee
Command What it does Fee
molt deploy wasm --symbol X Deploy WASM + register in symbol registry 25.001 LICN
licn token create "Name" SYM --wasm token.wasm Deploy token WASM + register (template=token) 25.001 LICN
molt contract register <addr> --symbol X Retroactively register deployed contract 0.001 LICN

Token Metadata Schema

When deploying with --metadata (CLI) or via the Playground template options, the following fields are stored in the on-chain Symbol Registry and returned by getContractInfo / getSymbolRegistryByProgram:

Field Type Description
description string Short description of the token/contract
website string Project website URL
logo_url string URL to a square logo image (PNG/SVG recommended)
twitter string Twitter/X profile URL
telegram string Telegram group/channel URL
discord string Discord invite URL
mintable boolean Whether the supply can grow after deploy
burnable boolean Whether holders can burn their tokens
decimals number Decimal precision (0–18, default 9)
initial_supply string Initial supply in base units (spores)

Additional custom keys are allowed and stored as-is. The Explorer, Wallet, and DEX frontends read logo_url, description, and social links to render token profiles automatically.

Project Setup

Create a new contract project with the following Cargo.toml:

Cargo.toml
[package]
name = "my-contract"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
lichen-sdk = { package = "lichen-contract-sdk", path = "../../sdk" }

Your src/lib.rs must declare #![no_std] and export entry points as extern "C" functions:

src/lib.rs — Minimal Contract
#![no_std]
#![cfg_attr(target_arch = "wasm32", no_main)]

extern crate alloc;
use alloc::vec::Vec;

use lichen_sdk::{
    storage, contract, event, log,
    Address, ContractResult, ContractError,
};

/// Initialize the contract. Called once after deployment.
#[no_mangle]
pub extern "C" fn initialize(admin_ptr: *const u8) -> u32 {
    let admin = unsafe { core::slice::from_raw_parts(admin_ptr, 32) };
    storage::set(b"admin", admin);
    log::info("Contract initialized");
    0 // success
}

/// Example: store a value.
#[no_mangle]
pub extern "C" fn set_value() -> u32 {
    let args = contract::args();
    if args.len() < 8 {
        return 1; // invalid input
    }
    storage::set(b"value", &args[..8]);
    event::emit(r#"{"name":"ValueSet","data":"..."}"#);
    0
}

/// Example: read a value.
#[no_mangle]
pub extern "C" fn get_value() -> u32 {
    match storage::get(b"value") {
        Some(data) => {
            contract::set_return(&data);
            0
        }
        None => 1,
    }
}

SDK Host Functions

The lichen-contract-sdk crate (imported as lichen_sdk) exposes the following host functions that the WASM runtime provides to your contract.

Storage

storage::get(key: &[u8]) → Option<Vec<u8>> READ

Read a value from the contract's key-value storage. Returns None if the key does not exist. Buffer size: up to 64 KB.

Parameter Type Description
key &[u8] Storage key bytes

Returns: Option<Vec<u8>> — the stored value or None.

storage::set(key: &[u8], value: &[u8]) → bool WRITE

Write a value to storage. Overwrites any existing value at the key.

Parameter Type Description
key &[u8] Storage key bytes
value &[u8] Value bytes to store

Returns: booltrue on success.

storage::remove(key: &[u8]) → bool DELETE

Remove a key from storage. Internally writes an empty value.

Parameter Type Description
key &[u8] Storage key to delete

Contract Context

contract::args() → Vec<u8> READ

Get the arguments passed to this contract call. The raw byte payload from the transaction.

Returns: Vec<u8> — argument bytes (empty if none).

contract::set_return(data: &[u8]) → bool WRITE

Set the return data for this contract execution. Callers (including cross-contract callers) will receive this data.

Parameter Type Description
data &[u8] Return data bytes

Environment

get_caller() → [u8; 32] READ

Get the 32-byte public key of the account that invoked this contract call. Used for authorization checks.

get_value() → u64 READ

Get the amount of LICN (in spores) transferred along with this contract call. Used for payable functions (e.g., .lichen name registration).

get_timestamp() → u64 READ

Get the current block timestamp (Unix seconds). Useful for time-based logic like expiry checks and vesting schedules.

get_slot() → u64 READ

Get the current slot number. Lichen produces ~2.5 slots/second (400ms per slot). Used for slot-based timing (e.g., name expiry at ~78.8M slots/year).

Events & Logging

event::emit(json_data: &str) → bool EMIT

Emit a structured event as a JSON string. Events are indexed and queryable via RPC. Example:

event::emit(r#"{"name":"Transfer","from":"...","to":"...","amount":1000}"#);
log::info(msg: &str) LOG

Log a message for debugging. Logs are visible in contract execution output and via molt contract logs <address>.

Cryptography

poseidon_hash(left: &[u8; 32], right: &[u8; 32]) → [u8; 32] CRYPTO

Compute a Poseidon hash over two 32-byte field elements on the BN254 curve. This is a SNARK-friendly hash function used for Merkle trees, commitments, and nullifiers in zero-knowledge proof circuits.

Parameter Type Description
left &[u8; 32] Left input (BN254 Fr element, little-endian)
right &[u8; 32] Right input (BN254 Fr element, little-endian)

Returns: [u8; 32] — the Poseidon hash result as a 32-byte little-endian field element. Compute cost: 2,000 CU.

use lichen_sdk::poseidon_hash;

let left = [0u8; 32];  // first field element
let right = [1u8; 32]; // second field element
let hash = poseidon_hash(&left, &right); // 32-byte hash output

Storage Model

Each contract has its own isolated key-value byte store. Both keys and values are arbitrary byte arrays. Common conventions used across Lichen contracts:

Key Naming Conventions

Pattern Example Usage
admin b"admin" 32-byte admin/owner address
id:{hex} b"id:a1b2c3..." Identity record keyed by hex-encoded address
balance:{addr} b"balance:" + addr_bytes Token/NFT balance for an address
name:{lowercase} b"name:tradingbot" .lichen name forward lookup
name_rev:{hex} b"name_rev:a1b2..." .lichen name reverse lookup
allowance:{owner}:{spender} b"allowance:" + owner + ":" + spender Token spending allowance

Value Packing

  • Integers: Stored as little-endian u64 (8 bytes). Use u64_to_bytes() and bytes_to_u64() helpers from the SDK.
  • Addresses: Raw 32-byte public keys.
  • Fixed records: Packed structs at known byte offsets (e.g., LichenID identity = 127 bytes with owner at 0..32, reputation at 99..107).
  • Strings: UTF-8 bytes, often with a length prefix.
Storage Pattern Example
use lichen_sdk::{storage, u64_to_bytes, bytes_to_u64, Address};

// Store a counter
fn increment_counter() {
    let current = storage::get(b"counter")
        .map(|d| bytes_to_u64(&d))
        .unwrap_or(0);
    storage::set(b"counter", &u64_to_bytes(current + 1));
}

// Store per-account data with composite key
fn set_balance(account: Address, amount: u64) {
    let mut key = b"balance:".to_vec();
    key.extend_from_slice(account.to_bytes());
    storage::set(&key, &u64_to_bytes(amount));
}

Cross-Contract Calls

Contracts can invoke functions on other deployed contracts using the crosscall module. This enables composability — for example, checking a caller's LichenID reputation before granting access.

call_contract(call: CrossCall) → CallResult<Vec<u8>> CALL

Execute a cross-contract call. The CrossCall struct specifies the target address, function name, arguments, and optional value transfer.

use lichen_sdk::crosscall::{CrossCall, call_contract};
use lichen_sdk::Address;

let target = Address::new(contract_addr_bytes);
let call = CrossCall::new(target, "get_identity", caller_pubkey.to_vec())
    .with_value(0); // optional LICN transfer

match call_contract(call) {
    Ok(data) => { /* process return data */ }
    Err(e) => { /* handle error */ }
}

Helper Functions

call_token_transfer(token, from, to, amount) → CallResult<bool>

Transfer tokens on a token contract. Packs from/to addresses and amount into the args automatically.

call_token_balance(token, account) → CallResult<u64>

Query the token balance of an account. Calls balance_of on the target token contract and decodes the u64 result.

call_nft_transfer(nft, from, to, token_id) → CallResult<bool>

Transfer an NFT by token ID between addresses.

call_nft_owner(nft, token_id) → CallResult<Address>

Query the current owner of an NFT by token ID.

LichenID Integration Pattern

Identity Gate Example
use lichen_sdk::crosscall::{CrossCall, call_contract};
use lichen_sdk::{Address, bytes_to_u64};

const LICHENID_ADDRESS: [u8; 32] = [/* well-known address */];
const MIN_REPUTATION: u64 = 500;

fn require_reputation(caller: &[u8]) -> bool {
    let lichenid = Address::new(LICHENID_ADDRESS);
    let call = CrossCall::new(lichenid, "get_reputation", caller.to_vec());
    match call_contract(call) {
        Ok(data) if data.len() >= 8 => {
            bytes_to_u64(&data) >= MIN_REPUTATION
        }
        _ => false,
    }
}

Token Module (MT-20)

The lichen_sdk::token::Token struct implements the MT-20 token standard (similar to ERC-20). It provides mint, burn, transfer, and allowance functionality backed by contract storage.

Token::new(name, symbol, decimals) → Token

Create a new token definition. Call initialize() afterwards to set initial supply.

initialize(&mut self, initial_supply, owner) → ContractResult<()>

Set total supply and mint all tokens to the owner address.

balance_of(&self, account) → u64

Get the token balance for an account. Storage key: balance:{address_bytes}.

transfer(&self, from, to, amount) → ContractResult<()>

Transfer tokens between addresses. Returns InsufficientFunds if balance is too low.

mint(&mut self, to, amount, caller, owner) → ContractResult<()>

Mint new tokens. Only the token owner can mint. Increases total_supply.

burn(&mut self, from, amount) → ContractResult<()>

Burn tokens from an address. Decreases total_supply.

approve(&self, owner, spender, amount) → ContractResult<()>

Approve a spender to transfer up to amount from the owner's balance. Storage key: allowance:{owner}:{spender}.

transfer_from(&self, caller, from, to, amount) → ContractResult<()>

Transfer using an approved allowance. Decrements the allowance by amount.

allowance(&self, owner, spender) → u64

Get the current spending allowance for a spender on an owner's tokens.

get_total_supply(&self) → u64

Read total supply from persistent storage.

NFT Module (MT-721)

The lichen_sdk::nft::NFT struct implements the MT-721 NFT standard (similar to ERC-721). Each token has a unique ID, owner, and optional metadata URI.

NFT::new(name, symbol) → NFT

Create a new NFT collection.

initialize(&mut self, minter) → NftResult<()>

Initialize the NFT collection, setting the authorized minter address.

mint(&mut self, to, token_id, metadata_uri) → NftResult<()>

Mint a new NFT with the given token ID and metadata URI. Fails if the ID already exists.

transfer(&self, from, to, token_id) → NftResult<()>

Transfer an NFT. Only the current owner can transfer.

owner_of(&self, token_id) → NftResult<Address>

Get the current owner of a token.

balance_of(&self, owner) → u64

Get the number of NFTs owned by an address.

approve(&self, owner, spender, token_id) → NftResult<()>

Approve a specific spender for a single token.

set_approval_for_all(&self, owner, operator, approved) → NftResult<()>

Approve or revoke an operator for all of the owner's tokens.

transfer_from(&self, caller, from, to, token_id) → NftResult<()>

Transfer using an approval (single token or operator). Clears the single-token approval after transfer.

burn(&mut self, owner, token_id) → NftResult<()>

Burn an NFT. Clears owner, metadata, and approval entries.

get_total_minted(&self) → u64

Get the total number of NFTs ever minted in this collection.

DEX Module (AMM)

The lichen_sdk::dex::Pool implements a constant-product AMM (x × y = k) with a configurable fee (default 0.3%). Used by the LichenSwap contract.

Pool::new(token_a, token_b) → Pool

Create a new liquidity pool for a token pair. Default fee: 3/1000 (0.3%).

initialize(&mut self, token_a, token_b) → DexResult<()>

Initialize the pool and persist to storage.

add_liquidity(&mut self, provider, amount_a, amount_b, min_liquidity) → DexResult<u64>

Add liquidity to the pool. First provider: LP tokens = √(amount_a × amount_b). Subsequent: proportional to reserves.

Returns: Number of LP tokens minted.

remove_liquidity(&mut self, provider, liquidity, min_a, min_b) → DexResult<(u64, u64)>

Remove liquidity by burning LP tokens. Returns (amount_a, amount_b) withdrawn.

swap_a_for_b(&mut self, amount_a_in, min_b_out) → DexResult<u64>

Swap token A for token B using the constant product formula with fee deduction. Returns the amount of token B received.

swap_b_for_a(&mut self, amount_b_in, min_a_out) → DexResult<u64>

Swap token B for token A. Symmetric to swap_a_for_b.

get_amount_out(&self, amount_in, reserve_in, reserve_out) → u64

Calculate the output amount for a given input, accounting for the fee. Pure function (no state change).

get_liquidity_balance(&self, provider) → u64

Get the LP token balance for a liquidity provider.

Constant Product Formula

Output = (amount_in × (1 − fee) × reserve_out) / (reserve_in + amount_in × (1 − fee))

With default 0.3% fee: fee_numerator = 3, fee_denominator = 1000.

Testing

The SDK includes a test_mock module that provides thread-local mock storage, allowing you to unit-test contracts on your host machine without compiling to WASM.

Mock Functions

Function Description
test_mock::reset() Clear all mock state (storage, caller, args, events, logs, timestamp, value, slot)
test_mock::set_caller(addr) Set the mock caller address (32 bytes)
test_mock::set_args(data) Set the mock contract arguments
test_mock::set_value(val) Set the mock LICN value sent with the call
test_mock::set_timestamp(ts) Set the mock block timestamp
test_mock::set_slot(s) Set the mock slot number
test_mock::get_return_data() Get the data set via contract::set_return()
test_mock::get_events() Get all emitted events
test_mock::get_storage(key) Read directly from mock storage
test_mock::get_logs() Get all logged messages
Full Test Example
#[cfg(test)]
mod tests {
    use super::*;
    use lichen_sdk::test_mock;

    #[test]
    fn test_initialize_and_set_value() {
        test_mock::reset();

        // Set up mock caller
        let admin = [1u8; 32];
        test_mock::set_caller(admin);

        // Initialize contract
        let result = initialize(admin.as_ptr());
        assert_eq!(result, 0, "initialize should succeed");

        // Verify admin was stored
        let stored = test_mock::get_storage(b"admin");
        assert_eq!(stored, Some(admin.to_vec()));

        // Set a value
        let value_bytes = 42u64.to_le_bytes();
        test_mock::set_args(&value_bytes);
        let result = set_value();
        assert_eq!(result, 0);

        // Read it back
        let result = get_value();
        assert_eq!(result, 0);
        let returned = test_mock::get_return_data();
        assert_eq!(returned, value_bytes.to_vec());

        // Check logs
        let logs = test_mock::get_logs();
        assert!(logs.iter().any(|l| l.contains("initialized")));
    }
}

Build & Deploy

Build

Compile your contract to WASM:

Terminal
# Install the WASM target (one-time)
rustup target add wasm32-unknown-unknown

# Build in release mode
cargo build --target wasm32-unknown-unknown --release

# Output location:
# target/wasm32-unknown-unknown/release/my_contract.wasm

Optimize (Optional)

Terminal
# Reduce WASM size with wasm-opt
wasm-opt -Oz -o optimized.wasm \
  target/wasm32-unknown-unknown/release/my_contract.wasm

Deploy

Terminal
# Deploy with full symbol registration (recommended)
molt deploy target/wasm32-unknown-unknown/release/my_contract.wasm \
    --symbol MYTK --name "My Token" --template token --decimals 9 \
    --supply 1000000 --metadata '{"website":"https://example.com","logo":"https://example.com/logo.png"}'

# Deploy with just symbol + name (minimum for explorer visibility)
molt deploy target/wasm32-unknown-unknown/release/my_contract.wasm \
    --symbol MYTK --name "My Token" --template token --decimals 9

# Deploy without registration (can register later)
molt deploy target/wasm32-unknown-unknown/release/my_contract.wasm

# Register an already-deployed contract retroactively
molt contract register <address> --symbol MYTK --name "My Token" --template token

# Output:
# 🦐 Deploying contract: my_contract.wasm
# 📦 Size: 45 KB
# 📍 Contract address: 7xK9...mN2q
# ✅ Contract deployed and verified on-chain!
# 🏷️  Symbol 'MYTK' registered in symbol registry
# 📝 Signature: 3hF8...pQ7r
Deploy Flags Reference
  • --symbol — Token ticker (e.g. MYTK). Registered in the on-chain symbol registry.
  • --name — Human-readable name (e.g. "My Token"). Shown in explorer and wallet.
  • --template — Contract type: token, nft, defi, dex, governance, wrapped, bridge, oracle, lending, marketplace, auction, identity, launchpad, vault, payments, etc.
  • --decimals — Token decimal places (default: 9 for LICN-compatible).
  • --supply — Initial total supply in whole tokens (auto-converted to spores via decimals).
  • --metadata — JSON object with arbitrary metadata (website, logo, description, etc.).

Fee: 25.001 LICN (25 LICN deploy premium + 0.001 LICN base fee). If deploy fails, the 25 LICN premium is automatically refunded.

Auto-register: If the symbol doesn't appear in the registry after deploy, the CLI automatically sends a fallback register-symbol transaction.

Call

Terminal
# Call a function on the deployed contract
molt call 7xK9...mN2q initialize '[]'

# Call with arguments
molt call 7xK9...mN2q set_value --args '[42]'

Security Best Practices

1. Reentrancy Guards

Cross-contract calls are synchronous and depth-bounded, but contracts should still guard sensitive state transitions around external calls with a storage-based mutex pattern:

Reentrancy Guard
fn enter_guard() -> bool {
    if storage::get(b"_locked").is_some() {
        return false; // already locked
    }
    storage::set(b"_locked", &[1]);
    true
}

fn exit_guard() {
    storage::remove(b"_locked");
}

#[no_mangle]
pub extern "C" fn sensitive_operation() -> u32 {
    if !enter_guard() { return 99; } // reentrancy blocked

    // ... do work, cross-contract calls, etc.

    exit_guard();
    0
}

2. Overflow Protection

Always use saturating_add and saturating_sub for arithmetic. Lichen contracts run in #![no_std] where overflow panics abort the WASM instance.

Safe Arithmetic
//  BAD: can overflow/underflow
let new_balance = balance + amount;
let new_balance = balance - amount;

//  GOOD: saturating arithmetic
let new_balance = balance.saturating_add(amount);
let new_balance = balance.saturating_sub(amount);

//  GOOD: explicit bounds check
if balance < amount {
    return Err(ContractError::InsufficientFunds);
}
let new_balance = balance - amount;

3. Admin Authentication

Always verify the caller is the authorized admin before state-changing operations:

Auth Pattern
fn require_admin(caller: &[u8]) -> bool {
    match storage::get(b"admin") {
        Some(admin) => caller == admin.as_slice(),
        None => false,
    }
}

#[no_mangle]
pub extern "C" fn admin_only_function(caller_ptr: *const u8) -> u32 {
    let caller = unsafe { core::slice::from_raw_parts(caller_ptr, 32) };
    if !require_admin(caller) {
        log::info("Unauthorized");
        return 2;
    }
    // ... proceed
    0
}

4. Input Validation

  • Always check argument lengths before reading from raw pointers.
  • Validate string lengths against maximums (e.g., name ≤ 64 bytes, URL ≤ 256 bytes).
  • Check enum/type values are within valid ranges.
  • Verify addresses are not zero ([0u8; 32]) when that's meaningful.

5. Storage Key Isolation

  • Use unique prefixes for different data types to avoid collisions (e.g., balance:, allowance:, owner:).
  • Include separators (:) between key components to prevent ambiguity.
  • Hex-encode addresses in keys for consistent length and readability.