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.
- 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:
lichen 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,--templateto auto-register in the symbol registry
lichen token create — Token Deploy Shortcut
Convenience wrapper for deploying a token WASM contract with
template="mt20" preset.
- Requires
--wasmwith a compiled standard MT-20 token contract that exportsinitialize,mint,transfer,balance_of, andtotal_supply - Automatically registers in the symbol registry with
template="mt20" - Initializes the token owner, stores full token profile metadata, and can mint the
initial owner supply immediately with
--initial-supply - Use
lichen deployinstead when you need custom init logic or a non-standard contract interface - Same fee as
lichen deploy: 25.001 LICN
lichen 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 |
|---|---|---|
lichen deploy /path/to/contract.wasm --symbol X |
Deploy WASM + register in symbol registry | 25.001 LICN |
lichen token create "Name" SYM --wasm token.wasm |
Deploy standard MT-20 token WASM + register + initialize owner state + optional owner mint | 25.001 LICN |
lichen 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 | Optional template hint. Authoritative capability flags in RPC are derived from the contract ABI. |
burnable |
boolean | Optional template hint. Authoritative capability flags in RPC are derived from the contract ABI. |
decimals |
number | Decimal precision (0–18, default 9) |
lichen deploy /path/to/contract.wasm --symbol X |
Registry metadata accepts flat scalar values only: strings, numbers, and booleans. Nested
objects and arrays are rejected during contract registration. The Explorer, Wallet, and DEX
frontends read logo_url, description, and social links to render
token profiles automatically.
total_supply is not a registry field. For standard MT-20 contracts it is derived from
live on-chain state through the contract's total_supply() export.
Project Setup
Create a new contract project with the following 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:
#![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
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.
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: bool — true on success.
Remove a key from storage. Internally writes an empty value.
| Parameter | Type | Description |
|---|---|---|
key |
&[u8] |
Storage key to delete |
Contract Context
Get the arguments passed to this contract call. The raw byte payload from the transaction.
Returns: Vec<u8> — argument bytes (empty if none).
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 the 32-byte public key of the account that invoked this contract call. Used for authorization checks.
Get the amount of LICN (in spores) transferred along with this contract call. Used for payable functions (e.g., .lichen name registration).
Get the current block timestamp (Unix seconds). Useful for time-based logic like expiry checks and vesting schedules.
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
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 a message for debugging. Logs are visible in contract execution output and via
lichen contract logs <address>.
Cryptography
Compute the runtime's canonical Poseidon2 hash over two 32-byte values. This is the native hash used for Merkle trees, commitments, and nullifiers in the shielded runtime.
| Parameter | Type | Description |
|---|---|---|
left |
&[u8; 32] |
Left 32-byte input |
right |
&[u8; 32] |
Right 32-byte input |
Returns: [u8; 32] — the 32-byte Poseidon2 digest. 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()andbytes_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.
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.
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
Transfer tokens on a token contract. Packs from/to addresses and amount into the args automatically.
Query the token balance of an account. Calls balance_of on the target token
contract and decodes the u64 result.
Transfer an NFT by token ID between addresses.
Query the current owner of an NFT by token ID.
For off-chain apps, prefer first-class SDK helpers when they exist. Today the
JavaScript, Python, and Rust SDKs ship LichenIdClient for identity
registration, profile and metadata reads, skills, vouches, premium-name auctions,
delegate lifecycle, delegated profile writes, recovery approvals, and skill
attestation flows. They also ship SporePayClient for stream creation,
reads, withdrawals, cancellations, transfers, and stats,
LichenSwapClient for pool discovery, quotes, liquidity reads, TWAP/protocol
fee reads, aggregate stats, and the common swap/liquidity writes, and
ThallLendClient for the stable lending flow: deposit, withdraw, borrow,
repay, liquidate, account/protocol reads, interest reads, and stats, plus
SporeVaultClient for vault/user/strategy reads, aggregate stats, and the
common deposit/withdraw/harvest flow. BountyBoard still does not have a first-class
helper, but the base transports expose its aggregate stats method. Most other contracts
still use the generic
callContract(), call_contract(), or
Client::call_contract() surface plus contract-specific RPC reads.
LichenSwap Off-Chain Example
Use the symbol registry to resolve the deployed program, then either use a first-class helper in the SDK pages or call the contract directly with JSON ABI arguments when you need an uncovered path.
lichen --rpc-url https://testnet-rpc.lichen.network --output json symbol lookup LICHENSWAP
# Replace <LICHENSWAP_ADDRESS> with the program from the lookup result.
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <LICHENSWAP_ADDRESS> get_quote --args '[1000000000, true]'
curl -X POST https://testnet-rpc.lichen.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLichenSwapStats","params":[]}'
ThallLend Off-Chain Example
Use the first-class helper when you want the standard lending flow. When you need the raw contract
surface, resolve LEND through the symbol registry first, then use the contract and
public stats routes directly.
lichen --rpc-url https://testnet-rpc.lichen.network --output json symbol lookup LEND
# Replace <LEND_ADDRESS> with the program from the lookup result.
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <LEND_ADDRESS> get_protocol_stats
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <LEND_ADDRESS> get_interest_rate
curl -X POST https://testnet-rpc.lichen.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getThallLendStats","params":[]}'
SporeVault Off-Chain Example
Use the first-class helper when you want the standard vault flow. When you need admin or raw
contract coverage, resolve SPOREVAULT once, then use readonly calls for vault- and
strategy-level reads and the public stats route for aggregate monitoring.
lichen --rpc-url https://testnet-rpc.lichen.network --output json symbol lookup SPOREVAULT
# Replace <SPOREVAULT_ADDRESS> with the program from the lookup result.
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <SPOREVAULT_ADDRESS> get_vault_stats
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <SPOREVAULT_ADDRESS> get_strategy_info --args '[0]'
curl -X POST https://testnet-rpc.lichen.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getSporeVaultStats","params":[]}'
BountyBoard Off-Chain Example
Given first-class helper client coverage, resolve BOUNTY once, then use
BountyBoardClient for bounty reads, stats, and the full create/submit/approve/cancel
lifecycle. CLI and raw RPC calls remain available for admin paths:
lichen --rpc-url https://testnet-rpc.lichen.network --output json symbol lookup BOUNTY
# Replace <BOUNTY_ADDRESS> with the program from the lookup result.
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <BOUNTY_ADDRESS> get_bounty_count
lichen --rpc-url https://testnet-rpc.lichen.network --output json \
call <BOUNTY_ADDRESS> get_platform_stats
curl -X POST https://testnet-rpc.lichen.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getBountyBoardStats","params":[]}'
LichenID Integration Pattern
The example below is the raw cross-contract path for contracts running on-chain. Wallets, bots,
and backend services should use LichenIdClient instead of hand-packing this ABI when
the helper already covers the flow.
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.
Create a new MT-20 token definition. The prefix namespaces storage keys like
{prefix}_bal_*, {prefix}_alw_*, and
{prefix}_supply. Call initialize() afterwards to set the initial
supply.
Set total supply and mint all tokens to the owner address.
Get the token balance for an account. Storage key:
{prefix}_bal_{hex_address}.
Transfer tokens between addresses. Returns InsufficientFunds if balance is too
low.
Mint new tokens. Only the token owner can mint. Increases total_supply.
Burn tokens from an address. Decreases total_supply.
Approve a spender to transfer up to amount from the owner's balance. Storage
key: {prefix}_alw_{hex_owner}_{hex_spender}.
Transfer using an approved allowance. Decrements the allowance by amount.
Get the current spending allowance for a spender on an owner's tokens.
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.
Create a new NFT collection.
Initialize the NFT collection, setting the authorized minter address.
Mint a new NFT with the given token ID and metadata URI. Fails if the ID already exists.
Transfer an NFT. Only the current owner can transfer.
Get the current owner of a token.
Get the number of NFTs owned by an address.
Approve a specific spender for a single token.
Approve or revoke an operator for all of the owner's tokens.
Transfer using an approval (single token or operator). Clears the single-token approval after transfer.
Burn an NFT. Clears owner, metadata, and approval entries.
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.
Create a new liquidity pool for a token pair. Default fee: 3/1000 (0.3%).
Initialize the pool and persist to storage.
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 by burning LP tokens. Returns (amount_a, amount_b) withdrawn.
Swap token A for token B using the constant product formula with fee deduction. Returns the amount of token B received.
Swap token B for token A. Symmetric to swap_a_for_b.
Calculate the output amount for a given input, accounting for the fee. Pure function (no state change).
Get the LP token balance for a liquidity provider.
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 |
#[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:
# 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)
# Reduce WASM size with wasm-opt
wasm-opt -Oz -o optimized.wasm \
target/wasm32-unknown-unknown/release/my_contract.wasm
Deploy
# Deploy with full symbol registration (recommended)
lichen deploy target/wasm32-unknown-unknown/release/my_contract.wasm \
--symbol MYTK --name "My Token" --template mt20 --decimals 9 \
--metadata '{"website":"https://example.com","logo_url":"https://example.com/logo.png","description":"Community token"}'
# Standard MT-20 launch with owner mint and token profile metadata
lichen token create "My Token" MYTK \
--wasm target/wasm32-unknown-unknown/release/mt20_token.wasm \
--decimals 9 --initial-supply 1000000 \
--website https://example.com --logo-url https://example.com/logo.png
# Deploy with just symbol + name (minimum for explorer visibility)
lichen deploy target/wasm32-unknown-unknown/release/my_contract.wasm \
--symbol MYTK --name "My Token" --template mt20 --decimals 9
# Deploy without registration (can register later)
lichen deploy target/wasm32-unknown-unknown/release/my_contract.wasm
# Register an already-deployed contract retroactively
lichen contract register <address> --symbol MYTK --name "My Token" --template mt20
# 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
--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).--metadata— JSON object with arbitrary metadata (website, logo, description, etc.).
Initial supply: lichen deploy no longer has a metadata-only
--supply path. For standard MT-20 launches, use
lichen token create --initial-supply so the owner actually receives minted
tokens on-chain.
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
# Call a function on the deployed contract
lichen call 7xK9...mN2q initialize --args '["AdminPubkey..."]'
# Call with arguments
lichen 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:
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.
// 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:
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.