Skip to content

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 #![no_std] (contracts run in a WASM sandbox with no standard library)
  • Import alloc for heap allocations (String, Vec, etc.)
  • Use dilithia_sdk::prelude::* to bring in Ctx, Value, json!, and all SDK helpers
  • Every public function inside the #[contract] module becomes a callable method
  • Methods receive a Ctx reference (mutable for state-changing, immutable for read-only) and a Value containing the JSON arguments
  • Methods return Result<Value, String> -- the Ok value is returned to the caller, the Err string 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:

ctx.emit("transfer", json!({
    "from": sender,
    "to": recipient,
    "amount": amount
}));

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:

let valid = ctx.verify_sig_multi("mldsa65", message, signature, public_key);

Messaging

Send a cross-chain or off-chain message:

let sent = ctx.send_message("destination_chain", payload);

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:

cargo test

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.