Writing Contracts¶
This guide covers the structure of a Dilithia smart contract, the APIs available through the Ctx context object, and how to test contracts locally.
Contract Structure¶
Every Dilithia contract is a Rust cdylib crate compiled to WASM. The vendored dilithia-sdk crate generated by dilithia-contract init provides the #[contract] attribute macro that generates the WASM entry points.
#![no_std]
extern crate alloc;
use dilithia_sdk::prelude::*;
#[contract]
mod my_contract {
use super::*;
pub fn my_method(ctx: &mut Ctx, args: Value) -> Result<Value, String> {
// ...
Ok(json!({ "ok": true }))
}
}
Key rules:
- The crate must be
# - Import
allocfor heap allocations (String,Vec, etc.) - Use
dilithia_sdk::prelude::*to bring inCtx,Value,json!, and all SDK helpers - Every public function inside the
#[contract]module becomes a callable method - Methods receive a
Ctxreference (mutable for state-changing, immutable for read-only) and aValuecontaining the JSON arguments - Methods return
Result<Value, String>-- theOkvalue is returned to the caller, theErrstring becomes a revert reason
Storage API¶
The Ctx object provides persistent key-value storage:
// Write a value (any JSON-serializable type)
ctx.storage_set("total_supply", json!(1000000));
// Read a value
let supply: Option<Value> = ctx.storage_get("total_supply");
// Delete a key (set to empty string)
ctx.storage_set("deprecated_key", "");
Storage keys are strings. Values are JSON (serde_json::Value). Storage is scoped to the contract -- different contracts cannot read each other's storage directly.
Events¶
Emit events to create an on-chain log entry that clients can subscribe to:
Events have a name (string) and a payload (JSON value). They are included in the transaction receipt and can be filtered by name when querying the node.
Cross-Contract Calls¶
Contracts can call methods on other deployed contracts:
// State-changing call to another contract (requires mutable Ctx)
let result = ctx.call_contract("other_contract", "some_method", json!({
"arg1": "value1"
}))?;
// Read-only query (works with immutable Ctx)
let data = ctx.query_contract("other_contract", "get_data", json!({
"key": "some_key"
}))?;
Reentrancy
Cross-contract calls can call back into your contract. Design your storage updates to be safe against reentrancy -- update state before making external calls when possible.
Token Operations¶
Built-in helpers for working with the native token:
// Get the balance of an address
let bal = ctx.balance_of("0xabc...");
// Transfer native tokens from the contract to an address
let ok = ctx.transfer("0xabc...", 1000);
Advanced APIs¶
Signature Verification¶
Verify a signature using a supported scheme name:
Messaging¶
Send a cross-chain or off-chain message:
Gas Introspection¶
Check remaining gas to guard expensive operations:
let remaining = ctx.gas_remaining();
if remaining < 100_000 {
return Err("insufficient gas for this operation".into());
}
Complete Example: Simple Token Contract¶
A simplified ERC-20-style token contract:
#![no_std]
extern crate alloc;
use dilithia_sdk::prelude::*;
#[contract]
mod simple_token {
use super::*;
/// Initialize the token with a total supply assigned to the deployer.
pub fn init(ctx: &mut Ctx, args: Value) -> Result<Value, String> {
let total_supply = args["total_supply"]
.as_u64()
.ok_or("missing total_supply")?;
let deployer = args["deployer"]
.as_str()
.ok_or("missing deployer")?;
ctx.storage_set("total_supply", json!(total_supply));
ctx.storage_set(&format!("balance:{deployer}"), json!(total_supply));
ctx.emit("initialized", json!({
"total_supply": total_supply,
"deployer": deployer
}));
Ok(json!({ "ok": true }))
}
/// Transfer tokens from the caller to a recipient.
pub fn transfer(ctx: &mut Ctx, args: Value) -> Result<Value, String> {
let from = args["from"].as_str().ok_or("missing from")?;
let to = args["to"].as_str().ok_or("missing to")?;
let amount = args["amount"].as_u64().ok_or("missing amount")?;
// Read balances
let from_bal: u64 = ctx
.storage_get(&format!("balance:{from}"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let to_bal: u64 = ctx
.storage_get(&format!("balance:{to}"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
if from_bal < amount {
return Err("insufficient balance".into());
}
// Update balances
ctx.storage_set(&format!("balance:{from}"), json!(from_bal - amount));
ctx.storage_set(&format!("balance:{to}"), json!(to_bal + amount));
ctx.emit("transfer", json!({
"from": from,
"to": to,
"amount": amount
}));
Ok(json!({ "ok": true }))
}
/// Query the balance of an address (read-only).
pub fn balance(ctx: &Ctx, args: Value) -> Result<Value, String> {
let addr = args["addr"].as_str().ok_or("missing addr")?;
let bal: u64 = ctx
.storage_get(&format!("balance:{addr}"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(json!({ "balance": bal }))
}
/// Query the total supply (read-only).
pub fn total_supply(ctx: &Ctx, _args: Value) -> Result<Value, String> {
let supply: u64 = ctx
.storage_get("total_supply")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(json!({ "total_supply": supply }))
}
}
Testing Contracts¶
The generated contract is still a normal Rust crate, so unit tests should focus on deterministic logic that can be exercised natively:
#[cfg(test)]
mod tests {
fn add(left: u64, right: u64) -> u64 {
left + right
}
#[test]
fn adds_values() {
assert_eq!(add(2, 3), 5);
}
}
Run tests with:
Note
The vendored SDK shipped by contract-tools does not currently provide a MockCtx test harness. Keep unit tests focused on pure logic, and use node-backed or higher-level integration tests when you need to validate storage, events, or cross-contract behavior.