sigil-sdk is the reference implementation. It is transport-agnostic: it builds and signs transactions and resolves on-chain state, but you bring your own HTTP client (reqwest, ureq, anything that speaks JSON). The canonical surface lives at node/sigil-sdk/src/lib.rs.

Install

# Cargo.toml
[dependencies]
sigil-sdk   = { git = "https://github.com/l1feai/sigil", package = "sigil-sdk" }
sigil-core  = { git = "https://github.com/l1feai/sigil", package = "sigil-core" }
ed25519-dalek = "2"
rand          = "0.8"
reqwest       = { version = "0.12", features = ["json", "rustls-tls"] }
serde_json    = "1"
tokio         = { version = "1", features = ["full"] }
anyhow        = "1"
chrono        = "0.4"
Optional features:
FeatureWhat it adds
galOAS resolution and sign-in challenges backed by GAL anchors
gal-httpHttpSigilAnchorClient that calls the typed gal_* JSON-RPC methods
computeCompute-marketplace transaction types

Configuration

SigilClientConfig describes the network you want to talk to. The constructors are mainnet, testnet, and devnet; the builder methods set the timeout and an optional bearer token.
use sigil_sdk::SigilClientConfig;

let cfg = SigilClientConfig::testnet("https://testnet-rpc.sigil.ml/rpc")
    .with_timeout(30);
Recognised chain ids — these are baked in, you do not need to pass them:
Constructorchain_idCurrency
mainnetsigil-mainnet-1MINT
testnetsigil-testnet-1test SIGIL
devnetsigil-devnet-1local-only
Environment-variable convention (used throughout these docs):
export SIGIL_RPC_URL=https://testnet-rpc.sigil.ml/rpc
export SIGIL_CHAIN_ID=sigil-testnet-1
export SIGIL_SIGNING_KEY_HEX=<64-hex-chars>
export SIGIL_SENDER_DID=did:oas:sigil:agent:...

Identity

A Sigil DID is did:oas:sigil:<kind>:<32-byte-blake3-hex> where the suffix is blake3(public_key_bytes). The SDK does not yet provide a did_from_key helper; the four lines below are canonical:
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;

fn new_agent_did() -> (SigningKey, String) {
    let key = SigningKey::generate(&mut OsRng);
    let pubkey = key.verifying_key();
    let addr = blake3::hash(pubkey.as_bytes());
    let did = format!("did:oas:sigil:agent:{}", addr.to_hex());
    (key, did)
}

Loading a key from disk

Persist the 32-byte secret seed only; the public key derives from it.
use ed25519_dalek::SigningKey;
use std::fs;

fn load_key(path: &str) -> anyhow::Result<SigningKey> {
    let bytes = fs::read(path)?;
    let seed: [u8; 32] = bytes
        .try_into()
        .map_err(|b: Vec<u8>| anyhow::anyhow!("expected 32 bytes, got {}", b.len()))?;
    Ok(SigningKey::from_bytes(&seed))
}

Rotating a key

Sigil DIDs are bound to a public key at registration time. To rotate, mint a new DID and migrate balances over with a single Transfer. There is no in-place key-rotation primitive on testnet today.

Reading chain state

The SDK does not bundle an HTTP client — call the JSON-RPC directly. The helper below is the shape used throughout these docs.
use serde::Deserialize;
use serde_json::json;

async fn rpc<T: for<'de> Deserialize<'de>>(
    cfg: &sigil_sdk::SigilClientConfig,
    method: &str,
    params: serde_json::Value,
) -> anyhow::Result<T> {
    let body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": method,
        "params": params,
    });
    let res: serde_json::Value = reqwest::Client::new()
        .post(&cfg.rpc_url)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;
    if let Some(err) = res.get("error") {
        anyhow::bail!("RPC {method} failed: {err}");
    }
    Ok(serde_json::from_value(res["result"].clone())?)
}

Balance, nonce, and identity

#[derive(serde::Deserialize)]
struct BalanceResult {
    did: String,
    balance: u64,
    nonce: u64,
}

let balance: BalanceResult = rpc(&cfg, "sigil_getBalance",
    serde_json::json!({ "did": &sender_did })).await?;
println!("{} has {} micro-MINT (nonce {})", balance.did, balance.balance, balance.nonce);

Latest block

let block: serde_json::Value = rpc(&cfg, "sigil_getLatestBlock",
    serde_json::json!({})).await?;
println!("height = {}", block["height"]);
The full method index is in API reference → JSON-RPC.

Signing and submitting transactions

The TransactionBuilder covers staking, governance, and contract registration. For Transfer, token, NFT, DEX, mail, and other primitives you construct Transaction { ... } directly with the matching TransactionType variant from sigil_core::transaction.

Using the builder (staking, gov, contracts)

use sigil_sdk::{sign_transaction, TransactionBuilder};

let tx = TransactionBuilder::new("sigil-testnet-1", &sender_did)
    .nonce(7)
    .gas_limit(300_000)
    .gas_price(2)
    .stake(10_000, "producer")?;   // 10,000 micro-MINT, block-producer mode

let signed = sign_transaction(tx, &signing_key)?;
Available builder methods (from builder.rs):
  • register_identity(did, document, conformance_level)
  • stake(amount, mode)mode is "producer" or "validator"
  • unstake(amount)
  • delegate(validator_did, amount)
  • undelegate(validator_did, shares)
  • claim_rewards()
  • propose(title, description, proposal_type, payload, deposit)
  • vote(proposal_id, choice)choice is "yes" | "no" | "abstain" | "no_with_veto"
  • register_contract_code(code, license_metadata)
  • deploy_contract(contract_did, controller_did, code_hash, zone_id)

Building a raw transaction (Transfer, tokens, NFTs, DEX, …)

use chrono::Utc;
use sigil_core::transaction::{TransferData, TransactionType};
use sigil_core::Transaction;
use sigil_sdk::sign_transaction;

let tx = Transaction {
    chain_id: "sigil-testnet-1".to_string(),
    sender_did: sender_did.clone(),
    nonce,
    gas_limit: 200_000,
    gas_price: 1,
    tx_type: TransactionType::Transfer(TransferData {
        recipient_did: recipient_did.to_string(),
        amount: 1_000_000,                       // 1 test SIGIL
        memo: Some("invoice 42".into()),
    }),
    timestamp: Utc::now(),
};

let signed = sign_transaction(tx, &signing_key)?;
Every TransactionType::*Data struct lives in sigil-core/src/transaction.rs. See the cookbook for runnable examples per primitive.

Submitting

let body = serde_json::json!({
    "jsonrpc": "2.0",
    "id": 1,
    "method": "sigil_sendTransaction",
    "params": signed,
});
let res: serde_json::Value = reqwest::Client::new()
    .post(&cfg.rpc_url)
    .json(&body)
    .send()
    .await?
    .json()
    .await?;
let tx_hash = res["result"]["tx_hash"].as_str()
    .ok_or_else(|| anyhow::anyhow!("missing tx_hash"))?;

Waiting for finality

Sigil finalises every block in one MACA round (~6 s). Poll sigil_getTransactionReceipt until status != "pending".
use tokio::time::{sleep, Duration};

async fn wait_receipt(
    cfg: &sigil_sdk::SigilClientConfig,
    tx_hash: &str,
) -> anyhow::Result<serde_json::Value> {
    for _ in 0..20 {
        let receipt: Option<serde_json::Value> = rpc(
            cfg,
            "sigil_getTransactionReceipt",
            serde_json::json!({ "tx_hash": tx_hash }),
        )
        .await
        .ok();
        if let Some(r) = receipt {
            if r["status"] != "pending" {
                return Ok(r);
            }
        }
        sleep(Duration::from_millis(1_500)).await;
    }
    anyhow::bail!("receipt for {tx_hash} did not finalise in 30s")
}

Subscribing to events

WebSocket subscriptions are on the roadmap. For now, poll sigil_getLatestBlock and follow the height monotonically:
let mut last = 0u64;
loop {
    let block: serde_json::Value = rpc(&cfg, "sigil_getLatestBlock",
        serde_json::json!({})).await?;
    let h = block["height"].as_u64().unwrap_or(0);
    if h > last {
        last = h;
        // process txs from block...
    }
    tokio::time::sleep(std::time::Duration::from_millis(1_500)).await;
}
For finalised blocks specifically, the SDK’s gal feature exposes a FinalizedBlockSource trait (see gal_adapter.rs). It’s intended for systems that need certainty under reorgs.

Error handling

The SDK error type is SdkError. It is Send + Sync + 'static and implements std::error::Error, so it composes with anyhow and thiserror.
use sigil_sdk::error::SdkError;

match TransactionBuilder::new(chain_id, sender_did).vote(&proposal_id, &choice) {
    Ok(tx) => { /* ... */ }
    Err(SdkError::TransactionBuildError(msg)) => {
        eprintln!("invalid vote: {msg}");
        // user-input error — surface to the caller
    }
    Err(e) => {
        eprintln!("unexpected: {e}");
    }
}
RPC errors come back as JSON; the helper above bails on result.error. Common error codes are documented in API reference → Errors.

Worked example: send a payment with retry

This is a complete, compilable example that:
  1. Loads the signing key from SIGIL_SIGNING_KEY_HEX.
  2. Fetches the current nonce.
  3. Builds and signs a Transfer.
  4. Submits with exponential backoff on transient errors.
  5. Waits for the receipt.
use anyhow::Context;
use chrono::Utc;
use ed25519_dalek::SigningKey;
use serde::Deserialize;
use serde_json::json;
use sigil_core::transaction::{TransferData, TransactionType};
use sigil_core::Transaction;
use sigil_sdk::{sign_transaction, SigilClientConfig};
use std::env;
use std::time::Duration;
use tokio::time::sleep;

#[derive(Deserialize)]
struct BalanceResult {
    nonce: u64,
}

async fn rpc<T: for<'de> Deserialize<'de>>(
    cfg: &SigilClientConfig,
    method: &str,
    params: serde_json::Value,
) -> anyhow::Result<T> {
    let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params });
    let res: serde_json::Value = reqwest::Client::new()
        .post(&cfg.rpc_url)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;
    if let Some(err) = res.get("error") {
        anyhow::bail!("RPC {method} failed: {err}");
    }
    Ok(serde_json::from_value(res["result"].clone())?)
}

async fn send_payment_with_retry(
    cfg: &SigilClientConfig,
    signing_key: &SigningKey,
    sender_did: &str,
    recipient_did: &str,
    amount_base_units: u64,
) -> anyhow::Result<String> {
    let acct: BalanceResult = rpc(
        cfg,
        "sigil_getBalance",
        json!({ "did": sender_did }),
    )
    .await
    .context("failed to fetch nonce")?;

    let tx = Transaction {
        chain_id: cfg.chain_id.clone(),
        sender_did: sender_did.to_string(),
        nonce: acct.nonce,
        gas_limit: 200_000,
        gas_price: 1,
        tx_type: TransactionType::Transfer(TransferData {
            recipient_did: recipient_did.to_string(),
            amount: amount_base_units,
            memo: None,
        }),
        timestamp: Utc::now(),
    };
    let signed = sign_transaction(tx, signing_key)?;

    let mut delay_ms = 250u64;
    for attempt in 0..5 {
        let body = json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "sigil_sendTransaction",
            "params": &signed,
        });
        let res: serde_json::Value = reqwest::Client::new()
            .post(&cfg.rpc_url)
            .json(&body)
            .send()
            .await?
            .json()
            .await?;

        if let Some(tx_hash) = res["result"]["tx_hash"].as_str() {
            return Ok(tx_hash.to_string());
        }

        let code = res["error"]["code"].as_i64().unwrap_or(0);
        // -32603 internal, -32002 mempool full — both retryable.
        if !matches!(code, -32603 | -32002) || attempt == 4 {
            anyhow::bail!("send failed: {}", res["error"]);
        }
        sleep(Duration::from_millis(delay_ms)).await;
        delay_ms *= 2;
    }
    unreachable!()
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cfg = SigilClientConfig::testnet(
        &env::var("SIGIL_RPC_URL")
            .unwrap_or_else(|_| "https://testnet-rpc.sigil.ml/rpc".into()),
    );
    let key_hex = env::var("SIGIL_SIGNING_KEY_HEX")
        .context("set SIGIL_SIGNING_KEY_HEX")?;
    let key_bytes: [u8; 32] = hex::decode(&key_hex)?
        .try_into()
        .map_err(|_| anyhow::anyhow!("signing key must be 32 bytes"))?;
    let signing_key = SigningKey::from_bytes(&key_bytes);

    let sender_did = env::var("SIGIL_SENDER_DID")?;
    let recipient_did = env::var("RECIPIENT_DID")?;
    let amount: u64 = env::var("AMOUNT_BASE_UNITS")?.parse()?;

    let tx_hash = send_payment_with_retry(
        &cfg, &signing_key, &sender_did, &recipient_did, amount,
    )
    .await?;
    println!("submitted: {tx_hash}");
    Ok(())
}

Reference