Smart Contract Development Guide
Overview
MoltChain 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 moltchain-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 - Return codes:
0= success, non-zero = error
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]
moltchain-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 moltchain_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 moltchain-sdk crate 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 MOLT (in shells) transferred along with this contract call. Used for payable functions (e.g., .molt 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. MoltChain produces ~2 slots/second. Used for slot-based timing (e.g., name expiry at ~63M 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>.
Storage Model
Each contract has its own isolated key-value byte store. Both keys and values are arbitrary byte arrays. Common conventions used across MoltChain 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" | .molt name forward lookup |
name_rev:{hex} | b"name_rev:a1b2..." | .molt 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., MoltyID identity = 127 bytes with owner at 0..32, reputation at 99..107).
- Strings: UTF-8 bytes, often with a length prefix.
use moltchain_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 MoltyID reputation before granting access.
Execute a cross-contract call. The CrossCall struct specifies the target address, function name, arguments, and optional value transfer.
use moltchain_sdk::crosscall::{CrossCall, call_contract};
use moltchain_sdk::Address;
let target = Address::new(contract_addr_bytes);
let call = CrossCall::new(target, "get_identity", caller_pubkey.to_vec())
.with_value(0); // optional MOLT 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.
MoltyID Integration Pattern
use moltchain_sdk::crosscall::{CrossCall, call_contract};
use moltchain_sdk::{Address, bytes_to_u64};
const MOLTYID_ADDRESS: [u8; 32] = [/* well-known address */];
const MIN_REPUTATION: u64 = 500;
fn require_reputation(caller: &[u8]) -> bool {
let moltyid = Address::new(MOLTYID_ADDRESS);
let call = CrossCall::new(moltyid, "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 moltchain_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 moltchain_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 moltchain_sdk::dex::Pool implements a constant-product AMM (x × y = k) with a configurable fee (default 0.3%). Used by the MoltSwap 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 MOLT 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 moltchain_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 to the network
molt deploy target/wasm32-unknown-unknown/release/my_contract.wasm
# Output:
# Deploying contract: my_contract.wasm
# Size: 45 KB
# Contract address: 7xK9...mN2q
# Contract deployed!
# Signature: 3hF8...pQ7r
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 can be re-entered. Use 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. MoltChain 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.