Rand Protocol
Documentation
Guides

Proofs

How the wallet produces a bundle proof, what it costs in time and bytes, how a node verifies it, what a constraint set is and why changing one is a hard fork - and block aggregation, the upcoming change to how proofs reach a block.

Use this page to size a proving machine, to understand why a transfer takes over a minute and why block space, not compute, bounds throughput, and to know what breaks when the proof system changes. Everything a validator accepts is backed by a proof it verified itself.

What a proof states

One relation, for every program the chain runs:

This RV32IM program, started at base_pc, run on a private input vector committed to by H_IN and a public input segment committed to by H_PUB, halted within 2ᵗ − 1 cycles, spent at most GAS_LIMIT gas and published these eight output words.

The program is identified by its in-circuit digest hc; the verifier never sees an instruction word. A shielded transfer is that relation applied to the hidden-asset bundle guest, a hand-assembled 4-in-4-out program whose hc is pinned in the genesis file as hc_bundle. Chain 21 pins guest v3 (guests::bundle_hidden_v3, 60af094a…6e3fce, since chain 17; its digest did not move with constraint set 9, its verifier key did). Chains 14 and 15 pinned v1 (83d3a370…cd0ef8) and chain 16 the branch-free v2 (651043e2…839b). Since chain 17 every bundle also carries a second proof, of the auth guest (guests::auth, genesis hc_auth, 1e4e347f…5639c1): the bundle proof takes the viewing key, and the auth proof shows that the sender holds the spend key behind it. This is split authorisation, and it is what lets a paired prover that holds only a viewing key make the bundle proof. The hidden-asset guest replaced the 2-in-2-out bundle guest of chains up to 13, which survives in the fullnode only for tests and which no chain pins. A confidential call is the same relation applied to a deployed program (Programs).

How the wallet proves a bundle

rand send <rand1…> <amount> does the private work locally, and only proofs, commitments, nullifiers and sealed envelopes reach the chain:

  1. Scan the tree with the viewing key and pick inputs: at most two RAND notes covering the amount plus the fee in slots 2–3, and for a token transfer or burn at most two notes of the token in slots 0–1 with the RAND notes covering the fee alone. An unused input slot is a dummy: amount 0, owned by the wallet, its membership failure masked in circuit, nullifier still published. Since guest v2 the guest is branch-free: a dummy’s path is hashed up all 32 levels like a real note’s.
  2. Fetch the head anchor (rand_getAnchor) and the 32-level Merkle witness of each real input (rand_getWitness(index)), checking the returned root against the anchor.
  3. Build four outputs: the recipient’s note and the change note in the group that moves value, present even at zero, and zero-amount dummies elsewhere, sealed to a throwaway key.
  4. Seal each output in an envelope (ML-KEM-768 to the recipient, plus the transaction key wrapped under the wallet’s ovk), assemble the whole transaction with its action, and take its binding: blake3("rand-tx-bind-1" || …) over the transaction with the proofs blanked, eight words (Transactions). Since chain 20, whose genesis set binding_domain: 1 (chain 21 keeps it), it is blake3("rand-tx-bind-2" || …) with the genesis hash first, then the chain id, so a proof made for one chain verifies on no other, even one with the same chain id. A wallet older than v0.6.8 takes the old binding, and chains 20 and 21 refuse its proofs; on chain 21 a wallet older than v0.8.0 is refused earlier still, since its proofs are constraint set 8’s and do not decode.
  5. Authorise: draw a fresh 256-bit salt and prove the auth guest on its 16-word private input, the spend key and the salt, with the binding as the public input segment, at tier 10. It derives nk from the spend key and publishes c = H(AUTH, nk ‖ salt), which the bundle carries as auth_commit.
  6. Prove the bundle guest on its 1,212-word private input (the viewing key nk, four input slots with their paths, the anchor, four outputs, fee, burn_a, burn_r, the private asset A, time, the salt) with the same binding as the public input segment, at tier 14. The proof shows ownership, membership, that each real input carries its slot’s asset, the four nullifiers, the same c, and the two balances in₀ + in₁ = out₀ + out₁ + burn_a and in₂ + in₃ = out₂ + out₃ + fee + burn_r. This is the proof a paired prover can make in the wallet’s place; the auth proof is made on the wallet’s own machine.
  7. Check the proof’s published digest against the one computed from the plaintext, and submit Transaction { chain_id, bundle, action: None }.

The order matters since chain 14: the envelopes and the action exist before the proofs do, because the proofs commit to them. For a call, the call proof is made first and sits inside the binding too. On a hardening_v6 chain the call proof is itself made over a call binding of the transaction with both proofs blanked (rand-call-bind-1 on chains 16 to 19, rand-call-bind-2 with the genesis hash since chain 20), which covers the fee bundle’s nullifiers, so it cannot be copied under another bundle.

proving bundle (tier 14; about a minute on a laptop)…
proved in 98.3s: tier 14, 1423170 bytes
submitted transfer 4f2c…  1.5 RAND out, 98.499 RAND change, fee 0.001 RAND, anchored at height 192

The transcript is the wallet’s output format from the fullnode’s guide as of chain 14; the byte count shown is that of a production-profile bundle proof committed on chain 14. The wallet since chain 17, v0.8.0 on chain 21 included, prints two lines before these, proving the spend authorisation on this machine (tier 10; seconds)… and authorisation proved in …: tier 10, … bytes, and words the bundle line proving the bundle (tier 14; about a minute and a half on a laptop)….

The same machinery pays for every action, always with exactly one bundle: a deploy, a call, a token registration or a token mint rides on a zero-value self-transfer of RAND, a bond on a bundle that burns the stake through burn_r, and a token burn or a bridge burn on one bundle that burns the asset from slots 0–1 (burn_a, burn_asset) and pays the RAND fee from slots 2–3. The asset a transfer moves is a witness word and is in no public field.

What it costs

Measured on the development laptop (Apple Silicon, release build), the numbers the fullnode’s own documents record:

guestwordscyclestierproveproof size (production, 80 queries)
bundle guest v3 (chains 17 to 20)—4,024 cycles and 273 Poseidon2 permutations of headroom in the tier-14 budget14≈ 100 s, 5.7 GB peak; 102.5 to 134.0 s measured on chain 18 (2026-09-30)≈ 1.50 MB; 1,494,151 to 1,504,260 bytes measured on chain 18 (2026-09-30)
auth guest (chains 17 to 20)—25010≈ 7 s; 6.6 to 19.9 s measured on chain 18 (2026-09-30)≈ 1.37 MB; 1,364,940 to 1,375,371 bytes measured on chain 18 (2026-09-30)
hidden-asset bundle v1, 4-in-4-out (chains 14 and 15)—under the tier-14 budget with about 2,071 cycles (13%) to spare14≈ 98–100 s, 5.7 GB peak≈ 1.43 MB; 1,422,116 and 1,423,170 bytes measured on chain 14
bundle, 2-in-2-out (retired; chains up to 13)3,811≈ 9,16014≈ 100 s (94.6–97.9 s alone; 114 s each for two at once)1,321,773 bytes measured on chain 8
private_payment (a call)small< 1,00010≈ 7 s≈ 1.37 MB (fib at tier 10: 1,367,688 bytes under constraint set 7; 1,298,729 under set 6)
a call with no hash table (off-chain ledger, chain 18)773—14114.8 s1,498,589 bytes
a call with the SHA-256 table (proof of liabilities, chain 18)994—14122.9 s1,911,152 bytes
a call with the Keccak table (anonymous vote, chain 18)——1229.2 s3,346,865 bytes

Verification is the consensus cost, paid by every node: about 16 ms with a warm verifier key, about 230 ms the first time a key is built for a given shape. The verifier key does not depend on program content — it is keyed on (tier, program_log_height, input_log_height, keccak_log_height, sha256_log_height, public_log_height) — so a node builds one per shape and reuses it; for a deployed program the key is computed once in the background at deploy time (about 2 s on a laptop, 7 s on a 2-vCPU server). The 2-in-2-out bundle of chain 13 and earlier declared the shape program 12, input 10, keccak 0, sha256 0, public 2, mem 16; a chain-14 bundle proof declares public 4, the height of the eight binding words, carries neither hash table, and is refused before any verifier key is built if it declares another tier, either table or another public height. Since constraint set 7 every declared table has at least 2⁷ rows, so a chain-20 bundle proof declares public 7.

A transfer on chain 20 therefore carried about 2.87 MB of proof, the bundle proof and the auth proof, and a call a third proof on top; under constraint set 9 a chip-free proof grows by about 1 to 4 KB (the wider cpu table), and chain 21’s sizes are not yet measured. Each must fit the genesis max_proof_bytes: 6,291,456 (6 MiB) on chain 21, raised at its cut for the private-multisig authorisation proof (estimated at 3.4 MB or more, unmeasured); 4 MiB on chains 17 to 20; 8 MiB on chains 13 to 16. On chain 21 a bundle may carry a second shape of auth proof, the multisig auth guest’s (hc_auth_multisig, tier 18), which the ledger tells apart by the header the proof declares (Transactions).

The hidden-asset guest fits tier 14 only because it reads its private inputs in a loop and fuses the Merkle check’s sibling reads; written in the older unrolled style the same relation lands at tier 16, about 400 s and 21–22 GB, which would exclude 16 GB wallets.

Proving is CPU-bound, and the wallet uses the CPU backend on every platform today. In the large-tier runs the prover was single-threaded: a 16-vCPU droplet ran at 99% of one core, and memory, not cores, decided whether a proof finished (proving memory). The --cuda flag on send, bond, call, deploy, bridge-mint and bridge-burn selects an NVIDIA backend that requires a build with --features cuda. Its kernels first ran on real hardware, an NVIDIA H100, on 2026-09-28 and matched the CPU reference, but no PTX is committed in the circuits checkout the release vendors, so a build fails with no PTX for the GPU kernels until the kernels are compiled. No production proof has been measured on a fleet GPU node. There is deliberately no fallback to the CPU. A CUDA call also cannot seal an input envelope, because the salt never leaves the prover. Peak memory for a tier-14 hidden-asset bundle proof is about 5.7 GB (Production FRI, CPU); the larger memory classes concern the interpreter and recursion proofs below (≥ 64 GB and up).

How a node verifies

Admission runs cheap checks first (Transactions), then:

  • recomputes the bundle digest from the published plaintext (anchor, four nullifiers, four commitments, fee, burn_a, burn_r, burn_asset, time, the bundle’s auth_commit, and a taint word fixed to zero) and requires it to equal what the proof published — a proof whose witness broke the relation publishes a tainted digest that matches no plaintext. Some checks are structural instead (the guest supplies an output’s sender, asset and time itself, so a prover cannot choose them), and the private asset is not in the digest;
  • decodes the proof and checks its declared heights against the tier (check_declared_heights), so the chain’s bound on a proof’s shape is the verifier’s. Before any signature or proof is checked, a canonical-proof screen runs on every submitted or gossiped transaction (and, under hardening_v6, in block validation); since fullnode v0.6.8 (audit v6, ZKV-4) that screen first bounds every declared degree at 25 bits (MAX_HONEST_DEGREE_BITS, the memory table’s ceiling plus one), so a header with a huge degree no longer holds a node while the honest folding schedule is re-derived for it, and a panic inside the screen refuses that transaction instead of stopping the node;
  • computes the transaction’s binding and runs Machine::verify_public(hc_bundle, &binding, proof) — the batch verifier plus the check that H_PUB is the digest of those eight words, so a proof copied onto a transaction with another action, another envelope or another chain id, or since chain 20 onto another chain’s genesis, does not verify (through chain 13 the segment was empty and nothing bound a proof to its transaction);
  • verifies the auth proof against hc_auth and the same binding, and requires the c it publishes to equal auth_commit. A bundle without an auth proof is refused (AuthMissing), and so is one whose c differs (AuthMismatch);
  • requires the bundle proof’s GAS_LIMIT to equal the genesis bundle_gas_limit, 20,479, and the auth proof’s to equal 1,279, the two guests’ header ceilings (BundleGasLimit, AuthGasLimit);
  • for a call, does the same against the program’s stored code_hash, with the program’s public words followed by the call binding as the segment (an RPL-2 Invoke appends its transition as context words, Programs), then checks the fee against the gas limit the proof declares and its bytes (Fees).

A transaction submitted over RPC or gossip is verified on one of four workers behind a 64-deep queue, off the consensus loop, and gossiped onward only once it has verified; a per-peer token bucket (burst 16, refill 4 per second) meters what a peer can make a node verify, and an 8,192-entry cache answers a repeat of a permanently refused transaction for free. Verification of the proofs inside a proposed block still runs on the consensus loop when the block is applied. A node that syncs re-verifies every certificate and every proof of every block before accepting it; nothing is trusted because a quorum signed it.

Tiers

tiercycle budget (2ᵗ − 1)cpu rowsused by
101,0232¹⁰the auth guest; private_payment, fib
124,0952¹²
1416,3832¹⁴the hidden-asset bundle guest (and the retired bundle guest); the largest call
1665,5352¹⁶the EVM call proof in the test suite; the translated ERC-20 approve (called on chain 13)
18262,1432¹⁸ERC-20 transfer and transferFrom, translated or interpreted
201,048,5752²⁰SPL Token, translated or on the sBPF interpreter guest

Since fullnode v0.5.6 (2026-09-25) a call proof above tier 14 is refused (CallTierTooHigh) before a verifier key is built, and a block carrying one is invalid; a call may also carry at most 128 Keccak-f permutations and 128 SHA-256 compressions. Tiers 16 to 20 still prove, but off chain only: none of the rows above them can be called on chain 21, and chain 21, like chains 19 and 20, refuses the deploy of a program too large for any tier-14 call, more than 8,180 words without a public input (ProgramUncallable) (the budget).

The prover picks the smallest tier whose budget covers cycles plus the program and input digest rows; a run that does not fit fails before proving. Only the tier is public. Each step roughly quadruples proving time and adds a little to proof size. Since chain 20 a call’s fee follows the gas limit its proof declares and its size in bytes, not the tier; on chain 21 that floor is burned rather than paid to the proposer (Fees).

Constraint sets and hard forks

A constraint set is the exact set of tables, columns, buses, public values and FRI parameters the verifier checks. A proof made under one set does not verify under another, in either direction, and a node’s startup replay re-verifies every proof in its chain — so a node built from a different set fails at the first proof and truncates. That is why every change to the set is cut as a new chain id with every node on one build, and why rand_status reports hc_bundle and hc_auth and a node whose build disagrees with the genesis refuses to start.

setdatewhat changed
22026-09-10five soundness fixes after the first review
32026-09-11Poseidon2 chip, hc in-circuit, the program table as witness, sub-word memory, RV32M, FRI retuned to 27 queries
42026-09-11the salted input commitment H_IN with its own table; the flat-binary loader
52026-09-12the optional keccak table; proof-declared memory height; two critical fixes from the zk audit; FRI back to 80 queries; MAX_PROOF_BYTES to 2 MiB
62026-09-14the mandatory public table and H_PUB; the optional sha256 table; the EVM and sBPF interpreter guests as libraries; 34 public values. What chains 12 to 15 ran.
72026-09-28every LogUp terminal blinded through the BLIND bus; every declared table floored at 2⁷ rows; 32-bit range checks on input, public and salt words; the POSEIDON2_LEN syscall (7); JALR clears bit 0 of its target and requires funct3 = 0; verifier keys salted from key_derivation_v2. What chains 16 and 17 ran.
82026-09-29the gas meter: the GAS column in the cpu table and the public value GAS_LIMIT; 35 public values. What chains 18 to 20 ran.
92026-10-12the MODQ chip: syscall 8 (MODQ, one mod-q NTT, inverse NTT, dot or multiply-subtract over 256-coefficient polynomials, Dilithium’s q = 8,380,417) and an optional modq table a proof declares in a new header field, modq_log_height; the cpu table gained the syscall’s columns, so every verifier key and the proof encoding moved; rVM phase 4’s Merkle spans in the recursion machine. Only the multisig auth guest carries the table. What chain 21 runs.

Chains 14 and 15 changed no constraint. Chain 15’s node holds Poseidon2’s 86 round constants as a literal table instead of a seeded draw (audit ZKV-2) — the same values, so no hash, digest or proof moved. Chain 14: the hidden-asset bundle is a new guest (a new hc_bundle) and the transaction binding uses the public segment that set 6 already binds in circuit. It is a hard fork all the same — the bundle’s wire format, the pinned guest and every transaction id changed, a chain-13 transaction carrying a bundle is refused by the new build, and a wallet built before the fork produces empty-segment proofs the chain refuses.

The chains since each changed what a proof is. Chain 16 (fullnode v0.6.1, 2026-09-28) moved to constraint set 7, pinned the branch-free bundle guest v2 and set hardening_v6, after the 2026-09-27 review showed that short tables and unblinded LogUp terminals leaked private data (How the zkVM works). Every verifier key changed. Chain 17 (v0.6.3, 2026-09-29) kept set 7 and added split authorisation: bundle guest v3, the auth guest, two new fields on the bundle’s wire and the transaction id domain rand-txid-3. Chain 18 (v0.6.7-rc1, 2026-09-29) moved to constraint set 8 for the gas meter. Its measured cost in proof size is under 1%. Chain 19 (2026-10-01) is a re-genesis of chain 18 on the same v0.6.7 build and changed nothing in the proofs. Chain 20 (fullnode v0.6.8, 2026-10-01) kept constraint set 8 and the same guests and verifier keys, and changed what a proof is bound to: the transaction and call bindings carry the genesis hash (binding_domain: 1), a public input the wallet and the ledger compute, so no circuit moved. It was a hard fork all the same, since a v0.6.7 wallet’s proofs do not verify on it. Chain 21 (fullnode v0.8.0, 2026-10-12) moved to constraint set 9 for private multisig: a Dilithium2 verification is mostly polynomial arithmetic mod q, 880,102 cycles in RV32 instructions and 80,572 with the MODQ syscall, which is what lets the multisig auth guest (hc_auth_multisig, 9705f8e2…8247, tier 18) check up to three signatures inside tier 18’s 262,143 cycles. The bundle and single-key auth guests keep their digests, every verifier key and the proof encoding changed, and a v0.8.0 build refuses chains 18 to 20 at startup; a proof from an older wallet does not decode on chain 21. Each build refuses the chains before it by genesis hash.

Two audit notes carried in the code and worth knowing: the TIER public value is checked by the verifier against the proof header and the batch’s degree bits but is not constrained by the circuit itself (audit ZL1, flagged for the next set rather than fixed, because removing it shifts the public-values layout); and H_IN is a guest-visible commitment the verifier does not compare against a caller-supplied value, by design.

Upcoming: block aggregation

At about 1.43 MB per hidden-asset bundle proof on chains 14 and 15, a 4 MiB block (the default, and chain 12’s) held two shielded transfers; it held three at the 2-in-2-out bundle’s ~1.3 MB. Chains 13, 14 and 15 raised the block cap to 20 MiB (max_block_bytes), room for fourteen. Chain 20 kept 20 MiB, and a transfer there carried about 2.87 MB of proof, so a block held about seven; chain 21 sets 19 MiB (19,922,944 bytes, three 6 MiB proofs and a mebibyte), which holds about six. The remedy the fullnode chose (2026-09-12) over pruning and over a bigger block is block-level aggregation: one recursive proof per sealed window that verifies N bundle proofs, so history stays proof-carrying end to end while the raw proofs can be dropped. It is built and merged but not activated, and since chain 14 it has to be re-measured before it can be: its admitted bundle shape was measured with the empty public segment (public 2), and a transaction-bound bundle declares public 4, so no bundle proved on chain 14 matches it. Constraint sets 7 and 8 moved the shape again: a bundle proof now declares public 7 and publishes 35 values, so the aggregate’s interface is 35 words a proof. A node build refuses to start on any genesis that carries an aggregation section, and chains 14 to 20 were cut without one. A genesis with dynamic gas prices, such as chain 20’s, is also refused beside an aggregation section. Since fullnode v0.5.8 the aggregate proof also binds its aggregator (audit AGG-2: the program absorbs aggregate_binding(chain_id, aggregator, nonce)), so the admitted shape’s program digest must be measured on a build that carries that change. Activation also still waits on an end-to-end forged-aggregate exercise against the fixed recursion verifier, which has not been run, and on a decision about the auth proof: the aggregate program verifies bundle proofs only, so on a split-authorisation chain sealing a window would shrink a transfer from about 2.87 MB to about 1.37 MB, not to nothing.

What is built:

  • a second, Goldilocks-native machine (the recursion VM, randprotocol-rvm) whose verifier program checks an RV32 proof in about 1.97 million rows at tier 21 (2,044,506 under constraint set 7), and an N-generic aggregate program over a window of bundle proofs;
  • two roles: validators stay CPU-cheap and verify one aggregate per sealed block; aggregators (a permissionless, bonded register with a shielded payout address) prove aggregates on GPU-class machines and are paid the covered bundles’ proving share plus a block subsidy in new RAND on a halving schedule;
  • five actions (RegisterAggregator, UnbondAggregator, WithdrawAggregator, SlashAggregator, Aggregate; SlashAggregator stays on the wire and is refused, because a retried aggregate re-signs the same nonce and is not equivocation), a nine-step admission, sealing and pruning of covered bundles to about 3 KB of public fields each, a sealed-form sync that costs a fresh node one recursion verification per window, the RPC methods rand_getAggregate, rand_getAggregators, rand_getUnsealed, rand_getRawTransaction, and the rand-node aggregate --watch daemon.

Measured on the test FRI profile: one N=1 aggregate proved in about 26 minutes on a loaded laptop (327,035 bytes); a fresh node applied a seven-block sealed batch in under a second with one recursion verification. Machine classes as first bounded: N=1 needs ≥ 64 GB of host memory, N=2 ≥ 128 GB, N=3 ≥ 160 GB; production N ≥ 2 belongs to the CUDA backend, which needs a GPU node that does not exist yet. Measured on a 256 GB machine under constraint set 7 (2026-09-28 and 29), the N=2 aggregate took 133 GB, N=3 took 221 GB, and the production exit proof (the verifier program over one inner proof) exceeded 256 GB. None of it has been re-measured under constraint set 8 or 9. Until aggregation activates, no aggregate stands in for a proof on chain 21 (whose genesis, like chain 20’s, has no aggregation section): its two archive nodes keep every raw proof, and its validators prune history older than 24 hours.

Last verified against fullnode commit c0878c3f (chain 21) on 2026-10-12. Where the code and this page disagree, the code is right; tell us.