- Status: Draft
- Audience: Numscript implementers, host environment integrators (Ledger v3, SDKs, tooling)
- Scope: Compiling Numscript to a serializable, executable representation via WebAssembly, with upfront variable validation, zero-allocation numeric types, and a host-agnostic execution model.
Note: Numscript is a general-purpose financial scripting language. While the Ledger v3 is the primary consumer today, Numscript may be embedded in other environments (other ledger implementations, simulation tools, browser playgrounds, SDK runtimes). Part I of this RFC defines the Numscript contract independent of any specific host. Part II describes Ledger v3 integration as one concrete consumer.
| # | Change | Why |
|---|---|---|
| 1 | WASM distribution — parser + executor available as a WASM module | Single source of truth, reusable across runtimes (Go server, browser, CLI, SDKs) |
| 2 | Serializable compiled output — parser produces a compact IR (bytecode), not just an in-memory AST | The compiled form can be stored and transmitted without re-parsing |
| 3 | Variable validation as a separate pass — validate and bind variables before execution | Allows the host to reject bad inputs early, before committing to execution |
| 4 | Uint256 native type — replace *big.Int with a 256-bit unsigned integer |
Zero-allocation numeric boundary between Numscript and the host |
| 5 | Volume-based balance interface — receive (input, output) instead of a signed balance |
Eliminates signed arithmetic; flexible for hosts with different storage models |
| 6 | Data-in-memory execution — all state (balances, metadata) is provided upfront, no host callbacks during execution | Zero host↔WASM round-trips; the module reads from its own linear memory |
The Numscript parser and executor MUST be compiled to a WASM module so that they can be embedded in:
- Go server — via a WASM runtime (wazero, wasmtime-go, etc.)
- Browser — native WASM support (playground, documentation)
- CLI tooling — linting, formatting, static analysis
- Other languages — SDKs, tests — WASM is language-agnostic
Today the parser is a Go library. This couples it to Go and prevents reuse.
The parser can be written in any language that compiles to WASM:
| Language | Pros | Cons |
|---|---|---|
| Rust | Excellent WASM support, zero-cost abstractions, mature tooling | Steeper learning curve |
| Go | Team familiarity, existing parser code | Larger WASM binary, GC overhead |
| TypeScript (AssemblyScript) | Easy for browser scenarios | Less suitable for compiler internals |
The choice is out of scope for this RFC. What matters is that the parser exposes a WASM-compatible API.
The WASM module MUST export at minimum:
parse(source: bytes) → ParseResult
ParseResult:
ok: CompiledProgram (serializable bytes)
error: ParseError[]
analyze(compiled: bytes, vars: bytes) → AnalyzeResult
AnalyzeResult:
ok: Requirements
error: AnalyzeError[]
See Static Inputs RFC for the Requirements contract.
The parser MUST produce a serializable compiled representation — not just an in-memory AST. This representation is called the IR (Intermediate Representation).
The IR MUST:
- Be serializable — flat bytes, no pointers, no host references.
- Be deterministic — the same source always produces the same bytes (bitwise).
- Be versionable — include a version tag so consumers can reject incompatible artifacts.
- Be compact — smaller than the source text for typical scripts.
- Be self-contained — no external references needed to execute (except runtime state).
Every compiled artifact MUST start with a version header:
[version: uint32] [payload: bytes]
The parser produces a serialized bytecode (protobuf-encoded AST, stack machine, or register machine). An interpreter decodes and executes it.
Source → [WASM parser] → IR (bytes) → [interpreter] → postings
The compiler produces a WASM module as output. The consumer loads the WASM module and calls its execute export directly.
Source → [WASM compiler] → WASM module (bytes) → [WASM runtime] → postings
Recommendation: Start with Tier 1. Design the version tag so Tier 2 artifacts can coexist.
Numscript MUST expose variable validation as a distinct step, separate from execution:
validate_vars(compiled: bytes, vars: map[string, string]) → ValidateResult
ValidateResult:
ok: BoundVars (serializable bytes, typed values)
error: ValidationError[]
This allows the caller to:
- Reject bad inputs early — before committing to execution.
- Transmit pre-validated values — the executor receives
BoundVarsand skips all type-checking.
BoundVars is a serializable representation of the variables, already converted to their typed form:
| Numscript type | Wire type (BoundVars) |
|---|---|
account |
string (validated account address) |
monetary |
asset: string + amount: Uint256 |
string |
string |
number |
int64 or Uint256 |
portion |
numerator: int64 + denominator: int64 |
The executor MUST accept BoundVars directly and MUST NOT re-validate types, check for missing variables, or perform string→typed conversion. The caller guarantees validity.
Numscript currently uses Go's *big.Int for all monetary amounts. This is a heap-allocated, pointer-heavy, GC-visible type. Every amount crossing the Numscript boundary requires allocation.
Numscript MUST use a 256-bit unsigned integer as its native numeric type for monetary amounts. The concrete type can be holiman/uint256.Int (Go), a 4×u64 struct (Rust/WASM), or any ABI-compatible equivalent.
The key property: zero allocation when passing amounts between the host and Numscript.
| Interface | Today (*big.Int) |
Proposed (uint256) |
|---|---|---|
Posting.Amount |
*big.Int |
uint256 |
| Balance query result | map[string]map[string]*big.Int |
Replaced by volume interface (see §6) |
Variable type number |
*big.Int |
uint256 (or int64 for small values) |
| Overdraft limits | *big.Int |
uint256 |
In WASM, a uint256 is 4 × i64 — same as the Go [4]uint64 layout. No GC, no heap. Host functions pass 4 × i64 values directly.
This makes the BigInt→Uint256 migration a prerequisite for the WASM compilation pipeline.
uint256 is unsigned, but balances can be negative (output > input, i.e. overdraft). Passing a signed balance to Numscript would require either:
- A signed wrapper type — adds complexity
- A heap-allocated arbitrary-precision integer — defeats the zero-allocation goal
Numscript MUST accept account state as a pair (input, output) — two unsigned uint256 values — instead of a single signed balance.
Today: signed balance → Numscript
Proposed: (input, output) → 2 × uint256 → Numscript
Different host environments store account state differently. The (input, output) interface accommodates all of them:
| Host storage model | How to provide (input, output) |
|---|---|
| Separate input/output volumes (e.g., Ledger v3) | Pass volumes directly — zero conversion |
| Single signed balance (e.g., simple ledger, simulation) | Pass (balance, 0) if balance ≥ 0, or (0, abs(balance)) if balance < 0 |
| Single unsigned balance (no overdraft) | Pass (balance, 0) |
The conversion for hosts with a single balance is trivial and happens once at the injection boundary.
Numscript only needs to answer:
- Does the account have enough funds? →
input >= output + amount(unsigned comparison, no subtraction) - What is the available balance? →
input - output(safe wheninput >= output, or overdraft is explicit) - What is the overdraft amount? →
output - input(always positive when output > input)
No signed arithmetic is ever needed. Both values are unsigned.
- Eliminates signed integers from the Numscript numeric model entirely.
- Gives Numscript full information about the account state (input and output separately).
- Host-agnostic: any environment can map its storage model to
(input, output). - Zero-allocation: two uint256 value copies.
Today Numscript uses a callback interface — the host provides a Store that the interpreter calls during execution to fetch balances and metadata. Each call is a host↔WASM boundary crossing with register save/restore, memory copies, and context switching overhead.
For a script reading 10 accounts × 2 assets, that's 20+ host round-trips during a single execution.
The WASM module MUST start with all required data already in its linear memory. There must be zero host function calls during script execution.
The execution model becomes:
1. Host writes all state (volumes, metadata) into WASM linear memory
2. Host calls execute(state_ptr, state_len, vars_ptr, vars_len)
3. WASM module reads state from its own linear memory — ZERO host calls
4. Host reads results (postings, metadata) from WASM linear memory
Only 2 boundary crossings: enter (execute) and exit (read results).
This model is host-agnostic: the host is responsible for collecting the required state (however it stores it) and injecting it into WASM memory before execution.
WASM linear memory:
┌─────────────────────────────────────────────────┐
│ Header: num_volumes, num_metadata_entries │
├─────────────────────────────────────────────────┤
│ Volume table (sorted by account+asset): │
│ [account_hash, asset_hash, input_v0..v3, │
│ output_v0..v3] │
│ ... │
├─────────────────────────────────────────────────┤
│ Metadata table (sorted by account+key): │
│ [account_hash, key_hash, value_offset, │
│ value_len] │
│ ... │
├─────────────────────────────────────────────────┤
│ String pool (account names, keys, values) │
├─────────────────────────────────────────────────┤
│ Bound vars (typed, pre-validated) │
├─────────────────────────────────────────────────┤
│ Result area (postings, metadata output) │
└─────────────────────────────────────────────────┘
This model requires the Static Inputs RFC: the Requirements object declares all state that will be read, so the host knows what to collect and inject before execution. The host is free to source this data from any backing store — volumes, a database, an in-memory map, or even hardcoded test fixtures.
Today the Numscript lifecycle in the ledger is:
- Parse the script source (Go, on the leader).
- Emulate to discover volume reads (Go, on the leader).
- Encode the source text into the Raft command.
- On every replica, re-parse + re-execute the same source text.
| Problem | Impact |
|---|---|
| Parsing happens on every replica | Wasted CPU on the FSM hot path |
| Variable validation happens at execution | Errors surface too late, wasted Raft round-trip |
| The interpreter is coupled to Go | No reuse in browser, CLI tooling, other languages |
| Source text is stored in the Raft log | Larger log entries than necessary |
*big.Int at the Numscript boundary |
Heap allocations on every balance read and posting conversion |
With the Numscript changes from Part I, the ledger pipeline becomes:
┌──────────────────────────────────┐
│ Admission Layer │
│ (leader node) │
│ │
Numscript source ───► │ 1. Parse (WASM parser) │
+ vars │ 2. Static analysis → Requirements│
│ 3. Validate variables → BoundVars│
│ 4. Compile → executable artifact │
│ 5. Encode artifact + bound vars │
│ into Raft command │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Raft Consensus │
│ (replicated to all nodes) │
│ │
│ Command = artifact + bound vars │
│ (opaque bytes, no source text) │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ FSM Apply (hot path) │
│ (every replica) │
│ │
│ 1. Decode artifact + bound vars │
│ 2. Write preloaded state into │
│ WASM linear memory │
│ 3. Execute (no parse, no │
│ validation, no host calls) │
│ 4. Read results, apply postings │
└──────────────────────────────────┘
| Step | Today | Proposed |
|---|---|---|
| Parse | Every replica (from source text) | Once at admission (WASM parser) |
| Variable validation | At execution time | At admission; FSM trusts pre-validated values |
| Raft log payload | Source text + vars (string) | Compiled artifact + bound vars (bytes) |
| Execution | Go interpreter on every replica | WASM executor on every replica (decode + run) |
| Balance reads | Host callbacks during execution | Pre-injected into WASM memory, zero host calls |
| Numeric type | *big.Int (heap alloc per value) |
uint256 (zero alloc, 4 × i64 copy) |
The Raft command for a Numscript transaction becomes:
message CreateLogCommand {
// ... existing fields ...
// Replaces the script source text:
bytes compiled_program = N; // opaque compiled artifact
bytes bound_vars = N; // pre-validated, typed variables
uint32 compiler_version = N; // artifact version tag
}The source text is not stored in the Raft log. If auditability of the original source is needed, it can be stored separately (e.g., in a content-addressed blob store keyed by the script hash).
On the hot path, the FSM:
- Decodes
compiled_programandbound_vars. - Does NOT validate variables — the admission layer already did.
- Writes preloaded state (balances as
(input, output)pairs, metadata) into WASM linear memory. - Calls
execute— single boundary crossing, zero host callbacks. - Reads results (postings, metadata) from WASM linear memory.
- Applies postings to ledger state.
// Illustrative — the concrete API will depend on the WASM runtime choice.
type Executor interface {
Execute(ctx context.Context, req ExecuteRequest) (ExecuteResult, error)
}
type ExecuteRequest struct {
Program []byte // compiled artifact
BoundVars []byte // pre-validated variables
State ExecutionState // preloaded volumes (input, output) + metadata
}
type ExecuteResult struct {
Postings []Posting
TxMetadata map[string]string
AccMetadata map[string]map[string]string
}Every replica applies the same Raft entry and MUST produce the same result.
WASM is almost deterministic by specification. Known sources of non-determinism and mitigations:
| Source | Mitigation |
|---|---|
| Floating-point NaN bit patterns | Numscript does not use floats — all amounts are uint256 |
memory.grow failure |
Bound memory at module instantiation |
| Host function behavior | No host calls during execution — all state pre-injected |
| Thread scheduling | Single-threaded; no threads in Numscript |
// Before — allocates 3 *big.Int per balance read:
balance := new(big.Int).Sub(inputVal.ToBig(), outputVal.ToBig())
accountBalance[asset] = balance
// After — zero-alloc, volumes passed directly:
// (not applicable — balances are pre-injected into WASM memory as (input, output) pairs)// Before — allocates + overflow check per posting:
if overflow := u256Amount.SetFromBig(posting.Amount); overflow { ... }
// After — direct copy, no allocation:
u256Amount = posting.Amount // already uint256The leader caches compiled artifacts by script content hash (blake3):
cache: map[blake3(source)] → CompiledProgram
Local to the leader node, not replicated.
For the WASM executable approach, the runtime can pool pre-compiled modules:
pool: map[blake3(compiled_program)] → pre-compiled WASM module
wazero and wasmtime both support module caching natively.
{
"script": {
"plain": "vars { account $src monetary $amt } send $amt ( source = $src destination = @treasury )",
"vars": { "src": "users:alice", "amt": "USD/2 1000" }
}
}1. Parse source → CompiledProgram (via WASM parser)
2. Analyze(program, vars) → Requirements { balanceReads: [{account: "users:alice", asset: "USD/2"}] }
3. Validate vars → BoundVars { src: "users:alice", amt: {asset: "USD/2", amount: 1000} }
4. Preload state → { "users:alice/USD/2": (input, output) }
5. Encode into Raft cmd:
compiled_program = <bytes>
bound_vars = <bytes>
Command replicated. All replicas receive (compiled_program, bound_vars).
1. Decode compiled_program, bound_vars
2. Write preloaded state into WASM linear memory
3. Call execute → postings
4. Apply postings to ledger state
No parsing, no variable validation, no host callbacks.
- Numscript exposes
parse → IR,validate_vars → BoundVars, and uses uint256. - Ledger stores
(IR, bound_vars)in the Raft command instead of source text. - FSM decodes IR and executes via an interpreter (Go).
- Balance interface changed to
(input, output).
Benefit: No re-parsing on replicas, no re-validation, zero-alloc numeric boundary.
- Compile the Numscript parser/compiler to WASM.
- Replace the Go parser call in the admission layer with a WASM parser call.
- Same IR format, same interpreter — but the parser is now reusable across runtimes.
Benefit: Single source of truth; enables browser playground, SDK tooling.
- The compiler produces a WASM module as output.
- The FSM executes the WASM module directly via a WASM runtime.
- All state pre-injected into WASM linear memory; zero host calls.
Benefit: Maximum hot-path performance — WASM JIT/AOT, no interpreter, no host round-trips.
| # | Question | Notes |
|---|---|---|
| 1 | Should the Raft log store the original source text for auditability? | Could use a content-addressed store (keyed by blake3 hash) outside the Raft log |
| 2 | What WASM runtime for Go? | wazero (pure Go, no CGo) vs wasmtime-go (faster, requires CGo) |
| 3 | How to handle compiler version upgrades across a rolling cluster? | Version tag in artifact + minimum cluster version negotiation |
| 4 | Should the preloaded state be part of the Raft command or loaded independently on each replica? | Trade-off: larger Raft entries vs requiring each replica to load state |
| 5 | What language for the Numscript compiler? | Rust (best WASM target), Go (team familiarity), or other |
- Static Inputs RFC — Defines the
Requirementscontract. This RFC extends it: Requirements are computed at admission and used to preload state that is injected into WASM memory. - Request Signing — The
signed_payloadenvelope pattern is orthogonal. The compiled artifact replaces the script source in the Raft command, but signing still covers the original request.
| Principle | Rule |
|---|---|
| Parse once | Parsing happens at admission, never on the FSM hot path |
| Validate once | Variables are type-checked at admission; the FSM trusts pre-validated values |
| Compile once | The compiled artifact is stored in the Raft log; replicas decode and execute |
| Zero-alloc numerics | All amounts are uint256 — no *big.Int at any boundary |
| Volume interface | Balances passed as (input, output) — no signed arithmetic |
| Zero host calls | All state pre-injected into WASM memory; execution has no callbacks |
| WASM parser | The parser is a WASM module, reusable across runtimes |
| WASM executor (ideal) | The compiled Numscript program is itself a WASM module |
| Deterministic | WASM execution + preloaded state = fully deterministic on all replicas |