RAND pays for confidential calls: a program runs off-chain inside the Rand zkVM on private inputs, and only a STARK proof plus eight public output words go on chain. Every node verifies the proof, charges gas, and stores a receipt.
Since phase S1 a call pays through a shielded bundle, not from an account. Deploy and Call
are actions on an ordinary shielded transaction whose bundle is a self-transfer of zero: it exists
to pay the action's fee floor out of the caller's own notes, and the chain never learns whose they
were. The call proof and the bundle proof are separate objects and both are verified
(docs/shielded.md, docs/architecture.md §9). The consequence for programs: effect kind 1 —
the program-driven transfer to a recipient — is deleted, along with the recipient list a call
used to carry, because there are no accounts to pay. A program that paid recipients[i] must be
rewritten to publish what it decided and let the caller move the value in the bundle.
crates/randprotocol-zkvm (vendored from circuits/research, upstream rand_zkvm): an RV32I subset,
plus the RV32M extension and sub-word loads/stores (constraint set 3, below), proven by a
Plonky3 batch STARK over Goldilocks with Poseidon2 hashing and ZK-hiding FRI. Nine mandatory
tables (program, cpu, memory, alu, range, nibble, poseidon2, input, public — input added in
constraint set 4, public in constraint set 6, below), plus a keccak and a sha256 table a
proof carries only when its guest called the matching syscall (sets 5 and 6), connected by
LogUp/permutation buses, every LogUp terminal blinded and every table at least 2^7 rows since
constraint set 7. Eight syscalls: read_input(i) (private input word, bound since
constraint set 4 to a salted commitment H_IN), write_output(slot, word) (one of eight
public outputs), poseidon2(ptr, n) (in-place hash of n words), keccak(ptr) (one in-place
Keccak-f[1600] permutation, constraint set 5), sha256(ptr) (one in-place SHA-256 compression,
arrived with constraint set 6's re-vendor), read_public(i) (public input word, bound to the
unsalted H_PUB, constraint set 6), poseidon2_len(ptr, n) (the length-bound sponge,
constraint set 7), halt.
Gas tiers pad the execution trace: tier t (10, 12, ..., 20) proves up to 2^t - 1 cycles and the
proof reveals only the tier, never the real cycle count. Production FRI profile: blowup 8, 80
queries, 20 PoW bits — the whitepaper's own Part III parameters, restored in constraint set 5
after the 2026-09-12 zk audit; constraint sets 3 and 4 ran at 27 queries (see docs/03-privacy.md
in the upstream research crate for the full retune-and-revert history).
Measured upstream on guests::fib at tier 10: at constraint set 3, prove 3.1 s, proof 268 KB,
first (uncached) verify 16 ms. At constraint set 5 the proof is ~1 202 416 bytes and the first
verify ~233 ms, with prove time unchanged within noise — the query count moves bytes, not work.
Constraint set 6's mandatory public table and wider cpu table add a few percent to those bytes
(see "Constraint set 6" below for the measured deltas); prove and verify are unchanged in kind.
Constraint set 7's blinding columns and 2^7 floors take tier 10 to 1 367 688 bytes (see
"Constraint set 7").
The verifier key itself is what a cached verify amortizes away; see "Constraint set 3" and
"Constraint set 5" below for the full before/after.
Constraint set 2 (2026-09-10, upstream fix wave through f44d58f). The vendored zkVM now
carries five soundness fixes: ALU padding rows can no longer provide a bus tuple with arbitrary
multiplicity (previously fib(10) = 999 was provable), never-written output slots are pinned to zero
by WRITTEN accumulators, verify rejects non-canonical public values, load/store word
alignment is a stated constraint (the cpu table grew from 40 to 52 columns), and a store's mem_val
is pinned to the rs2 value it writes — left unstated, a cheating witness could store a value that
was never in any register and read it back through a later load as genuine memory contents. The
prover also runs the emulator up to the largest tier's cycle budget rather than a hardcoded 1 << 20,
so OutOfCycles and TooManyCycles agree on one limit. Proofs made under the
old constraints do not verify under the new ones, so a node built from this commit will fail the
startup ledger replay of chain 4 at the first block with a confidential call (block 19) and truncate
its chain. Treat this as a hard fork: start a new chain id, or run --verify-chain off on nodes that
must keep serving the old chain.
The balance_check guest also became carry-aware (a four-balance sum that wraps mod 2^32 now counts
as over any 32-bit threshold), which changes its program id; private_payment is unchanged and keeps
the id chain 4 has deployed.
Constraint set 3 (2026-09-11, upstream 35fceab: milestones 2+3). The vendored zkVM was
re-synced past constraint set 2 to research's milestones 2 (finished) and 3 (Poseidon2). The
headline changes:
- Proofs shrank roughly 3x, verify got faster. FRI retuned to arity 8 (
max_log_arity: 3) and 27 queries (down from 80), still meeting the ethSTARK conjectured-soundness target (3·27+20 = 101bits); splitting the old 2^16-row byte table into two 256-row tables (range,nibble) cut the preprocessed commitment the prover rebuilds every proof and the verifier rebuilds on every uncached verify by two orders of magnitude. Net effect measured upstream at tier 10: proof size 892 KB → 268 KB, prove 21.6 s → 3.1 s, first (uncached) verify 2.17 s → 16 ms. - Sub-word memory. Loads and stores can now address a byte or halfword within a word
(
Instr::Load/Store { width, .. }replace the old word-onlyLw/Sw), sign-extended forlb/lh. Memory itself stays word-addressed. - The RV32M extension.
mul mulh mulhu mulhsu div divu rem remu(AluOpgrew from 11 to 19 variants), register-register only, reached the same way upstream RISC-V reaches it (OP_ALU,funct7 = 1). - A Poseidon2 syscall.
SYS_POSEIDON2 = 3:a0 = ptr(word address),a1 = n(≤ 4096 words); hashes thenwords in place with a padding-free Poseidon2 sponge and overwritesptr..ptr+8with the 8-word digest. Proved by a dedicatedposeidon2chip (a seventh table) the cpu table's new hash rows call into over aPOSEIDON2bus;src/hash.rsis the host-side reference sponge (also used for the in-circuit program digest below) and is vendored, unlike the note/viewing-key layer built on top of it (notes.rs/viewing.rs/ledger.rs, still not vendored — seedeploy/sync-zkvm.sh's header comment). - The program table is a witness now, and
hcmoved in-circuit. Through constraint set 2,hcwas the preprocessed program table's Merkle root and the verifier held the whole program in the clear to recompute it. The program table is proved as an ordinary witness trace now (an in-circuit decoder reproducesInstr::decode), and a digest-row prefix at the front of the cpu table absorbs every program word through the same Poseidon2 chip, publishing an 8-word digesthcthe verifier checks directly —Machine::verify(hc, proof)replacesverify(program, proof); the verifier never sees a single instruction word.hcis still binding, not hiding (no salt of its own — the upstreamresearchcrate'sdocs/03-privacy.md, "What that does and doesn't change about whathcleaks", has the full argument), and it is still guessable by anyone who can enumerate candidate programs, but it closes a much larger leak: the program itself.Proofgained aprogram_log_height: u8field (the program table's height, declared by the prover per proof, not derived from the tier — a program can need far more table rows than cycles, since a digest row absorbs up to 4 words each);verifier_key(tier, program_log_height)is program-content-independent (cached byMachineitself, keyed on that pair). - On-chain effect:
ProgramRecord.code_hashis nowhc, and it is load-bearing. Before this sync,code_hashwas blake3(program_id) again — informational, sinceverify_callreconstructed the program fromrecord.wordsand passed it toMachine::verifydirectly. Now that the verifier no longer takes a program at all,ZkExecutor::check_programcomputes and storeshc(isa::Program::digest(), 8 little-endianu32words) ascode_hashat deploy time, andZkExecutor::verify_calldecodes it back out and hands it toMachine::verify—code_hashis now the actual verification key material, not just an explorer-facing label.randprotocol-core'sprogramsRPC already servescode_hashas hex; nothing there changed. - Tiers are unchanged (10, 12, 14, 16, 18, 20;
cpu/alu/memoryheight formulas unchanged);Tier::poseidon2_height()(2^(t+2)) and the proof-declaredprogram_log_heightare new, both sized independently ofcpu_height.
Proofs made under constraint set 2 (or earlier) do not verify under constraint set 3 — different
FRI parameters, a different table set, and a completely different hc construction (a Merkle
root of a preprocessed table vs. an in-circuit Poseidon2 digest). This is the same hard-fork
situation constraint set 2 already documented: a node built from this commit will fail startup
ledger replay of any chain with a confidential call proved under an older constraint set and
truncate its chain. Start a new chain id, or run --verify-chain off on nodes that must keep
serving an old chain.
Constraint set 4 (2026-09-11, upstream 8667928: milestone 4.1). The vendored zkVM was
re-synced past constraint set 3 to research's milestone 4.1 (compiled guests, docs/superpowers/ plans/2026-09-11-zkvm-m4-1.md). The headline changes:
READ_INPUTis bound to a salted commitment,H_IN. Through constraint set 3, a guest'sread_input(idx)syscall returned whatever word the prover chose to supply, unconstrained — nothing tied two reads of the same index together, and nothing stopped a cheating prover from answering the same index differently on different cycles. An eighth table,input(one row per committed private-input word), fixes this: everyREAD_INPUTand the digest's own mandatory absorption draw(idx, word)from that table over two separate LogUp buses,INPUT_DIGEST(countIS_REAL, the digest's sole and unconditional source) andINPUT_READ(countIS_REAL * MULT_READ,SYS_READ's sole source) — split, rather than shared, so the two consumer classes cannot trade budget with each other (an earlier single-bus design let a prover under-supply the digest's mandatory copy while a genuine read still succeeded). The cpu table'sIS_INDIGESTrows absorb the committed vector intoH_INthe same way the existingIS_DIGESTrows absorb the program intohc, published aspv::IN0..7.H_INis salted (four witness words, drawn fresh per proof from OS entropy, absorbed ahead of the real input words) — unlikehc, which has no salt of its own — precisely so it is hiding as well as binding: a verifier who can enumerate candidate input vectors gets nowhere testing them against a publishedH_IN, since the salt never leaves the prover.H_INalone makes no input word public; it only makes repeated reads of the same index consistent and an out-of-range read (idx >= n_in) unsatisfiable — a guest that wants an input word public still has towrite_outputit itself. The prover must draw a fresh salt every proof (Machine::prove, neverMachine::prove_saltedoutside tests that deliberately need a fixed one to check against, or hiding is lost) —ZkExecutor's prover entry point (executor::prove) does this already, since every backend it can select routes throughMachine::prove/Machine::prove_on, both of which draw fresh entropy internally.pv::NUMgrew from 18 to 26, andProofgained a new field,input_log_height: u8— theinputtable's declared height, exactly analogous toprogram_log_height(proof-declared, not tier-derived;tables::input::input_log_height's doc comment mirrorstables::program:: program_log_height's rule).Machine::verifier_keyandlog_ext_degreesgrew from a(tier, program_log_height)2-tuple key to a(tier, program_log_height, input_log_height)3-tuple; the injectedlog_ext_degrees_pubwrapper and every verifier-key lookup inZkExecutor(verify_call's degree-bits pre-check,warm) grew the same third argument.pv::HC0..HC7's offsets (10..17) are unaffected —pv::IN0..7(18..25) was appended after them, not inserted before.- A flat-binary loader,
Program::from_flat_binary, and real compiled guests. Through constraint set 3, every guest was hand-assembled againstasm.rs's mnemonic helpers; M4.1 adds ariscv32im-unknown-none-elf-targeted toolchain (upstream'sguest-sdk/guests-compiled/, not vendored into this crate — this crate has no use for compiling new guests at runtime, only for loading an already-compiled one) and a loader that turns a flat little-endian instruction image plus a basepcinto aProgram, rejecting anything that doesn't decode (isa::LoadError).guests::compiled::fib()(mirroring upstream's module, with itsinclude_bytes!path adjusted for this crate's shallower layout) loads the vendoredguests-compiled/bin/fib.binthis way;tests/e2e.rs'scompiled_fib_matches_the_hand_written_ guestandcompiled_fib_proves_and_verifies(vendored wholesale) exercise it end to end. Measured upstream (fib(20), compiled,Tier(10), 136 cycles): proof size 271,600–275,889 bytes over three proofs (varies per proof with the hiding salt). - Tiers, FRI parameters and every other table are unchanged from constraint set 3 — this is purely an input-commitment and loader addition, not a re-tune.
Proofs made under constraint set 3 (or earlier) do not verify under constraint set 4 — pv::NUM
grew, the batch grew from seven AIRs to eight, and Proof gained a field (input_log_height), so
neither the public-values shape nor the wire encoding round-trips against the old one. This is the
same hard-fork situation constraint sets 2 and 3 already documented: a node built from this commit
will fail startup ledger replay of any chain with a confidential call proved under an older
constraint set and truncate its chain. Start a new chain id, or run --verify-chain off on nodes
that must keep serving an old chain.
Disclosure implication. H_IN is a commitment, not encryption: it is binding and (thanks to
the salt) hiding against a verifier who only ever sees the published pv::IN0..7, but it is not
hiding against a party the caller chooses to show the salt and the input vector to — the same
"binding, revocably hideable" shape as any Pedersen/Poseidon2 commitment. A caller who retains the
salt (and the inputs) can, at any later time, open H_IN to a third party by handing over both —
proving after the fact exactly which private inputs a given call used, without any further
proving. This is a capability, not a flaw (auditability on demand, without a second proof), but it
means "private" here means "not disclosed unless the salt-holder chooses to disclose it," not
"provably undiscoverable" — see the shielded pool spec §6.1 for how the shielded-pool design
budgets for this when a bundle's own note commitments and nullifiers (which have their own,
separately-managed disclosure story) sit downstream of a call whose H_IN the caller could later
open.
The call-input envelope (spec §6.1, phase S3) is that opening, made durable: a caller who keeps
the salt in its own head has a capability it loses with the next laptop, so rand call publishes
the (salt, inputs) transcript on chain by default, sealed so that the caller, a per-call key or a
named auditor can open it later. See "Call input envelopes" below for the format and the rules.
Constraint set 5 (2026-09-12, upstream ffd9e1e: milestone 4.2 + audit port + FRI 80). The
vendored zkVM was re-synced past constraint set 4 to research's milestone 4.2, which also
carries the 2026-09-12 zk audit's findings back into the crate they were vendored from. The
headline changes:
-
A ninth table,
keccak(Keccak-f[1600]), and a fifth syscall,KECCAK(number 4). A guest callscall_keccak(ptr_words)and the chip permutes the 50-word state at that word address in place, reading and writing the words itself over theMEMORYbus and answering the cpu row over a newKECCAKbus. The table is 32-row blocks (24 rounds plus 8 idle rows) and 2 612 columns wide. The host reference iskeccak.rs(keccak256,keccak_f), checked againstp3_keccakon 1 000 random states; a compiled guest,guests::compiled::keccak256(), hashes a message through the syscall end to end. The sponge itself stays in guest code — the chip proves the permutation only. -
The keccak table is optional per proof.
Proofgainedkeccak_log_height: u8, where0means the proof declares no keccak table at all:machine::chipsthen returns eight chips, not nine, and the degree-bit vector is eight entries long, soverify's existing degree-bits equality check also pins the batch's instance count. This matters because a 2 612-column table costs about 1.91 MB of a production proof's FRI leaf openings regardless of how few rows it holds — every query opens a full-width main-trace leaf, so the table's width, not its height, is what a proof pays for. With the table in every proof a keccak-free tier-10 proof measured 1 142 262 bytes (at 27 queries); optional, it is back to 439 816 bytes, within half a percent of its pre-M4.2 size. No guest this chain deploys callsKECCAK, so every proof on this chain declareskeccak_log_height = 0. -
mem_log_heightis proof-declared too, and both new heights are untrusted words checked before anything is sized from them.machine::check_declared_heightsis the single place that does it — tier inTIERS, thenprogram_log_height, theninput_log_height, then the keccak table's flat range[5, 20], then the keccak-vs-tier relationklh ≤ tier + 5(a permutation costs a cycle), thenmem_log_height ∈ [tier + 2, 24].ZkExecutor::decode_and_checkcalls that same function rather than restating its rules, so the chain's admission bound on a proof's declared shape is the verifier's. -
Machine::verifier_keyis four-keyed,(tier, program_log_height, input_log_height, keccak_log_height);log_ext_degreestakes a fifth argument, the declaredmem_log_height. The memory height is deliberately absent from the key: the memory table declares no preprocessed and no periodic columns, so every valid declared height yields the sameCommonData. The injectedlog_ext_degrees_pubwrapper and every verifier-key lookup inZkExecutor(decode_and_check's degree-bits pre-check,warm,warm_bundle) follow. -
The 2026-09-12 zk audit, ported upstream and re-vendored. Two Critical soundness fixes in the cpu table: hash row-groups now have entry gates (an absorb row may only follow the ecall row or another absorb row; a write-back row only the ecall row, an absorb row, or the first write-back row; nothing follows the second) — without them a free-standing
IS_HASHrow spliced after any ordinary row gave arbitrary RAM writes at an unboundedHASH_PTR— andHASH_FINis pinned to write-back rows (HASH_FIN·(1 − IS_HASH_OUT) = 0), closing the same hole one row later. Then: the memory table's sort key is computed by addition (SPACE·2^30 + ADDR, not|), so an honest run whose Poseidon2-derived addresses cross2^30can actually be proved;Instr::encodeasserts every immediate's range instead of silently truncating it into wrong code; the emulator rejects a Poseidon2 pointer at or above2^30, matching the AIR's own bound; the auto-tier pick fits the Poseidon2 permutation budget as well as the cycle budget; and a program or private-input vector longer than 65 535 words is rejected at prove time (the digest rows'HASH_LEFTis a 16-bit value, so a longer one is unprovable at any tier) as is an explicit tier outsideTIERS. -
The production FRI profile is back to the whitepaper's: 80 queries, blowup 8, 20 PoW bits. The 27-query retune constraint sets 3 and 4 ran on met the ethSTARK conjectured 100-bit target (
3·27 + 20 = 101) but left only ~42 proven proximity-gaps bits; 80/8/20 gives ~86 proven and 260 conjectured, and the whitepaper's Part III reconciliation had already weighed exactly that trade. Measured onguests::fibimmediately before and after the revert, same machine (research/docs/03-privacy.md):tier 10 tier 12 proof size, 27 queries 435 529 bytes 460 441 bytes proof size, 80 queries 1 202 416 / 1 195 120 bytes 1 252 338 / 1 263 921 bytes prove time (27 → 80) 5.96 s → 6.05 s, 5.81 s 22.91 s → 22.78 s, 22.74 s first (uncached) verify (27 → 80) 213.2 ms → 232.7 ms 809.2 ms → 837.5 ms Two 80-query numbers are given per cell because the hiding PCS draws fresh entropy per proof, so the postcard encoding moves about a percent run to run. Proof size grows ~2.75x, a little under the 80/27 = 2.96 the query count alone suggests; prove and first-verify time barely move, since both are dominated by trace commitment and the uncached verifier-key recomputation, costs the query count does not touch. A keccak-carrying tier-10 proof measures 3 106 757 bytes.
MAX_PROOF_BYTES is 2 MiB (crates/randprotocol-core/src/gas.rs), raised from 1 MiB in the same
change: at 80 queries the 1 MiB cap rejected every production proof. 2 MiB is the smallest
power-of-two cap above the measured sizes with room for the per-proof variation, and it sits
deliberately below the ~3.11 MB a keccak-bearing proof costs. (Note 2026-09-28: 2 MiB is gas::MAX_PROOF_BYTES, the default for a genesis that does not set max_proof_bytes. The live chain does: chain 15's genesis — like chains 13 and 14 — sets max_proof_bytes to 8 388 608 (8 MiB), so a keccak-carrying proof fits there.) MAX_BLOCK_BYTES stays 4 MiB —
docs/block-space.md §5 records that decision: at ~1.3 MB per shielded transfer that is three
transfers per block, and block-level aggregation rather than a bigger block is the queued remedy.
(Both are now genesis parameters with these values as defaults; chain 13 raises them to 8 MiB and
20 MiB: Call limits and a program's public input.)
Proofs made under constraint set 4 (or earlier) do not verify under constraint set 5, in either
direction — the FRI profile differs (a 27-query proof and an 80-query verifier reject each other),
Proof gained two fields (keccak_log_height, mem_log_height) so the wire encoding does not
round-trip, and the verifier key is keyed on four components instead of three. This is the same
hard-fork situation constraint sets 2, 3 and 4 already documented: a node built from this commit
will fail startup ledger replay of any chain with a confidential call proved under an older
constraint set and truncate its chain. Start a new chain id, or run --verify-chain off on nodes
that must keep serving an old chain. A fleet must run one build.
Constraint set 6 (2026-09-14, upstream 0200877: the public input segment, carrying milestones
4.3 + 4.4). The vendored zkVM was re-synced past constraint set 5 to research's
constraint-set-6 merge, which also brings milestones 4.3 (the EVM interpreter guest) and 4.4
(the SHA-256 table and the sBPF interpreter guest) into the crate. The headline changes:
- A ninth mandatory table,
public— the public input segment — and a seventh syscall,SYS_READ_PUBLIC(number 6). A second, independently indexed input vector the guest reads withread_public(idx), committed toH_PUB(pv::PUB0..7): a Poseidon2 digest of the segment with capacity header[PUB(15), n_pub, 0], computed by the cpu table's newIS_PUBDIGESTrow prefix exactly the wayIS_INDIGESTcomputesH_IN. The buses mirror the input table's pair —PUBLIC_DIGEST(the digest's sole source) andPUBLIC_READ(the syscall's sole source, countMULT_READ) — sixteen buses in all. UnlikeH_IN,H_PUBis unsalted: the words are meant to be published with the transaction, so a verifier holding them recomputes the digest natively and compares —Machine::verify_public(hc, public_words, proof). The table is mandatory, not a third optional hash chip: every proof commits to a public segment even when it is empty (four rows,public_log_height = 2, the fixed header-only digest), because the segment is where a guest reads data the chain itself publishes — upstream's sBPF guest reads its whole ELF from it, dropping M4.4's in-circuit hash of a hiding commitment (1 753 945 → 694 498 cycles). pv::NUMis 34 and the cpu table is 275 columns (was 26 and 224): the eightPUB0..7public values and the pubdigest region's columns. The existingpvslots (OUT0,HC0,IN0) do not move, andH_PUBbindsSYS_READ_PUBLICexactly the wayH_INbindsSYS_READ_INPUT— 17 new cheating tests upstream pin the table and its digest region.Machine::verifier_keyis a 6-tuple,(tier, program_log_height, input_log_height, keccak_log_height, sha256_log_height, public_log_height);log_ext_degreestakes a seventh argument, the declaredmem_log_height(still deliberately absent from the key). The injectedlog_ext_degrees_pubwrapper and every verifier-key lookup inZkExecutorfollow, andcheck_declared_heightsgained the sha256 pair (flat range, then the sha256-vs-tier relation — the keccak table's exact terms) plus a plain range check on the public height (mandatory, so there is no0escape).ZkExecutor::decode_and_checkstill delegates to that same function, so the chain's admission bound on a proof's declared shape is the verifier's.- M4.4's
sha256table andSHA256syscall (number 5) ride along, optional per proof on the keccak table's exact and independent terms:sha256_log_height == 0means no sha256 table (the batch is then nine instances; ten with one hash chip, eleven with both). The chip is 466 + 10 columns, 64-row blocks, one compression in place over 24 words; a proof carrying it measures +92 307 bytes atFriProfile::Testand +400 563 at the production profile (upstream, same guest, instance in versus out). No guest this chain deploys calls either hash syscall, so every proof here declares both heights0. - M4.3/M4.4's interpreter guests arrive as libraries and test fixtures, not as deployed
guests.
src/evm.rs(the EVM host side:HostRef, the Poseidon2 sparse storage tree,EvmCall) andsrc/sbpf.rs(the sBPF host side and the committed SPL Token ELF) are vendored withevm-core/sbpf-coreas path dependencies (beside the repo, likerand-zkvm-cuda, but not optional —circuits/must sit besidefullnode/for any build of the crate), and the compiledevm.bin/sbpf.binbesidefib.bin/keccak256.bin. Their test suites (tests/evm_*.rs,tests/sbpf_*.rs,tests/sha256.rs, the growntests/e2e.rs) are vendored wholesale as always, because they are the upstream authority on the machine's behaviour; the EVM tier-16 call proof ine2e.rsis part of this suite now. - The chain admits only the empty public segment. (Superseded for bundles by the
transaction binding below: a bundle proof now carries its transaction's eight binding words.)
No transaction on this chain publishes
public words, so
ZkExecutor::verify_callandverify_bundleboth runMachine::verify_public(hc, &[], proof), pinningH_PUBtohash::public_digest(&[]). Plainverifywould leavepv::PUB0..7unchecked against anything outside the proof — bound in-circuit to a public segment the chain never saw — and every honest proof by today's guests (none of which callsSYS_READ_PUBLIC) has the empty segment anyway, so the stronger check costs nothing. A future action type that publishes words (a public-ELF program in the upstream sBPF shape is the obvious one) would pass them in place of&[]. (That is now the deploy's public input: a call is checked against the digest recorded at deploy, and a program deployed without one still against the empty segment's. See Call limits and a program's public input.) MAX_PROOF_BYTESstays 2 MiB. Measured on this tree (cargo test -p randprotocol-zkvm --release --test e2e measure_production_profile_at_tier_10_and_12 -- --ignored --nocapture): a keccak-free production proof is 1 298 729 bytes at tier 10 and 1 359 978 at tier 12 (constraint set 5 measured 1 202 416 / 1 252 338 — the delta is the mandatory public table, the 51 new cpu columns and the eight new public values), and a keccak-carrying tier-10 proof is 3 198 430 (set 5: 3 106 757). The cap keeps both of its constraint-set-5 properties: every hash-table-free proof the deployed guests can produce fits, with room for the hiding PCS's ~1% per-proof variation, and a keccak-bearing proof is still refused outright.
Proofs made under constraint set 5 (or earlier) do not verify under constraint set 6, in either
direction: pv::NUM 26 → 34 changes the public-values vector every proof carries, Proof
gained two fields (sha256_log_height, public_log_height) so the wire encoding does not
round-trip, the verifier key is six-keyed instead of four, and the AIR itself changed (the
pubdigest row region, the public table, its two buses). This is the same hard-fork situation
constraint sets 2–5 already documented: a node built from this commit will fail startup ledger
replay of any chain with a confidential call proved under an older constraint set and truncate
its chain. Start a new chain id, or run --verify-chain off on nodes that must keep serving an
old chain. A fleet must run one build.
Constraint set 7 (2026-09-28, circuits b9ffc39: fullnode v0.6.1, chain 16 only). The
vendored zkVM and rVM were re-synced to circuits feat/cs7, which carries every verifier-key-moving
fix the v0.6 review deferred to the next cut. The headline changes:
- Every LogUp terminal is blinded, and every declared table is floored at 2^7 rows (INT-2 /
GV-1). Each table's published running-sum terminal was a checkable function of its trace (a
call's private words, which bundle slots are real). Every instance now carries five appended
columns on a new
BLINDbus (tables/blind.rs): the honest prover moves each terminal by a uniformF_{p²}element while their sum stays zero. Because a table's running-sum columns are opened like its main columns,check_declared_heightsfloors program, input and public atMIN_LOG_HEIGHT = 7and keccak/sha256 at 7 when present (80 queries plus 2 out-of-domain points are fewer than 128 random rows). So an 8-word transaction binding declares public height 7 (it was 4), and so does the empty segment (it was 2): a pre-binding bundle proof is no longer refused on its header, but atverify_bundle(PublicValues, itsH_PUBis the empty segment's).randprotocol_core::types::BUNDLE_PUBLIC_LOG_HEIGHTis 7. - Verifier keys are salted from
key_derivation_v2(HCS-1), a Poseidon2 stream over the crate's own constants, notrand'sStdRng.randnow seeds only proving's salts. - 32-bit range checks on the input and public words and the salt lanes (ZKM-1 / ZKH-2).
POSEIDON2_LEN, syscall 7 (HCS-4):POSEIDON2with the length in capacity lane 4 and at least one permutation.POSEIDON2itself is unchanged, so no note commitment, nullifier, root or program digest moves:hc_bundleis unchanged — v183d3a370…0ef8, v2651043e2…839b.JALRclears bit 0 of its target and requiresfunct3 = 0(ISA-4). No in-tree program's words or digest change.- The pc window (#53):
Machine::prove*refuses andMachine::verifyrejects a program whose declared (floored) tablebase_pc + 4·2^program_log_heightpasses 2^32. The deploy-side mirror,randprotocol_core::program::pc_window_fits, already measured the floored table (PCW-FLOOR). - The verifier-key cache is LRU and builds each key once (#54).
- rVM: the verifier program refuses a non-zero commit-phase PoW word in-circuit (VERIFIER-1),
every chip's writes are bound on its row (#58), and the admission-stub vectors moved: inner vk
digest
ee072b7a…fbe, interface digest6059c52a…a6ee(the aggregate program digest moves).
Measured on this tree (testbox, 2026-09-28; measure_production_profile_at_tier_10_and_12 and
the two Production bundle tests in tests/hidden_bundle.rs): fib at the production profile is
1 367 688 bytes at tier 10 and 1 424 299 at tier 12 (constraint set 6: 1 298 729 /
1 359 978), a keccak-carrying tier-10 proof 3 283 898 (3 198 430); a mixed hidden-asset bundle
at tier 14 is 1 498 821 bytes (guest v1) and 1 497 156 (guest v2). MAX_PROOF_BYTES
stays 2 MiB: every hash-table-free proof fits with room, and a keccak-bearing one is still
refused outright. A Test-profile bundle proof is ~347 KB and took 226–237 s on the shared 16-vCPU
testbox (~100 s alone on the laptop under constraint set 6). The call caps stand (MAX_CALL_TIER 14,
keccak ≤ 2^12, sha256 ≤ 2^13): the worst admissible call header's verifier key builds in 11.6 s at
295 MB peak against a base tier-14 shape's 8.6 s / 220 MB on the same loaded box — the ratio
constraint set 6 measured (4.7 s against 3.9 s on the laptop), with memory flat
(measure_a_call_verifier_key_build_at_the_production_profile).
Proofs made under constraint set 6 do not verify under constraint set 7: every verifier key, the
AIR (the blind columns and bus, the range checks, JALR) and the rVM's programs changed. v0.6.1
runs only on a new genesis — chain 16. A v0.6.1 node refuses chains 14 and 15 by genesis hash at
run and verify (PRE_CONSTRAINT_SET_7_CHAINS in v0.6.1; CHAINS_THIS_BUILD_CANNOT_RUN since
v0.6.3, which adds chain 16), and chains up to 13 by their retired
hc_bundle; keep chain 15 on v0.6.
Constraint set 8 (2026-09-29, circuits feat/cs8-gas 18c2627: the gas meter, built on
feat/gas-chain18, chain 18, not yet cut). One new column and one new public value in the cpu
table (docs/superpowers/specs/2026-09-28-gas-model-design.md §4.2, docs/fees.md §1.1,
docs/zkvm.md §1, §4):
- The column,
GAS(col::GAS, right afterJALR_B0): the running gas accumulator. Row 0 — always a digest row — starts it at1; every real-to-real transition addsw_next = 1 + 2·n(IS_HASH) + 191·n(SYS_KECCAK) + 63·n(SYS_SHA256)for the row it is moving into, degree 2 (not degree 1:w_nextis itself a sum of selector products). - The public value,
GAS_LIMIT(pv::GAS = 34,pv::NUM 34 → 35): the prover's declared ceiling. On theHALTrow,pv[GAS] − GASis decomposed into fourRANGE8-looked-up witness limbsGD0..3([0, 2³²), little-endian), never opened or published — the run's exact gas stays private, and onlyGAS_LIMITis new public information. Off theHALTrow the limbs are forced to zero. - The native checks (
machine::check_public_values, before any verifier key is built): a proof'sTIERmust be inTIERS(VerifyError::Tier, native — an untrusted tier that survivesgas_max's clamp to[10, 20]is caught here first), andpv::GAS > gas::gas_max(tier, keccak_log_height, sha256_log_height)is refused (VerifyError::GasLimit). gas_maxcarries a term the design's first draft missed (the final review's Important 1, found by a probe that spent 1 078 gas against a tier-10 run's old 1 023 ceiling): the Poseidon2 absorb surcharge can run a tier over its plain cycle budget by up to2^(t−2), bounded by the Poseidon2 table's own capacity (2^(t+2)rows at 32 rows/permutation, so at most2^(t−3)absorb rows at+2gas each). Built:gas_max(t, klh, slh) = (2ᵗ−1) + 2^(t−2) + 191·⌊2^klh/32⌋ + 63·⌊2^slh/64⌋, tier and height-clamped. The tier-14 hash-free ceiling moved16 383 → 20 479; chain 18'sbundle_gas_limitmoves with it (below).- Measured deltas (constraint-set-6 baseline → cs8, re-run and reconciled against the noise
band
±6 KB,research/docs/05-roadmap.md): tier 10 (no hash) +0.15%; tier 12 (no hash) +0.56%; tier 10 + one keccak block +0.29% — four newRANGE8lookups on the packed lookups, no new preprocessed table (every pinnedcommitmenthalf is unchanged, onlycommonmoved).MAX_PROOF_BYTESis unaffected at this scale. - The rVM interface grows to
35·N(was34·N):[vk ‖ N ‖ B(8) ‖ 35·N]. Re-vendoring for the 35th word exposed a real completeness bug inrv32n's runtime-length interface sponge (it permuted eagerly, on the word that filled the rate, then once more unconditionally after the last word — correct only when the list never ends exactly on a block boundary; at 35 words per proof it does, for everyN ≡ 1 (mod 4), N = 1 included). Fixed by deferring the permute until the word that opens the next block arrives, so one block is always left pending for the final permutation. It failed closed before the fix (DigestMismatch), never wrongly accepted. The productionaggregate_program_digestmoved for both reasons (the wider interface and the sponge fix):1831f036a2d3524249df17a66a220457878f8aeed669c77db08d58026461ddd7. The phase-2 row cuts (circuits75b7893, 2026-10-04) moved it again, toc90b3f0a7758c7e306042f27a94cc1f123441b0284c7352cb3f426048c7a74d8; the inner machine, its verifier key and the interface digest did not move (recursion/docs/04-phase2-row-cuts.md). rVM phase 3 (circuits71e1a04, 2026-10-08,recursion/docs/06-phase3-fold-reduce.md) moved it once more,c90b3f0a…74d8→dc350ecf6b60af74f4bb032bdf607c3fa0fbd6317705f0b1077e71b455e38ba0; the rate-¼ rVM profile vendored with it (docs/07-rvm-rate-quarter.md) moves rVM keys and proofs, not the digest. rVM phase 4 and constraint set 9 (circuits57fb9dc, vendored atd217252, 2026-10-11) moved it twice more,dc350ecf…8ba0→e0eede8b…b6c3(Merkle spans) →8bc2cecbd30b975ec15a5e11354434bd7e1c2814d46dd0acc1d92b3ff944d8c7(the MODQ chip), and with the chip the innerinner_vk_digestmoved too, to1b0c4b0043dd5d00d710f10048bfde989a955034d7fd52f1a85595a61339ddc7(agg_executor.rs's pinned tests;docs/aggregation.md,docs/zkvm.md§8a). rVM phase 5 (circuitsb3e21d9, 2026-10-15,recursion/docs/10-phase5-n2-tier19.md) moved the program digest once more,8bc2cecb…d8c7→792a66d4a995c7b3874d38ecb8c971d5494b57b4cce13db11e08a4f9b2f9a982, with no key change (inner_vk_digestunchanged); production N = 1 is tier 18 since.
Every verifier key changes (the cpu AIR changes), so constraint set 8 rides only a chain cut —
chain 18, which also carries hardening_v6 and the genesis gas section (docs/fees.md §1.1,
§1.2). A constraint-set-8 build refuses chain 16 (constraint set 7) by genesis hash at run and
verify beside chains 14 and 15 (node::CHAINS_THIS_BUILD_CANNOT_RUN, whose chain-16 reason
v0.6.3 already carried for the wire change): chain 16 pins guest v2, which this build still
carries, so the hc_bundle check alone would let it start there and its replay (or a verify --repair) refuse and truncate the chain's history. Chain 17 (constraint set 7, v0.6.3, live
since 2026-09-29, genesis d1afefc3…e7ff) is on the list too: it pins bundle guest v3 and
hc_auth, both still carried, so nothing but its hash refuses it. See the AGENTS.md chain 18 entry for the full built state, review traps and the cut order.
The hole it closes. A transaction is {chain_id, bundle, action} and carries no signature;
a bundle's STARK proof commits only to Bundle::digest_input — anchor, nullifiers, commitments,
fee, the burn fields, time. Until this fix nothing tied a proved bundle to the action it rode
with, nor to its own envelopes (nor, while two-bundle transactions existed, to the other bundle). Anyone who saw a
transaction — a gossip peer, a proposer — could copy it, keep every proof byte for byte and change
the rest: a BridgeBurn's to, relayer_fee or (to_chain, token) (the source-chain release
then pays the attacker), a Bond's validator (the burned stake credited to someone else), a
(then) TokenTransfer's memo, an attest's r/time/envelope, any bundle's envelopes (the recipient
can never open the note), or lift a bundle into a different transaction altogether. Whichever copy
committed first spent the nullifiers. The ledger tests
a_burns_proofs_cannot_ride_a_changed_destination_fee_amount_or_asset,
a_burns_proofs_cannot_ride_another_backing, an_attests_fee_bundle_cannot_ride_a_changed_deposit,
a_bundle_lifted_into_another_transaction_is_refused, a_bonds_proof_cannot_ride_a_changed_validator,
a_transfers_proofs_cannot_ride_a_changed_memo and
a_transfers_proof_cannot_ride_a_changed_envelope_or_chain each admitted its altered copy before
the fix.
The fix — no guest or circuit change. Since constraint set 6 every proof carries a public
input segment whose digest H_PUB (pv::PUB0..7) is bound in-circuit to the words the prover
supplied — by the public table's PUBLIC_DIGEST bus, whether or not the guest ever reads them. A
bundle proof is now made with, and verified against, its transaction's binding:
binding(tx) = blake3("rand-tx-bind-1" || bincode(chain_id, bundle', action')) as 8 LE u32 words
where ' means "every bundle proof byte string replaced by the empty vector": bundle.proof and
Aggregate.proof (since the hidden-asset bundle no action carries a second bundle). Everything
else — all four envelopes, every burn field, destination, validator, recipient,
signatures, the guardian attestation, and a Call's proof — is inside. The call proof is kept
deliberately (fix round 1): a program is public and stateless, so anyone can prove their own run of
it, and a call proof outside the binding could be swapped in under someone else's fee bundle — the
victim pays, the receipt's outputs and H_IN become the attacker's, and the victim's input
envelope (AEAD-bound to its own H_IN) no longer opens. The wallet proves the call before the fee
bundle (rand call), and sealing prunes only bundle.proof, so nothing needs it blanked. Transaction::binding is the one function the
wallet (before proving) and the ledger (before verifying) both call; it hashes the decoded
transaction with the bincode configuration pinned inside it (fixed-width integers, little-endian —
what bincode::serialize writes), and Action::blanked is an exhaustive match that names every
field of every variant, so a new variant or field does not compile until it is classified as a
proof or not. the_binding_encoding_is_pinned holds a golden value.
ConfidentialExecutor::verify_bundle(hc_bundle, proof, binding): the zkVM runsMachine::verify_public(hc_bundle, &binding, proof); the stub executor carries the eight words in its stub proof and compares them, so every ledger test runs with the binding enforced.Ledger::validate_innercomputes the binding once per transaction and verifies the fee bundle and then the asset bundle against the same words, at steps 8–9, after every cheap check of both.- The bundle verifier key's
public_log_heightispublic_log_height(8) == 4(the empty segment's is 2), a single known value:ZkExecutor::bundle_heightsreturns it,warm_bundlewarms it, anddecode_and_checkrefuses a bundle proof declaring any other public height before any verifier key is built. prove_bundle(profile, inputs, binding, backend): the wallet (randprotocol-client'swallet::prepare_bundle→prove_transaction) builds the whole transaction first — witnesses, outputs, envelopes (sealed against the output notes, never the proof), the action — takes its binding, then proves its bundle with it. The bundle still lands at tier 14 (tests/shielded.rs, 101.9 s at the test profile, 324 387 bytes).
The hidden-asset bundle's proof is pinned to tier 14, with neither the keccak nor the sha256
table — a token transfer, mint, burn or bridge deposit runs the same guest as a plain RAND
transfer, and none of them call either hash's syscall, so both tables stay absent regardless of
which asset the bundle moves. A bundle whose declared shape asks for a different tier or either
table is refused before any proof is verified, the same cheap-before-expensive rule every other
declared shape gets (ZkExecutor::check_program/decode_and_check).
Transaction::hash(the txid) is unchanged.
What this does not cover. The bundle-less actions (Mint, Unbond, Withdraw,
UnbondAggregator, WithdrawAggregator, SlashAggregator, Aggregate) have no proof to bind;
they rest on their own signatures (audited with this fix; Aggregate's envelope is outside its
signed message — a recorded follow-up, aggregation being inactive on every chain). A Call's own
proof is inside the binding, so the fee bundle's proof binds the whole call — program, proof
and input_envelope.
The marker form outside sync (fix round 1). A sealed block's pruned bundle carries
PRUNED_PROOF_MARKER ‖ digest(proof) in place of its proof, and hashes to the raw transaction's id
by design (M1). Outside sealed-form sync — admission, gossip, RPC, a proposer — a marker-form proof
in tx.bundle (no side-table entry vouching for it) or in any asset bundle is refused at step 1
with TxError::PrunedFormOutsideSync, which is deliberately not a permanent admission verdict:
the refused cache keys on tx.hash(), and caching a marker-form copy's refusal under the shared id
would let any gossip peer censor the honest transaction. The ledger also drops a block's side table
once the block has applied.
Pruned (sealed) transactions. A pruned bundle's proof is a marker, verified through its
covering aggregate rather than verify_bundle, so the binding is not checked on that path. What
authenticates a pruned transaction's action and envelopes is its id: Transaction::hash covers
chain_id, every bundle field including both envelopes (the proof by its digest) and the action
as-is; Block::verify_tx_root recomputes the root from the served transactions' hashes inside
Ledger::apply_block_for_sync, before any transaction is applied; the header commits to that root
(BlockHeader::hash); and node.rs's apply_synced verifies the QC over that header hash against
the epoch's set before it applies the block. A marker-form transaction hashes to its raw hash, so
a peer that changes anything outside the proof bytes fails the root. The honest raw transaction was
verified against its binding when the validators first admitted it.
Block aggregation must be re-measured before it is ever activated. Its admitted shape
(docs/aggregation.md, "The fleet bundle's declared shape") was measured with the empty public
segment — public 2 — and a bound bundle declares public 4, so no bundle proved after this fork
matches it. Aggregation is inactive on every chain (chain 14 is cut without it); re-measure the
shape (and consider checking a covered bundle's PUB0..7 against its transaction's binding in the
covered-record path) before cutting a chain with an aggregation section.
Operational. Every wallet and relayer must run the new binary at the fork: an old rand
produces empty-segment bundle proofs, which are refused (public height not the transaction binding's). A chain-13 genesis still builds, but every chain-13 transaction carrying a bundle is
refused by this build — the intended hard fork; it ships with chain 14.
Programs are content addressed: program_id = blake3("rand-program" || base_pc || words),
or, for a program deployed with a public input,
blake3("rand-program-2" || base_pc || u32_le(len(words)) || words || u32_le(len(public)) || public).
A Deploy transaction stores { base_pc, words, public } (at most the chain's program cap: 4096 words, or
the genesis file's max_program_words, at most 65 535, when it sets one; public at most
max_program_public_words, 0 unless the genesis sets it); every word must decode as an
instruction. Programs are immutable and part of the state root. The record keeps public_len
and public_digest (H_PUB of the public words); the node keeps the words themselves and serves
them with rand_getProgramPublic.
Calls carry { program, proof }. The proof is postcard(rand_zkvm::Proof) (tier, public
values, batch STARK proof). There is no recipient list: it existed only for effect kind 1.
Outputs. The eight output words are published data, not an instruction to the chain. They are
recorded in the receipt verbatim and nothing follows from them; out0..out7 mean whatever the
program and its caller agree they mean. The old layout (out0 effect kind, out1 recipient index,
out2|out3 a little-endian u64 amount) survives only as a convention inside guests like
private_payment, which still writes [1, 0, amount_lo, amount_hi, …]; on this chain that is a
statement, not a payment.
Receipts { tx, program, tier, outputs, height, index, h_pub } are stored per call and served by
rand_getReceipt (h_pub is the program's public digest, null for a program without a public
input); they are recomputed and checked when a node syncs or verifies its chain. There
is no effect field.
- Both: the transaction carries a bundle, and that bundle passes the shielded admission order
(
docs/shielded.md§5) — anchor in the 256-block window, neither note already spent, the recomputed digest equal to what the bundle proof published, and the bundle proof valid against the genesis-pinnedhc_bundleand against the transaction's binding (see "Transaction binding" above). - Deploy:
words.len() <= max_program_words(genesis; 4096 when the file does not set it, and never above 65 535, the word count the zkVM can prove),base_pc % 4 == 0, every word decodes,public.len() <= max_program_public_words(checked first, before any fee or code work),fee >= BUNDLE_BASE + 100_000 * (words + public). - Call: program exists;
proof.len() <= max_proof_bytes(genesis — 8 388 608 on chains 13–15; 2 MiB,gas::MAX_PROOF_BYTES, when the file does not set it: raised for constraint set 5's proof sizes, re-measured and kept at constraint set 6's); the input envelope withinmax_call_envelope_bytes(18 432 by default); the proof's header is pinned before any verifier key is built (next paragraph); the proof verifies against the stored program'shcfor the tier it declares, and itsH_PUBequals the program's recorded public digest (the empty input's for a program without one), or the call fails withPublicValues;fee >= BUNDLE_BASE + call_fee(tier, bytes), checked last, once a verified proof has revealed the tier. - A call proof's header is pinned against the deployed program before any verifier key is
built (deep scan 2026-09-24, zkvm;
ZkExecutor::verify_call). The declared tier is at mostexecutor::MAX_CALL_TIER(14 — the highest tierwarmpre-builds, and the two are one expression), or the call is refused asCallTierTooHighnaming the cap and the remedy; the declaredprogram_log_heightequalsprogram_log_height(words.len())of the deployed record, exactly as the prover derives it; the declaredinput_log_heightis at mostmin(tier + 2, 16)(executor::max_input_log_height— four input words a digest cycle, and the 16-bitHASH_LEFTcap); the declaredkeccak_log_heightis at mostexecutor::MAX_CALL_KECCAK_LOG_HEIGHT(12, 128 permutations) andsha256_log_heightat mostexecutor::MAX_CALL_SHA256_LOG_HEIGHT(13, 128 compressions),0— no table — always admitted; the public height equals the record'spublic_lenclass (the earlier pin). Why:Machine::verifybuilds the verifier key for whatever shape a header declares before it reads a byte of STARK data, every check in front of that build is satisfiable from public data, and the key is priced by the tier and by the two hash tables' preprocessed columns — measured at the production profile (tests/executor.rs'smeasure_a_call_verifier_key_build_at_the_production_profile): tier 14 ≈ 3.6 s and 227 MB, tier 20 ≈ 216 s and 6.5 GB; at tier 14 a keccak table at the tier's own honest bound of 2^19 rows is 128 s and 8.7 GB on its own, a sha256 table at 2^20 is 48.7 s and 3.3 GB, and the program, input and public tables at their maxima cost nothing extra. The validators are 2–4 GB droplets, so one legal such header (its transaction never mined, its fee bundle's note never spent, so resubmittable forever) was an out-of-memory kill of the admitting node. Under all four pins the worst admissible header — tier 14, program and input tables at 2^16, public at 2^15, both hash tables at their caps — builds in 4.7 s at 312 MB peak (the bundle key's own order), and eight such keys retained by the cache together peak at 711 MB.gas::MAX_TIER(20) remains the prover's ceiling; a call that needs more is refused, and raising a cap means raising whatwarmcovers with it and re-measuring the worst admissible shape on a fleet droplet. Every chain-14 call to date is a tier-10 call against a 42- or 43-word program with no hash syscall (fee 2 000 000 on all 340 of them); chain 13's ERC-20approvewas a tier-16 call, which this rule would refuse. What the pins do not bound isMachine's 64-entry key cache, which retains every key it builds (upstream code, vendored): distinct admissible shapes, each paid for with its own fee-bundle proof, can fill it with 64 keys of the capped size. - Any transaction: its encoding at most
max_block_bytes(4 MiB by default), and a block's transactions within the same cap. - A block with an invalid call is invalid, like any other invalid transaction.
| operation | minimum fee |
|---|---|
any bundle (BUNDLE_BASE) |
0.001 RAND |
| shielded transfer | 0.001 RAND (the base alone) |
| Deploy | 0.001 RAND + 100,000 units per word (0.0266 RAND for 256 words) |
| Deploy with a public input | as Deploy, counting the public words with the code words |
| Call | 0.002 RAND at tier 10, plus 0.0001 RAND per two tiers above it (0.0025 at tier 20), plus 0.000001 RAND per KiB (or part) of call proof and input envelope past 2 097 152 + 18 432 bytes |
| BridgeAttest | 0.001 RAND (the base alone) |
| BridgeBurn | 0.01 RAND (BRIDGE_BURN_FEE) — the base plus the bridge's charge, both out of the one bundle a burn carries. A deposit (BridgeAttest) pays only the base: the depositor has no RAND yet |
RPL TokenBurn |
0.001 RAND (the base alone) |
| Mint (faucet) | free, and carries no bundle |
Every floor above the mint's includes BUNDLE_BASE, because every one of those transactions
carries a bundle. Since the hidden-asset bundle (spec §3.7 of
docs/superpowers/specs/2026-09-19-hidden-asset-bundle-design.md) every action carries exactly
one: a burn spends the token from that bundle's slots 0–1 (burn_a, burn_asset) and pays
the RAND fee from slots 2–3 (fee) of the same proof — there is no longer a second, asset-only
bundle. BridgeBurn pays BRIDGE_BURN_FEE because it is the one bridge transaction whose sender
is sure to hold RAND; RPL's TokenBurn pays the plain base like any other one-bundle action.
Anything above the minimum is a tip; all of it is credited to the block proposer's rewards in
the validator register, which phase S2's Withdraw turns back into a note. Blocks hold at most
4 MiB of transactions on a chain without max_block_bytes (20 MiB on chain 13), and the
hidden-asset bundle's proof is ~1.43 MB at the 80-query production profile (docs/rpc.md's "The
transaction on the wire"), so two shielded transactions per block at that default (it was
three at the two-bundle shape's ~1.3 MB, and roughly a dozen at constraint set 3's 27 queries;
docs/block-space.md). Constants live in randprotocol_core::gas; rand fee bundle|deploy <words> [--public-words M]|call <tier> [--bytes B] asks the node.
Public: program id, tier, the eight outputs, the bundle's fee, and — since constraint set 4 — the
salted input commitment H_IN (proof.public_values[ IN0..IN7]). H_IN being public does not make any input word public: without the salt (never
published, never leaves the prover) it cannot be opened, so on its own it only pins two reads of
the same input index to agree and makes an out-of-range read unsatisfiable — see "Disclosure
implication" above for what happens if a caller later reveals the salt. Private: inputs, registers,
memory, branches taken, cycle count (padded to the tier), the H_IN salt itself — and, since S1,
who called it and what they paid with: the bundle publishes its nullifiers and commitments and
names nobody. Two proofs of the same run are different bytes (hiding commitments).
Corrections (the 2026-09-27 zkVM/ISA review — read these before relying on the paragraph above):
- Small tables are not hidden (INT-1 family: INT-1, COV-2, INT-6, HCS-3; High, live on
chain 15). A proof opens each committed table at 80 FRI queries plus two out-of-domain points,
and a table is hiding only while it has more random rows than that. Before v0.6's prover floor,
a call with 31 or fewer private words proved an input table of 2^3–2^5 rows, and the review
recovered every private word from production proofs — so inputs of such calls are public in
the proofs already on chain 15. The same holds for a small keccak or sha256 table (a 128-byte
secret was recovered from a one-permutation keccak call), 64-row tables leak Boolean and
small-count columns by lattice reduction, and the program table of any program under 64
words (every chain-15 program is 43) publishes its fetch counts — the branches taken. The
declared
keccak_log_height/sha256_log_heightsay whether and roughly how often a call hashed. Fixed without a cut for input/keccak/sha256 (the v0.6 prover floors them at 2^7 rows and every v0.6 pool refuses a call proof that does not,CallRevealsPrivateInputs); the program table only under genesishardening_v6(docs/deploy.md, "The program-table floor"). Calls already committed stay exposed. - Bundle proofs reveal their real-slot pattern (INT-2 / GV-1; High, live). The per-table LogUp
totals are published unblinded, and the hidden-asset guest branches on dummy slots and on each
Merkle path bit, so every bundle proof tells which input slots are real (hence whether it moves
a token or only RAND) and the popcount of each spent leaf index. The fix is a circuit change —
blinded LogUp totals or a branch-free bundle guest — and needs a chain cut with a new
hc_bundle; it is not fixed on chain 15. - A call's LogUp totals let low-entropy private inputs be brute-forced (INT-2 on calls; the
v0.6 rescan). The input table's total is an unsalted function of every committed input word
(
H_IN's salt is not in it), and the 2^7 floor hides rows, not totals — so a call whose inputs are a few small words can be matched by enumeration. A program handling such inputs should read at least two uniformly random private words into its computation; the generic fix, LogUp blinding, is chain 16's.docs/shielded.md, "What the proofs leak today", has the detail and the branch-free bundle guest's two remaining Merkle-bit residuals. - Proofs do fingerprint their shape where a header field is free (INT-5's memory height,
VERIFIER-2's FRI schedule): every v0.6 pool refuses a non-canonical header, and the ledger does
under
hardening_v6(docs/deploy.md, "Canonical proofs").
Two residuals that are still true on chain 18 (audit v6, HCS-3 and HCS-4; stated 2026-09-30).
- A call proof's header shows whether the call hashed, and roughly how much (HCS-3; Low,
live). Every proof declares
keccak_log_heightandsha256_log_heightin the clear, and0means "no such table". So anyone reading a call proof learns whether the call used theKECCAKorSHA256precompile at all. The 2^7 floor removes small counts (one to four keccak permutations, or one or two sha256 compressions, all declare 7), and the chain caps what a call may declare (keccak ≤ 2^12, sha256 ≤ 2^13,MAX_CALL_TIER14); between the floor and the ceiling the height shows the count to within a factor of two, because the table doubles. The memory table's declared height can move with a hashing call too. The prover can hide presence:ProveOptions::pad_absent_hash_tablesdeclares both tables at the floor even when the run never hashes, and the ordinary verifier accepts the proof. It is off by default, and no wallet sets it: on a tier-10 call that never hashes, the production proof grew from 1 305 415 to 3 622 604 bytes (one laptop run each;machine.rs's doc comment on the option), against chain 18's 4 MiB proof cap and its per-byte price. It also hides nothing unless the calls it hides among use it too. So "a call proof reveals only its public values" is not true of the header: the tier and both hash-table heights are visible. Turning the option on for calls with private inputs is a wallet- or chain-wide decision that has not been taken. - The legacy
POSEIDON2syscall is not length-injective (HCS-4; Low, live). It is an overwrite sponge with no padding, so[a]and[a, 0]hash alike. It is left exactly as it is because every note commitment, nullifier, tree root and program digest depends on it; the shipped guests call it on fixed-length inputs only. Constraint set 7 addedPOSEIDON2_LEN(syscall 7), the same sponge with the message length in capacity lane 4. A new guest must usePOSEIDON2_LENfor anything whose length an adversary can choose (asm::call_poseidon2_lenin this repo's assembler; in circuits' guest SDK ataeacf31,poseidon2_lenis the safe call and the legacyposeidon2isunsafe). No shipped guest and no consensus path uses syscall 7 yet; retiring the legacy syscall (3) is for a constraint set that re-derives every hash.
A precondition recorded beside the call cap (audit v6, ZKV-6; 2026-09-30). evm-core places a
contract storage leaf by the top 32 bits of keccak256(slot), so a slot colliding with a chosen
victim's costs about 2^32 Keccaks and, while it holds a non-zero value, freezes the victim slot
(no forgery, no lost value). It is dormant: the EVM interpreter is 18 009 words and the most a
call can hold at the tier-14 cap is 8 184 (executor::max_callable_program_words). Before any
chain lifts the tier-14 call cap (MAX_CALL_TIER) or admits an EVM runtime small enough to call,
evm-core's storage index must be deepened and evm.bin re-pinned. The same note sits on both
constants in crates/randprotocol-zkvm/src/executor.rs.
A call's private inputs are private because nothing published them — but the caller may want to be
able to show them later: to an auditor, to a counterparty, to itself on another machine. H_IN
makes that possible — it is a binding commitment to every word the guest read, and the salt is what
keeps anyone else from opening it — so the capability lives exactly as long as the caller keeps the
salt, which is to say not past the next laptop. The call-input envelope (spec §6.1) is that
disclosure written to the chain instead.
Action::Call { program, proof, input_envelope: Option<CallEnvelope> }
CallEnvelope { kem_ct: Vec<u8>, to_sender: Vec<u8>, to_auditor: Vec<u8>, body: Vec<u8> }
| part | bytes | what it is |
|---|---|---|
body |
12 + 16 + 4·n + 16 | salt || inputs, each word little-endian, sealed under a fresh per-call key K_call |
to_sender |
60 | K_call wrapped to the caller's outgoing viewing key (ovk) |
kem_ct |
1088 or 0 | an ML-KEM-768 encapsulation to the auditor's address, empty with no auditor |
to_auditor |
60 or 0 | K_call wrapped under that encapsulation's shared secret |
Everything is ChaCha20-Poly1305 with a random 12-byte nonce prepended, and the domain tags
(rand-call-sender, rand-call-auditor) are distinct from the note layer's, so no wrap of one
kind is ever a wrap of another. K_call is drawn from OS entropy per envelope and is not derived
from any other key: handing one over says nothing about any other call.
Three keys open it, and no more:
| key handed over | who holds it | what it opens |
|---|---|---|
the caller's viewing key (through ovk) |
the caller's wallet | every call that wallet made |
the per-call key K_call |
the caller, and whoever it gave it to | that one call |
| the auditor's viewing key | the auditor named when sealing | that one call |
What the chain does and does not do. It checks the envelope's size and nothing else:
max_call_envelope_bytes from the genesis, 18,432 (MAX_CALL_ENVELOPE_BYTES) when the file does not
set it (call_envelope::validate, step 7 of admission). The wallet derives its input-word cap from
it: (max_call_envelope_bytes − 1 252) / 4, where 1 252 bytes is an envelope with an auditor and no
inputs — 4 295 words by default, 16 071 on chain 13's 65 536. It holds no key that opens any of it, never looks
inside, and serves it verbatim to anyone who asks (rand_getCallEnvelope, alongside the receipt's
h_in). (A viewing key imported for note scanning — rand_importViewingKey, docs/shielded.md —
opens note envelopes only; a caller's viewing key would open its own calls' sender wraps, but
nothing on the node tries: the scan runs over the commitment tree's leaves, and call envelopes are
not part of it.) The envelope is part of the transaction, so it is part of the transaction hash.
What binds an envelope to its call is the AEAD: the body's associated data is the 32 bytes of
H_IN as the receipt publishes them, so an envelope lifted onto another call authenticates for
nobody. What makes an opened transcript faithful is the holder's own recomputation —
input_digest(salt, inputs) == H_IN — because H_IN commits in-circuit to every word the guest
read. A caller who seals a transcript that is not the preimage is not stopped by the chain; they are
caught by whoever decrypts, who can show that decryption to anyone. rand open-call checks
exactly this, then re-runs the program on the recovered inputs through the emulator and compares the
outputs with the receipt's. Both checks are the command's exit status, not just a printed line:
an unfaithful transcript, or one whose words do not reproduce the receipt's outputs, exits non-zero,
so a script that opens a disclosure to check a claim cannot read a warning beside a zero exit as a
yes.
rand call <id> --input 400 --input 250 --auditor rand1q9f… # seals for the caller and the auditor
rand call <id> --input 400 --print-call-key # also prints K_call
rand call <id> --input 400 --no-envelope # publishes nothing
rand open-call <txhash> # as the caller
rand open-call <txhash> --as-auditor # as the auditor
rand open-call <txhash> --call-key <hex> # with the per-call key alone--no-envelope is the deliberate opposite: nothing is published, and once the salt is gone nobody
— the caller included — can ever open that call. --cuda cannot seal one either: every backend but
the CPU draws the H_IN salt inside the prover and never returns it, so rand call --cuda fails
unless --no-envelope is passed with it. The forfeit is always asked for explicitly; a call is never
quietly downgraded to one.
Covered end to end by a_call_envelope_is_opened_by_the_caller_and_the_auditor_only
(crates/randprotocol-node/tests/cluster.rs), which opens the bytes a node served as the caller and as
the auditor, fails to open them as a third wallet that is neither, and catches both a tampered
transcript (the faithfulness check) and a tampered ciphertext (the AEAD).
Chain 13 (spec docs/superpowers/specs/2026-09-19-call-limits-design.md) makes four call limits
genesis parameters and lets a deploy fix a program's public input. A genesis without the fields
hashes, and behaves, exactly as before.
| field | absent | bounds | chain 13 |
|---|---|---|---|
max_proof_bytes |
2 097 152 | 1 MiB ..= 32 MiB | 8 388 608 |
max_block_bytes |
4 194 304 | 4 MiB ..= 64 MiB, ≥ 2 × max_proof_bytes + 1 MiB |
20 971 520 |
max_call_envelope_bytes |
18 432 | 18 432 ..= 1 MiB | 65 536 |
max_program_public_words |
0 | 0 ..= 65 535 | 32 768 |
- Every proof cap follows
max_proof_bytes: the call's, each bundle's, the bridge burn's and the aggregate's. A keccak-carrying call proof (3 198 430 bytes at tier 10, production) fits under 8 MiB. - The node's transport limits follow the block cap: the sync budget is
max_block_bytes + 2 MiB, the sync reader limit twice that plus 256 KiB, and the gossip transmit sizemax(16 MiB, max_block_bytes + 1 MiB). A default chain keeps 6 MiB, 12.25 MiB and 16 MiB. - A public input is fixed at deploy, never per call. A proof's public input is visible to
every verifier, so the chain accepts it once, with the program: every call to the program shows
the same public words, already on chain since the deploy, and everything that varies per call
goes through the private tape and the sealed envelope. The ledger records
H_PUBat deploy andverify_callcompares the proof'spv::PUB0..7with it afterverify. - The wallet:
rand program deploy <image> --public <words file | ELF>;rand callfetches the public input, checks it against the program id, and proves over it;--expect-public <FILE>refuses before proving on a mismatch; a proof overmax_proof_bytesis refused before the paying bundle is proved (cli.md).
The translated SPL Token (docs/translators.md) is the program this was built for: its image reads
the 27 151-word ELF from the public tape and 10 458 private words, so it needs all three raised
limits.
Genesis "confidential": false makes Deploy and Call invalid; "fri_profile" selects
production (default) or test (16 queries, insecure, for the test suite). Both are part of the
genesis hash, so nodes with different settings cannot join the same chain.
rand program build --guest <name> --arg ... assembles the built-in guests: fib n, memcpy n,
bubble_sort v..., balance_check threshold, private_payment threshold. Any RV32I program in the
supported subset can be deployed from a .json ({ "base_pc", "words" }), a raw .bin
(little-endian words, loaded from base 0), or a .bin produced by the sibling circuits repo's
rand-guest build — a real riscv32im-unknown-none-elf toolchain build, as opposed to a guest
assembled by hand against randprotocol-zkvm::asm.
rand-guest build emits the M4.3 image container: six little-endian header words
([0x444e4152, 1, text_base, n_text, data_base, n_data]), then the guest's text, then its data
segment. rand program deploy recognises the container by its first word — 0x444e4152
(IMAGE_MAGIC, b"RAND" read little-endian) never decodes as an RV32 instruction, so a raw
.bin can never be confused for one — and loads it the same way the prover does,
isa::Program::from_flat_image, which synthesises a prologue below the text that writes the data
segment into RAM before the guest's own code runs. That is what makes a compiled guest with a
.rodata (jump tables, panic locations, any constant LLVM did not rematerialise) deployable at
all: the M4.1 flat-binary loader populated instruction space only, so a guest with real data read
zeros. A file that starts with IMAGE_MAGIC but is not a well-formed container is a deploy error,
not a silent fallback to raw words.
Before proving anything, program deploy prints the program id, the word count, and hc — the
in-circuit Program::digest, hex-encoded the one way the chain ever shows it: the eight digest
words each in little-endian byte order, concatenated, the same form rand_getProgram and
rand program show print as code_hash (not Program::code_hash()'s own string, which hex-encodes
the same words big-endian and reads different for the same program). Then it calls
rand_estimateFee for a deploy of that word count. That RPC applies this chain's own
max_program_words admission (the cap above), so a program over the cap is refused there — for
the cost of one RPC round trip — rather than after the minutes it takes to prove a bundle the
ledger would then throw away.
A deployable program is not always a callable one (CPU-1 and its residual, issue #57). Every
call proof spends one Poseidon2 permutation per four program words, plus its private inputs' and
public segment's digests, before it executes an instruction, and a call is capped at tier 14
(MAX_CALL_TIER, 2 048 permutations). A deploy past the bound for a call with no private
inputs — 8 184 words, 8 180 under hardening_v6, fewer beside a public input — is refused
(ProgramUncallable: the pool's policy everywhere, a validity rule under the flag). That bound is
necessary, not sufficient: a program inside it can still be uncallable at every tier once a call's
inputs are digested too, or once it runs longer than tier 14 holds, and its deploy fee is then
paid for nothing. rand program deploy --input <u32> … (or --check-call for a call with no
inputs) runs the call you have in mind through the emulator first — no proving, the public segment
at the length the chain proves over, zero-filled — and refuses unless it lands at or under tier 14.
Writing, building and deploying a guest step by step (Rust, C, or a hand-built image) is in
guests.md. Deploying translated Solana and Ethereum programs is in
translators.md.
Where the vendored compiled guests come from (audit CS6-1). This repository carries four
compiled guests (fib.bin, keccak256.bin, evm.bin, sbpf.bin) and two test assets
(erc20.runtime.hex, spl_token.so) under crates/randprotocol-zkvm/guests-compiled/. None of
them is built here. PROVENANCE.md
in that directory names, for each file, its sha256, the public circuits commit and source path it
was copied from, and the command that rebuilds it: rand-guest build for the guests; solc 0.8.37
over ERC20.sol; for the SPL Token ELF, a fetch of the immutable mainnet program account (its
source release is not recorded). SHA256SUMS beside it is checked by
tests/guest_provenance.rs. CI's guest-provenance job compares each copy with circuits at
CIRCUITS_PIN and rebuilds all four guests and the ERC-20 bytecode from source there. The guest
chain 14 actually runs, the hidden-asset bundle, is not among them. It is assembled from
guests::bundle_hidden()'s source in this crate, and the same test pins its hc to the genesis.
The hidden-asset bundle guest: soundness (v0.5, chain 14's bundle guest)
guests::bundle_hidden() (spec docs/superpowers/specs/2026-09-19-hidden-asset-bundle-design.md)
is the four-slot shielded transfer whose asset A is private, and it is the only bundle guest
chain 14 pins: ZkExecutor::bundle_program() returns hidden_bundle_program(), and
genesis.hc_bundle commits to its hc. The two-bundle shape (a separate RAND fee bundle and
asset bundle) and the old bundle() guest are gone from the chain path — bundle() survives in
guests.rs only as ZkExecutor::legacy_bundle_program(), which no chain pins, kept for the tests
that still compare against it. It enforces each check of spec §3.3
in one of two ways. Structural means the guest supplies the value itself, so a prover cannot
choose it. Taint means the check ORs a 0/1 failure bit into BAD (x9). BAD is the last word
of the published digest, and the ledger recomputes the digest with bad = 0, so a tainted proof
still verifies but matches no plaintext. Three suites back each row:
- the emulator tests in
crates/randprotocol-zkvm/tests/hidden_bundle.rs(H1); - the real Production proofs in
tests/hidden_cheating.rs(real_proof_*, H2): each cheat proves and verifies, and its digest is not the one the ledger recomputes; - the mutation fuzz in the same file (
mutation_fuzz_*). It runs 25 000 seeded mutated witnesses against an independent host model of §3.3 and requires the guest to publish exactly the model's digest: honest if the model finds the witness valid,bad = 1otherwise. It also requires that every taint check be, in some run, the only check that fails. So removing any one check makes some run publish an honest digest that the model rejects.
The fuzz was checked for teeth. deploy/weaken-hidden-guest.sh weakens one check at a time in a
throwaway edit of guests.rs, runs the fuzz, and restores the file. There are twelve variants:
- each taint removed: anchor, input asset, both duplicate loops, each duplicate loop alone, range, carry, and the sum comparison;
- the asset check skipped for the R slots only, and for the A slots only;
- the dummy skip reading only the low amount word;
- the
burn_assetmask removed.
The fuzz caught all twelve, each in about 12 s including the rebuild.
| §3.3 check | Enforced by (guests.rs, bundle_hidden) |
Emulator test (hidden_bundle.rs) |
Real proof (hidden_cheating.rs) |
Fuzz (model label; weakened copy caught) |
|---|---|---|---|---|
nk, pk_self from sk; every input owned by pk_self |
structural: emit_derive_keys; emit_stage_note(.., PK, ..) for every input (there is no owner word in the input) |
a_note_owned_by_another_key_taints |
covered by the membership rows (a foreign note stages to a leaf not in the tree) | mutations of sk and note words (reported as membership failures) |
Non-dummy input: its Merkle root equals anchor |
taint: the fused MERKLE_VERIFY loop (li PTR, in_slot(k)+S_PATH, stride 8, DEPTH levels), then emit_eq8(ANCHOR, ROOT_TMP) → xori → emit_or_into |
a_real_input_under_another_root_taints |
real_proof_a_path_that_proves_a_different_leaf, real_proof_a_path_to_a_root_other_than_the_anchor |
membership: root != anchor; the anchor taint removed: caught |
| Fused-loop sibling index cannot be redirected | structural: PTR/CTR are program constants plus a fixed stride, pinned by hc; the prover chooses only the path words |
none; the tampered-sibling and tampered-index cases in a_real_input_under_another_root_taints exercise the path and leaf index, not the read index |
none | none. The evidence is the structural argument alone: the read index is fixed by the program, so no witness can change it, and a program change changes hc. The fuzz and the Merkle proofs mutate the words at those indices (the path and leaf index) and are caught, which is consistent with the argument but does not test a redirect |
Non-dummy input's asset equals its slot's (A / 0) |
taint: xor + sltu of slot(S_ASSET) against ASSET_A or the guest-zeroed ZERO → emit_or_into |
an_input_of_the_wrong_asset_taints |
real_proof_an_a_slot_input_of_another_asset (slots 0–1), real_proof_an_r_slot_input_whose_asset_is_not_0 (slots 2–3) |
both groups of slots, each failing alone. A-slot runs are labelled input asset != slot asset (A slot); R-slot runs are labelled (R slot) and come mainly from the swap-two-input-slots mutation, which keeps membership and unique nullifiers. Caught with the whole check removed, with R slots unchecked, and with A slots unchecked |
| A dummy carries no value | structural: the skip branch tests or(T1, T2) of the same registers stored to IN_AMT, and nothing reloads them |
a_dummy_cannot_carry_value |
real_proof_a_dummy_carrying_value_in_its_high_word, …_in_its_low_word |
amount-word mutations of dummies; a skip that reads only the low word: caught |
nf_k = H_NF(nk, cm_in_k) for all four inputs |
structural: emit_nullify for every slot, dummies included |
every_honest_shape_publishes_the_host_digest |
real_proof_honest_control_publishes_the_ledgers_digest |
every run compares all four nullifiers with the model's |
Outputs: from = pk_self, the slot's asset, the bundle's time |
structural: emit_stage_note(.., PK, .., slot_asset(k), hdr(TIME), ..); the input has no field for these |
an_outputs_asset_is_its_slots |
the control (the published commitments are the model's) | mutations of A and time move every commitment exactly as the model does |
| The 6 nullifier pairs differ | taint: emit_eq8 over NF pairs → emit_or_into |
every_duplicated_nullifier_pair_taints |
— (emulator only; the proof of a tainted run is shown by the other taint rows) | duplicate nullifier (a whole input slot copied); the NF loop removed: caught |
| The 6 output-commitment pairs differ | taint: emit_eq8 over CM_OUT pairs → emit_or_into |
every_duplicated_output_pair_taints |
— | duplicate output commitment (an output copied); the CM loop removed: caught |
< 2^63 on 8 amounts, fee, burn_a, burn_r |
taint: emit_range_check_u63 on each high word → emit_or_into |
each_of_the_eleven_range_checks_taints |
real_proof_an_r_output_at_2_63_with_only_the_range_check_to_stop_it |
amount >= 2^63; the range taint removed: caught |
| Each sum is carry-checked | taint: emit_add64_carry → emit_or_into per addition |
a_wrapping_sum_taints_in_either_group |
real_proof_an_r_sum_that_wraps_with_only_the_carry_check_to_stop_it |
asset-A sum passes 2^64 / RAND sum passes 2^64; the carry taint removed: caught |
in0+in1 = out0+out1+burn_a, in2+in3 = out2+out3+fee+burn_r |
taint: sub/or/sltu on the two totals → emit_or_into, once per group |
value_cannot_cross_between_the_asset_groups |
real_proof_value_moved_from_an_a_slot_into_an_r_slot and …_from_an_r_slot_into_an_a_slot (both groups fail), real_proof_group_a_alone_mints_one and real_proof_group_r_alone_mints_one (one group each) |
asset-A conservation / RAND conservation, each failing alone; the compare taint removed: caught |
burn_asset = A if burn_a != 0, else 0 |
structural: sltu/sub mask and ASSET_A; there is no witness word for it |
burn_asset_is_a_exactly_when_burn_a_is_nonzero |
real_proof_a_burn_claiming_another_asset |
every run compares burn_asset with the model's; the mask removed: caught |
Digest: tag 64, bad last, no A |
structural: the staging at step 8, checked against hidden_bundle_preimage by assert_eq! at assembly |
the_digest_is_domain_separated_and_has_no_asset_field |
every real_proof_* (the verifier's hidden_bundle_proof_digest equals the emulator's) |
every run |
Commands: the fast suite is cargo test --release -p randprotocol-zkvm --test hidden_cheating -- --skip real_proof_ (185 s measured on the laptop with the split-authorisation fuzz and emulator
companions below). The real proofs are … --test hidden_cheating real_proof_ (twenty-six: the
nineteen v1/v2 cheats, about 100 s and 5.7 GB each, ~32 min; and the seven split-authorisation
cases, ~6 min, which share three v3 bundle proofs and four auth proofs made once per run). They run one at a time behind the workspace
proving slot, <target-dir>/tmp/rand-proving-slot.lock, the lock the node and client proof tests
also take. To replay a fuzz failure, set HIDDEN_FUZZ_SEED and HIDDEN_FUZZ_ITERS. The teeth
check is deploy/weaken-hidden-guest.sh [variant …], about 2½ min for all twelve variants.
Delegated proving Phase 2 (spec docs/superpowers/specs/2026-09-28-delegated-proving-design.md
§4; docs/shielded.md §2) splits the spend authority out of the bundle guest. Bundle guest v3
(guests::bundle_hidden_v3(), hc 60af094acfe65d85fdb18fb3d06cf9085dcf28c96e59e87f1ee527226e6e3fce)
is v1's statement with nk in the spend key's place and a salt appended (1 212 input words), and
publishes c = H(AUTH, nk ‖ salt) inside its digest; the auth guest (guests::auth(), hc
1e4e347f44cf86750b30a9a4bdf9ec9256efe353d4ff8017451eca7d195639c1) reads sk and the salt and
publishes the same c, proved against the transaction's binding. Both digests are pinned in
tests/guest_provenance.rs. v3 shares v1/v2's assembler: every §3.3 row of the table above holds
for it unchanged (the mutation fuzz runs every v1/v2 witness's v3 twin under the same model), and
it lands at tier 14 with v1/v2's nine table heights, v2 + 87 cycles and + 19 permutations (4 024
cycles and 273 permutations of headroom). The auth guest is 250 cycles and 55 permutations, tier
10. What the split adds, and what each rule rests on — the ledger's rule is that the bundle
digest (recomputed with auth_commit) is what the bundle proof publishes, the auth proof
verifies against hc_auth and the same binding, and its c equals auth_commit:
| check | enforced by | emulator test | real proof | fuzz |
|---|---|---|---|---|
pk_self = H(PK, nk) from the witness's nk; every input owned by it |
structural in v3 (the derivation reads nk where v1 derives it from sk); a foreign nk stages the notes under another pk_self, so every input fails membership — taint |
a_v3_witness_with_another_keys_nk_taints, the_v3_builder_refuses_an_input_owned_by_another_key (hidden_bundle.rs) |
real_proof_an_auth_proof_for_a_different_nk (the cheater's re-proved bundle) |
mutation_fuzz_v3_nk_and_salt_words_publish_the_honest_digest_only_when_valid |
c = H(AUTH, nk ‖ salt) in the v3 digest, immediately before bad |
structural: staged by the guest from its own nk and salt words; hidden_bundle_preimage_v3 checked at assembly |
the_v3_digest_is_v1s_preimage_with_the_auth_commit_before_bad, a_v3_witness_with_a_different_salt_publishes_a_different_digest |
every v3 real_proof_* (the published digest is the host's) |
the v3 fuzz above |
The auth proof's c is H(AUTH, H(NK, sk) ‖ salt) |
structural: guests::auth() derives nk from sk exactly as every spend does |
a_different_sk_or_salt_changes_c (auth_spike.rs), a_witness_with_a_wrong_sk_publishes_another_c (auth_cheating.rs) |
real_proof_a_tampered_output_word_fails_verify (auth_cheating.rs) |
mutation_fuzz_every_mutation_of_the_16_words_changes_c |
An auth proof for another nk does not authorise the bundle |
the ledger: its c misses auth_commit (AuthMismatch), or, with the field moved, the bundle digest misses (BadDigest) |
an_auth_proof_for_a_different_nk |
real_proof_an_auth_proof_for_a_different_nk |
— |
| Bundle and auth proof under different salts | the ledger: no field value satisfies both proofs | a_bundle_whose_c_uses_a_different_salt_than_the_auth_proof |
real_proof_a_bundle_whose_c_uses_a_different_salt_than_the_auth_proof |
— |
auth_commit differing from both proofs |
the ledger: the recomputed v3 digest misses (BadDigest) whatever the auth proof says |
auth_commit_in_the_bundle_differing_from_either_proof |
real_proof_auth_commit_in_the_bundle_differing_from_either_proof |
— |
| An auth proof for another binding, or replayed from another transaction | the binding alone: the auth guest never reads its public segment, so only H_PUB ties the proof to its transaction, and verify_public against this transaction's binding refuses another's |
an_auth_proof_for_a_different_binding, an_auth_proof_replayed_from_another_transaction |
real_proof_an_auth_proof_for_a_different_binding, real_proof_an_auth_proof_replayed_from_another_transaction |
— |
| Neither guest accepts the empty public segment (Task 5b) | verify_public against the transaction's binding |
both_guests_publish_the_same_under_the_empty_segment |
real_proof_both_guests_refuse_the_empty_segment |
— |
| The honest pair passes | — | — | real_proof_split_honest_control_passes_the_rule |
— |
The salt's freshness is the wallet's, not a guest's: a repeated salt repeats c and links two
transactions, so the wallet draws 256 fresh random bits per bundle (crates/randprotocol-client,
a unit test asserts two bundles never share one). The ledger side is in docs/shielded.md §2 and
§5; the node's end-to-end test is crates/randprotocol-node/tests/split_auth.rs (a v3 transaction
with a real bundle and auth proof commits on a v3 genesis; a swapped-binding and a different-salt
auth proof are refused). The auth-guest suites: cargo test --release -p randprotocol-zkvm --test auth_spike --test auth_cheating (the real proofs are about 7 s each).
Proving is the only expensive half of a confidential call, and it happens in the wallet, never on
the chain. rand call --cuda runs the batch STARK's NTTs and Poseidon2 Merkle commitments on an
attached NVIDIA GPU instead of the CPU. The proof is the same object either way — same public
values, same tier, verified by the same CPU verifier — so nothing on the node changes and a chain
cannot tell which backend produced a proof.
The backend lives in the circuits repository, rand-zkvm-cuda, and is not vendored into this
repo: it is an optional git dependency on the public zkp-circuits repository at a pinned revision
(crates/randprotocol-zkvm/Cargo.toml; the same line in crates/randprotocol-rvm/Cargo.toml,
written by deploy/sync-zkvm.sh). cargo fetches it itself, so no sibling checkout is needed for
any of the features below — until 2026-10-01 it was a path to ../../../circuits/rand-zkvm-cuda,
which is what made a clone of this repository alone fail to build (audit v6, PROC-2).
Building. On a machine with a CUDA 13 toolkit, an NVIDIA driver, and the compiled PTX:
cargo build --release -p randprotocol-client --features cuda
rand call <program-id> --input 400 --input 250 --cuda
The flag is always accepted by the parser, so --cuda on a stock build fails with a message rather
than being silently ignored. There is deliberately no fallback: if the GPU path cannot start,
the call errors out and nothing is submitted, so a proof is never quietly produced somewhere other
than where it was asked for.
Two other feature flags exist for testing without hardware, neither of which needs a CUDA toolkit:
--features mock-cuda runs the same Backend::Cuda path with the kernel bodies executing on the
host, and --features reference-backend runs the backend's CPU-twin NTT and Merkle engines.
mock-cuda is a sibling of cuda, not a superset — enabling cuda is what pulls in cuda-core
and its toolkit requirement.
Failure modes. Each exits non-zero and submits nothing:
| Situation | Message |
|---|---|
| Built without the feature | built without CUDA support; rebuild rand with --features cuda |
| No driver or no device | Backend("CUDA driver: ...") |
| Built, driver present, PTX missing | Backend("no PTX for the GPU kernels at <dir>/ptx/kernels.sm_80.ptx (see rand-zkvm-cuda/ptx/PTX_BUILD.md)") |
| Tier too large for device memory | Backend("device allocation of N bytes failed (M bytes free); use a lower tier") |
The PTX path can be overridden with $RAND_ZKVM_PTX; it otherwise defaults to
rand-zkvm-cuda/ptx/kernels.sm_80.ptx. A deployed binary must set RAND_ZKVM_PTX, because that
default is an absolute path into the build machine's crate directory and will not exist on the
node.
Status as of 2026-09-10. The kernels have not been executed on real hardware, and no PTX is
committed — rand-zkvm-cuda/ptx/ holds only PTX_BUILD.md. Everything above is exercised on the
mock driver and the CPU twins; the first run on a GPU is still ahead, so treat the cuda feature as
untested on-device and expect the "no PTX" error until the kernels are compiled and committed.