roda-ledger lets you extend the transaction set at runtime with sandboxed WebAssembly functions. A registered function becomes a first-class Operation::Function: it is sequenced, executed atomically by the Transactor, and produces normal TxEntry records in the WAL alongside every other transaction.
This document covers everything about writing, registering, invoking, and operating programmable functions: ABI, lifecycle, storage layout, recovery guarantees, and gRPC surface.
The design is specified in ADR-014.
- When to use it
- Function ABI
- Host API
- Return values & rollback
- Writing a function
- Registering a function
- Invoking a function
- Listing & unregistering
- Versioning
- Storage layout on disk
- Durability & recovery
- Determinism & isolation
- Performance
- Limits & validation
- Troubleshooting
Use a function when the built-in operations aren't enough:
- Fee splits — deduct a fee to one account, forward the net to another, in one atomic transaction.
- Multi-leg settlements — move value between three or more accounts with custom rules (e.g. escrow release + platform cut + counterparty payout).
- Conditional logic — decline the transaction when a business rule fails (insufficient collateral, failed KYC flag, overdraft policy).
- Accounting templates — reusable "compliance check", "tax withholding", "loyalty accrual" rules shared across submitters.
If your operation is expressible as one of Deposit, Withdrawal, or Transfer, use those — they are faster and need no registration step.
Every registered function must export one symbol named execute with a fixed signature:
execute(i64, i64, i64, i64, i64, i64, i64, i64) -> i32
- Exactly 8
i64positional parameters. Unused slots are conventionally passed as0. - Exactly 1
i32return value, interpreted as au8status code:0— success (the transaction's host-side credits / debits are committed);1..=127— standard failure reasons (defined by the ledger, full rollback);128..=255— user-defined custom reasons (full rollback).
The fixed arity eliminates any need for linear memory pointer / length passing: account ids, amounts, rates, flags, timestamps all fit into 8 i64 slots.
A function can only call three host imports. All three live in the ledger module:
(import "ledger" "credit" (func (param i64 i64)))
(import "ledger" "debit" (func (param i64 i64)))
(import "ledger" "get_balance" (func (param i64) (result i64)))
| Host call | Signature | Effect |
|---|---|---|
credit(account_id, amount) |
(u64, u64) -> () |
Decreases account_id's balance by amount. Cannot fail individually. |
debit(account_id, amount) |
(u64, u64) -> () |
Increases account_id's balance by amount. Cannot fail individually. |
get_balance(account_id) |
(u64) -> i64 |
Reads the current balance (signed). |
Convention in roda-ledger: credit subtracts, debit adds. A Transfer { from, to, amount } is credit(from, amount) + debit(to, amount) — credits move value out of an account, debits move value in. Same convention applies to WASM functions.
Zero-sum invariant. The ledger verifies sum(credits) == sum(debits) after the function returns. If the function emits an unbalanced set of credits / debits, the transaction is rejected and rolled back with Status::ZERO_SUM_VIOLATION.
Overdraft policy. Host credits / debits never fail individually, and the ledger does not enforce negative-balance checks inside functions. If you need overdraft protection, call get_balance and return a non-zero status to trigger rollback:
if get_balance(sender) < amount as i64 {
return 1; // INSUFFICIENT_FUNDS
}No randomness, no wall clock, no I/O, no WASM threads, no atomics. The ABI is deliberately small so every execution is deterministic.
The execute return value is the transaction's final status:
0— the host-side credits and debits become part of the WAL transaction; the zero-sum invariant is verified; if it holds, the transaction commits.- any non-zero value — the transaction is fully rolled back: every credit / debit the function applied is reversed in the same way a failed built-in operation would be. The
TxMetadatarecord is still written, tagged with the function's CRC32C, and itsstatusfield carries the returnedu8so auditors can distinguish unsuccessful named operations by exact reason.
Host failures (linking, instantiation, traps) surface as Status::INVALID_OPERATION (numeric 5).
Standard status values used by the ledger:
| Value | Name |
|---|---|
0 |
NONE (success) |
1 |
INSUFFICIENT_FUNDS |
2 |
ACCOUNT_NOT_FOUND |
3 |
ZERO_SUM_VIOLATION |
4 |
ENTRY_LIMIT_EXCEEDED |
5 |
INVALID_OPERATION |
6 |
ACCOUNT_LIMIT_EXCEEDED |
7 |
DUPLICATE |
8..=127 |
reserved for future standard reasons |
128..=255 |
user-defined |
Returning 128..=255 is how functions report domain-specific errors without polluting the standard status namespace.
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "z"
lto = true
strip = true// src/lib.rs
#[link(wasm_import_module = "ledger")]
unsafe extern "C" {
fn credit(account_id: u64, amount: u64);
fn debit(account_id: u64, amount: u64);
fn get_balance(account_id: u64) -> i64;
}
/// Transfer `amount` from `from` to `to`, taking a `fee_bps` (basis
/// points) fee into `fee_acct`.
///
/// Params:
/// param0: sender
/// param1: receiver
/// param2: fee_acct
/// param3: amount
/// param4: fee_bps (e.g. 50 = 0.5%)
#[unsafe(no_mangle)]
pub extern "C" fn execute(
param0: i64, param1: i64, param2: i64, param3: i64,
param4: i64, _p5: i64, _p6: i64, _p7: i64,
) -> i32 {
let sender = param0 as u64;
let receiver = param1 as u64;
let fee_acct = param2 as u64;
let amount = param3 as u64;
let fee_bps = param4 as u64;
let fee = amount * fee_bps / 10_000;
unsafe {
if get_balance(sender) < (amount + fee) as i64 {
return 1; // INSUFFICIENT_FUNDS
}
credit(sender, amount + fee);
debit(receiver, amount);
debit(fee_acct, fee);
}
0
}Compile:
cargo build --target wasm32-unknown-unknown --release
# produces target/wasm32-unknown-unknown/release/<crate>.wasm// assembly/index.ts
@external("ledger", "credit")
declare function credit(account_id: u64, amount: u64): void;
@external("ledger", "debit")
declare function debit(account_id: u64, amount: u64): void;
@external("ledger", "get_balance")
declare function get_balance(account_id: u64): i64;
export function execute(
param0: i64, param1: i64, param2: i64, param3: i64,
param4: i64, _p5: i64, _p6: i64, _p7: i64,
): i32 {
const sender = <u64>param0;
const receiver = <u64>param1;
const feeAcct = <u64>param2;
const amount = <u64>param3;
const feeBps = <u64>param4;
const fee = amount * feeBps / 10_000;
if (get_balance(<i64>sender) < <i64>(amount + fee)) {
return 1; // INSUFFICIENT_FUNDS
}
credit(sender, amount + fee);
debit(receiver, amount);
debit(feeAcct, fee);
return 0;
}Compile:
asc assembly/index.ts -o build/function.wasm --optimize --runtime stubUseful for minimal-size tests and examples. Every WAT snippet in the test suite follows this template:
(module
(import "ledger" "credit" (func $credit (param i64 i64)))
(import "ledger" "debit" (func $debit (param i64 i64)))
(import "ledger" "get_balance" (func $get_balance (param i64) (result i64)))
(func (export "execute")
(param i64 i64 i64 i64 i64 i64 i64 i64) (result i32)
;; credit(param0, param1)
local.get 0 local.get 1 call $credit
;; debit(param2, param1)
local.get 2 local.get 1 call $debit
i32.const 0))Compile via the wat crate (already a dependency in test / bench paths):
let bytes = wat::parse_str(WAT)?;Registration is atomic and durable: it returns only after the WAL record has been committed and the handler is loaded into the live runtime. The next Operation::Function { name } is guaranteed to see it.
rpc RegisterFunction(RegisterFunctionRequest) returns (RegisterFunctionResponse);
rpc UnregisterFunction(UnregisterFunctionRequest) returns (UnregisterFunctionResponse);
rpc ListFunctions(ListFunctionsRequest) returns (ListFunctionsResponse);
message RegisterFunctionRequest {
string name = 1; // snake_case, max 32 bytes
bytes binary = 2; // WASM binary, max 4 MB
bool override_existing = 3; // false → ALREADY_EXISTS if name is taken
}
message RegisterFunctionResponse {
uint32 version = 1; // monotonic per name, starts at 1
uint32 crc32c = 2; // CRC32C of the binary
}Example with grpcurl:
grpcurl -d "$(jq -n --arg bin "$(base64 -w0 function.wasm)" \
'{name:"fee_transfer", binary:$bin, override_existing:false}')" \
-plaintext localhost:50051 \
roda.ledger.v1.Ledger/RegisterFunctionuse roda_ledger::client::LedgerClient;
let client = LedgerClient::connect("127.0.0.1:50051".parse()?).await?;
let binary = std::fs::read("function.wasm")?;
let (version, crc) = client
.register_function("fee_transfer", &binary, /* override_existing = */ false)
.await?;
println!("registered fee_transfer v{} (crc={:08x})", version, crc);Or in embedded mode (one process, no gRPC):
use roda_ledger::ledger::{Ledger, LedgerConfig};
let mut ledger = Ledger::new(LedgerConfig::temp());
ledger.start()?;
let binary = std::fs::read("function.wasm")?;
let (version, crc) = ledger.register_function("fee_transfer", &binary, false)?;Re-registering the same name without override_existing = true returns AlreadyExists. With the flag set, the new binary becomes version = previous + 1 and replaces the old one in the live registry atomically.
A registered function is invoked as Operation::Function { name, params, user_ref }:
message Function {
string name = 1; // max 32 bytes
repeated int64 params = 2; // 0..=8 values; short lists are zero-padded
uint64 user_ref = 3; // idempotency / dedup key, same as other ops
}grpcurl -d '{
"function": {
"name": "fee_transfer",
"params": [101, 202, 999, 10000, 50],
"user_ref": 123456
},
"wait_level": "COMMITTED"
}' -plaintext localhost:50051 \
roda.ledger.v1.Ledger/SubmitAndWaituse roda_ledger::transaction::{Operation, WaitLevel};
let result = ledger.submit_and_wait(
Operation::Function {
name: "fee_transfer".into(),
params: [101, 202, 999, 10_000, 50, 0, 0, 0],
user_ref: 123_456,
},
WaitLevel::Committed,
);
if result.fail_reason.is_failure() {
eprintln!("fee_transfer failed: {:?}", result.fail_reason);
}use roda_ledger::client::LedgerClient;
use roda_ledger::grpc::proto::WaitLevel;
let result = client
.submit_function_and_wait(
"fee_transfer",
[101, 202, 999, 10_000, 50, 0, 0, 0],
123_456, // user_ref
WaitLevel::Committed,
)
.await?;Every Operation::Function produces a normal transaction in the WAL with a TxMetadata.tag of the form:
b"fnw\n" ++ crc32c[0..4] (8 bytes total)
roda-ctl unpack renders it as:
{"type": "TxMetadata", "tx_id": 441001, "tag": "fnw\n4a2f1c3d", ...}The CRC32C identifies the exact binary that executed — cross-reference it with the FunctionRegistered WAL record or with a ListFunctions response to resolve the name / version.
List — returns every currently-loaded handler:
for info in client.list_functions().await? {
println!("{} v{} (crc={:08x})", info.name, info.version, info.crc32c);
}Unregister — writes an empty file under the next version and a FunctionRegistered WAL record with crc32c = 0. The call blocks until the handler is gone from the live registry; any Operation::Function { name } submitted after it returns will fail with INVALID_OPERATION.
let unregistered_version = client.unregister_function("fee_transfer").await?;Unregister is durable: the 0-byte-file + crc32c = 0 pair survives restarts and crashes, and is replayed during recovery.
- Every register or override bumps the version by
+1, starting at1. - Version counter is stored in the live registry (sourced from
FunctionRegisteredWAL records + the function snapshot). The on-diskfunctions/directory is reference data only. - A
Functionoperation always uses the current latest version — there is no pinning to a specific version at submit time. - The CRC32C embedded in
TxMetadata.tagidentifies which version actually ran, so auditors can always tell which binary produced any given set of entries.
data/
├── wal.bin # active WAL segment
├── wal_000001.bin # sealed segment (+ .crc, .seal, indexes)
├── wal_000002.bin
├── snapshot_000002.bin # balance snapshot
├── snapshot_000002.crc
├── function_snapshot_000002.bin # function-registry snapshot (same trigger)
├── function_snapshot_000002.crc
└── functions/
├── fee_transfer_v1.wasm # binary under its version
├── fee_transfer_v2.wasm # replaced version (override)
└── removed_fn_v3.wasm # 0 bytes — unregister marker
Function binaries are written atomically (temp file + rename). Unregister truncates the file to 0 bytes — it is not deleted, preserving the audit trail.
The function snapshot is emitted on the same snapshot_frequency trigger as the balance snapshot, so recovery always finds a paired snapshot_{N}.bin + function_snapshot_{N}.bin at the same segment boundary.
Registration is durable before register_function returns. The call blocks until:
- The binary is written atomically to
functions/{name}_v{N}.wasm. - A
FunctionRegisteredWAL record is committed to the active segment. - The live
WasmRuntimehas compiled and installed the handler.
Recovery on clean restart or crash proceeds as follows:
- Load the latest
function_snapshot_{N}.bin. For each record withcrc32c != 0, readfunctions/{name}_v{version}.wasmand compile it into the runtime. - Replay every
FunctionRegisteredWAL record in segments after the snapshot:crc32c != 0→ load the referenced version (replaces any older handler).crc32c == 0→ unload the handler.
- Resume normal operation.
A failure to read a binary or install a handler during recovery is non-recoverable: the server aborts startup rather than continue with a registry that diverges from the WAL. The CRC32C embedded in every FunctionRegistered record lets recovery detect silent disk corruption before anything transactional runs.
The runtime is intentionally narrow:
- No randomness. No host API exposes a PRNG.
- No wall clock. Functions do not see time; tag timestamps are decided by the host.
- No I/O, no network, no filesystem. Only the 3 ledger host calls are available.
- No threads, no atomics. Functions run single-threaded.
- No persistent memory. A function has no state that survives between invocations: every call runs against a fresh wasmtime instance (internally cached by the engine for speed; no durable state is carried).
- Sandboxed. A trap or infinite loop cannot corrupt ledger state — the transaction is rolled back and the handler is left intact for subsequent calls.
This is the property we need for future Raft replication: the leader executes the WASM function, and followers apply the resulting entries directly — no re-execution, no divergence.
The runtime is tuned for low per-call overhead on the hot path.
- Per-call cost: one
HashMap::geton the per-Transactor caller cache, oneTypedFunc::call, two host imports per credit / debit crossing. - Cache invalidation is per-name: registering
foodoes not evict the cached entry forbar. Each cached entry stores theupdate_seqit was verified at; the next lookup either short-circuits (seq unchanged) or does a shared-registry read to reconcile just that one name. - One wasmtime
Engineper ledger (shared viaArc<WasmRuntime>). - One
Linkerwith host imports wired exactly once at ledger startup. - One
Storeper Transactor, long-lived across calls. Function state is carried in hostTransactorState, not in WASM-visible globals. - Instantiation happens once per
(name, crc)pair on first lookup; the resultingTypedFuncis cached and reused.
Empirical numbers from the current build (Apple M-series, release build):
| Benchmark | Native Deposit |
WASM Function (same effect) |
|---|---|---|
TransactorRunner::process_direct (1 tx) |
~132 ns/op | ~335 ns/op |
TransactorRunner::process_direct_batch (1000) |
~133 ns/op | ~286 ns/op |
End-to-end --wait load test TPS, 1M accounts |
~819 k | ~814 k |
The per-op overhead at the Transactor level is ~200 ns — pure host-crossing cost. At the pipeline level (gRPC → WAL commit → response) it is invisible: the WAL commit path dominates.
Run the comparison yourself:
cargo bench --bench transaction_runner_bench # native
cargo bench --bench transaction_runner_bench_wasm # WASM
cargo run --release --bin load -- --wait --duration 30
cargo run --release --bin load_wasm -- --wait --duration 30Validation runs at register_function before any disk write:
| Rule | Limit |
|---|---|
| Name charset | ASCII letters, digits, _ |
| Name starts with | ASCII letter |
| Name length | 1..=32 bytes |
| Binary size | 4 MB (gRPC-enforced via max_message_size_bytes) |
| Module parses | validated by wasmtime |
Exports execute |
required |
execute signature |
exactly (i64 × 8) -> i32 |
execute host imports |
only ledger.credit / ledger.debit / ledger.get_balance |
A binary that fails any of these returns InvalidArgument from gRPC or io::ErrorKind::InvalidData / InvalidInput from the Rust API. Nothing is written to disk and no WAL record is produced.
ALREADY_EXISTS on RegisterFunction
A function by this name is already loaded. Pass override_existing = true to replace.
Operations return INVALID_OPERATION (status 5)
- The function is not registered, or was unregistered.
- The function trapped (out-of-bounds memory, stack overflow, unreachable, division by zero).
- The WASM binary parsed but failed instantiation. Check
tonic/ ledger logs around the call.
Operations return ZERO_SUM_VIOLATION (status 3)
The function's total credits did not match total debits. Every credit(a, N) needs a matching debit(b, N) somewhere else in the same invocation (or vice versa).
Unexpected balances after a restart Inspect both snapshots at the last sealed segment id:
ls data/function_snapshot_*.bin
roda-ctl unpack data/wal_000NNN.binYou should see a FunctionRegistered record for every function that should be loaded and a matching snapshot file at a later or equal segment id.
Snapshot: read_function(name vN) failed during startup
The WAL references a binary that is missing from data/functions/. This is non-recoverable. Either restore the binary from backup, or manually remove the stale FunctionRegistered record from the WAL using roda-ctl (at your own risk).
- ADR-014 — WASM Function Registry and Function Operation Execution
- Architecture — for context on the Transactor / WAL / Snapshot pipeline.
- API — full API reference for the ledger service.