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:
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,--templateto 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
--wasmflag 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:
[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
molt contract logs <address>.
Cryptography
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()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.
LichenID Integration Pattern
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 token definition. Call initialize() afterwards to set initial
supply.
Set total supply and mint all tokens to the owner address.
Get the token balance for an account. Storage key: balance:{address_bytes}.
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: allowance:{owner}:{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)
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
--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
# 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:
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.