Skip to content

Latest commit

 

History

History
1111 lines (999 loc) · 91.1 KB

confidential.md

File metadata and controls

1111 lines (999 loc) · 91.1 KB

Confidential computation

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.

The zkVM

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 = 101 bits); 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-only Lw/Sw), sign-extended for lb/lh. Memory itself stays word-addressed.
  • The RV32M extension. mul mulh mulhu mulhsu div divu rem remu (AluOp grew 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 the n words in place with a padding-free Poseidon2 sponge and overwrites ptr..ptr+8 with the 8-word digest. Proved by a dedicated poseidon2 chip (a seventh table) the cpu table's new hash rows call into over a POSEIDON2 bus; src/hash.rs is 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 — see deploy/sync-zkvm.sh's header comment).
  • The program table is a witness now, and hc moved in-circuit. Through constraint set 2, hc was 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 reproduces Instr::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 digest hc the verifier checks directly — Machine::verify(hc, proof) replaces verify(program, proof); the verifier never sees a single instruction word. hc is still binding, not hiding (no salt of its own — the upstream research crate's docs/03-privacy.md, "What that does and doesn't change about what hc leaks", 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. Proof gained a program_log_height: u8 field (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 by Machine itself, keyed on that pair).
  • On-chain effect: ProgramRecord.code_hash is now hc, and it is load-bearing. Before this sync, code_hash was blake3(program_id) again — informational, since verify_call reconstructed the program from record.words and passed it to Machine::verify directly. Now that the verifier no longer takes a program at all, ZkExecutor::check_program computes and stores hc (isa::Program::digest(), 8 little-endian u32 words) as code_hash at deploy time, and ZkExecutor::verify_call decodes it back out and hands it to Machine::verify — code_hash is now the actual verification key material, not just an explorer-facing label. randprotocol-core's programs RPC already serves code_hash as hex; nothing there changed.
  • Tiers are unchanged (10, 12, 14, 16, 18, 20; cpu/alu/memory height formulas unchanged); Tier::poseidon2_height() (2^(t+2)) and the proof-declared program_log_height are new, both sized independently of cpu_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_INPUT is bound to a salted commitment, H_IN. Through constraint set 3, a guest's read_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: every READ_INPUT and the digest's own mandatory absorption draw (idx, word) from that table over two separate LogUp buses, INPUT_DIGEST (count IS_REAL, the digest's sole and unconditional source) and INPUT_READ (count IS_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's IS_INDIGEST rows absorb the committed vector into H_IN the same way the existing IS_DIGEST rows absorb the program into hc, published as pv::IN0..7. H_IN is salted (four witness words, drawn fresh per proof from OS entropy, absorbed ahead of the real input words) — unlike hc, 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 published H_IN, since the salt never leaves the prover. H_IN alone 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 to write_output it itself. The prover must draw a fresh salt every proof (Machine::prove, never Machine::prove_salted outside 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 through Machine::prove/Machine::prove_on, both of which draw fresh entropy internally.
  • pv::NUM grew from 18 to 26, and Proof gained a new field, input_log_height: u8 — the input table's declared height, exactly analogous to program_log_height (proof-declared, not tier-derived; tables::input::input_log_height's doc comment mirrors tables::program:: program_log_height's rule). Machine::verifier_key and log_ext_degrees grew from a (tier, program_log_height) 2-tuple key to a (tier, program_log_height, input_log_height) 3-tuple; the injected log_ext_degrees_pub wrapper and every verifier-key lookup in ZkExecutor (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 against asm.rs's mnemonic helpers; M4.1 adds a riscv32im-unknown-none-elf-targeted toolchain (upstream's guest-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 base pc into a Program, rejecting anything that doesn't decode (isa::LoadError). guests::compiled::fib() (mirroring upstream's module, with its include_bytes! path adjusted for this crate's shallower layout) loads the vendored guests-compiled/bin/fib.bin this way; tests/e2e.rs's compiled_fib_matches_the_hand_written_ guest and compiled_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 calls call_keccak(ptr_words) and the chip permutes the 50-word state at that word address in place, reading and writing the words itself over the MEMORY bus and answering the cpu row over a new KECCAK bus. The table is 32-row blocks (24 rounds plus 8 idle rows) and 2 612 columns wide. The host reference is keccak.rs (keccak256, keccak_f), checked against p3_keccak on 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. Proof gained keccak_log_height: u8, where 0 means the proof declares no keccak table at all: machine::chips then returns eight chips, not nine, and the degree-bit vector is eight entries long, so verify'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 calls KECCAK, so every proof on this chain declares keccak_log_height = 0.

  • mem_log_height is proof-declared too, and both new heights are untrusted words checked before anything is sized from them. machine::check_declared_heights is the single place that does it — tier in TIERS, then program_log_height, then input_log_height, then the keccak table's flat range [5, 20], then the keccak-vs-tier relation klh ≤ tier + 5 (a permutation costs a cycle), then mem_log_height ∈ [tier + 2, 24]. ZkExecutor::decode_and_check calls 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_key is four-keyed, (tier, program_log_height, input_log_height, keccak_log_height); log_ext_degrees takes a fifth argument, the declared mem_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 same CommonData. The injected log_ext_degrees_pub wrapper and every verifier-key lookup in ZkExecutor (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_HASH row spliced after any ordinary row gave arbitrary RAM writes at an unbounded HASH_PTR — and HASH_FIN is 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 cross 2^30 can actually be proved; Instr::encode asserts every immediate's range instead of silently truncating it into wrong code; the emulator rejects a Poseidon2 pointer at or above 2^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_LEFT is a 16-bit value, so a longer one is unprovable at any tier) as is an explicit tier outside TIERS.

  • 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 on guests::fib immediately 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 with read_public(idx), committed to H_PUB (pv::PUB0..7): a Poseidon2 digest of the segment with capacity header [PUB(15), n_pub, 0], computed by the cpu table's new IS_PUBDIGEST row prefix exactly the way IS_INDIGEST computes H_IN. The buses mirror the input table's pair — PUBLIC_DIGEST (the digest's sole source) and PUBLIC_READ (the syscall's sole source, count MULT_READ) — sixteen buses in all. Unlike H_IN, H_PUB is 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::NUM is 34 and the cpu table is 275 columns (was 26 and 224): the eight PUB0..7 public values and the pubdigest region's columns. The existing pv slots (OUT0, HC0, IN0) do not move, and H_PUB binds SYS_READ_PUBLIC exactly the way H_IN binds SYS_READ_INPUT — 17 new cheating tests upstream pin the table and its digest region.
  • Machine::verifier_key is a 6-tuple, (tier, program_log_height, input_log_height, keccak_log_height, sha256_log_height, public_log_height); log_ext_degrees takes a seventh argument, the declared mem_log_height (still deliberately absent from the key). The injected log_ext_degrees_pub wrapper and every verifier-key lookup in ZkExecutor follow, and check_declared_heights gained 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 no 0 escape). ZkExecutor::decode_and_check still delegates to that same function, so the chain's admission bound on a proof's declared shape is the verifier's.
  • M4.4's sha256 table and SHA256 syscall (number 5) ride along, optional per proof on the keccak table's exact and independent terms: sha256_log_height == 0 means 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 at FriProfile::Test and +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 heights 0.
  • 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) and src/sbpf.rs (the sBPF host side and the committed SPL Token ELF) are vendored with evm-core/sbpf-core as path dependencies (beside the repo, like rand-zkvm-cuda, but not optional — circuits/ must sit beside fullnode/ for any build of the crate), and the compiled evm.bin/sbpf.bin beside fib.bin/keccak256.bin. Their test suites (tests/evm_*.rs, tests/sbpf_*.rs, tests/sha256.rs, the grown tests/e2e.rs) are vendored wholesale as always, because they are the upstream authority on the machine's behaviour; the EVM tier-16 call proof in e2e.rs is 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_call and verify_bundle both run Machine::verify_public(hc, &[], proof), pinning H_PUB to hash::public_digest(&[]). Plain verify would leave pv::PUB0..7 unchecked 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 calls SYS_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_BYTES stays 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 BLIND bus (tables/blind.rs): the honest prover moves each terminal by a uniform F_{p²} element while their sum stays zero. Because a table's running-sum columns are opened like its main columns, check_declared_heights floors program, input and public at MIN_LOG_HEIGHT = 7 and 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 at verify_bundle (PublicValues, its H_PUB is the empty segment's). randprotocol_core::types::BUNDLE_PUBLIC_LOG_HEIGHT is 7.
  • Verifier keys are salted from key_derivation_v2 (HCS-1), a Poseidon2 stream over the crate's own constants, not rand's StdRng. rand now 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): POSEIDON2 with the length in capacity lane 4 and at least one permutation. POSEIDON2 itself is unchanged, so no note commitment, nullifier, root or program digest moves: hc_bundle is unchanged — v1 83d3a370…0ef8, v2 651043e2…839b.
  • JALR clears bit 0 of its target and requires funct3 = 0 (ISA-4). No in-tree program's words or digest change.
  • The pc window (#53): Machine::prove* refuses and Machine::verify rejects a program whose declared (floored) table base_pc + 4·2^program_log_height passes 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 digest 6059c52a…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 after JALR_B0): the running gas accumulator. Row 0 — always a digest row — starts it at 1; every real-to-real transition adds w_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_next is itself a sum of selector products).
  • The public value, GAS_LIMIT (pv::GAS = 34, pv::NUM 34 → 35): the prover's declared ceiling. On the HALT row, pv[GAS] − GAS is decomposed into four RANGE8-looked-up witness limbs GD0..3 ([0, 2³²), little-endian), never opened or published — the run's exact gas stays private, and only GAS_LIMIT is new public information. Off the HALT row the limbs are forced to zero.
  • The native checks (machine::check_public_values, before any verifier key is built): a proof's TIER must be in TIERS (VerifyError::Tier, native — an untrusted tier that survives gas_max's clamp to [10, 20] is caught here first), and pv::GAS > gas::gas_max(tier, keccak_log_height, sha256_log_height) is refused (VerifyError::GasLimit).
  • gas_max carries 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 to 2^(t−2), bounded by the Poseidon2 table's own capacity (2^(t+2) rows at 32 rows/permutation, so at most 2^(t−3) absorb rows at +2 gas 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 moved 16 383 → 20 479; chain 18's bundle_gas_limit moves 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 new RANGE8 lookups on the packed lookups, no new preprocessed table (every pinned commitment half is unchanged, only common moved). MAX_PROOF_BYTES is unaffected at this scale.
  • The rVM interface grows to 35·N (was 34·N): [vk ‖ N ‖ B(8) ‖ 35·N]. Re-vendoring for the 35th word exposed a real completeness bug in rv32n'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 every N ≡ 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 production aggregate_program_digest moved for both reasons (the wider interface and the sponge fix): 1831f036a2d3524249df17a66a220457878f8aeed669c77db08d58026461ddd7. The phase-2 row cuts (circuits 75b7893, 2026-10-04) moved it again, to c90b3f0a7758c7e306042f27a94cc1f123441b0284c7352cb3f426048c7a74d8; the inner machine, its verifier key and the interface digest did not move (recursion/docs/04-phase2-row-cuts.md). rVM phase 3 (circuits 71e1a04, 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 (circuits 57fb9dc, vendored at d217252, 2026-10-11) moved it twice more, dc350ecf…8ba0 → e0eede8b…b6c3 (Merkle spans) → 8bc2cecbd30b975ec15a5e11354434bd7e1c2814d46dd0acc1d92b3ff944d8c7 (the MODQ chip), and with the chip the inner inner_vk_digest moved too, to 1b0c4b0043dd5d00d710f10048bfde989a955034d7fd52f1a85595a61339ddc7 (agg_executor.rs's pinned tests; docs/aggregation.md, docs/zkvm.md §8a). rVM phase 5 (circuits b3e21d9, 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_digest unchanged); 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.

Transaction binding (2026-09-19, Task 5b — a hard fork, chain 14)

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 runs Machine::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_inner computes 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_height is public_log_height(8) == 4 (the empty segment's is 2), a single known value: ZkExecutor::bundle_heights returns it, warm_bundle warms it, and decode_and_check refuses a bundle proof declaring any other public height before any verifier key is built.
  • prove_bundle(profile, inputs, binding, backend): the wallet (randprotocol-client's wallet::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.

On-chain model

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.

Validity rules

  • 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-pinned hc_bundle and 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 within max_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's hc for the tier it declares, and its H_PUB equals the program's recorded public digest (the empty input's for a program without one), or the call fails with PublicValues; 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 most executor::MAX_CALL_TIER (14 — the highest tier warm pre-builds, and the two are one expression), or the call is refused as CallTierTooHigh naming the cap and the remedy; the declared program_log_height equals program_log_height(words.len()) of the deployed record, exactly as the prover derives it; the declared input_log_height is at most min(tier + 2, 16) (executor::max_input_log_height — four input words a digest cycle, and the 16-bit HASH_LEFT cap); the declared keccak_log_height is at most executor::MAX_CALL_KECCAK_LOG_HEIGHT (12, 128 permutations) and sha256_log_height at most executor::MAX_CALL_SHA256_LOG_HEIGHT (13, 128 compressions), 0 — no table — always admitted; the public height equals the record's public_len class (the earlier pin). Why: Machine::verify builds 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's measure_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 what warm covers 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-20 approve was a tier-16 call, which this rule would refuse. What the pins do not bound is Machine'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.

Gas (v0)

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.

Privacy

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_height say 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 genesis hardening_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_height and sha256_log_height in the clear, and 0 means "no such table". So anyone reading a call proof learns whether the call used the KECCAK or SHA256 precompile 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_TIER 14); 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_tables declares 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 POSEIDON2 syscall 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 added POSEIDON2_LEN (syscall 7), the same sponge with the message length in capacity lane 4. A new guest must use POSEIDON2_LEN for anything whose length an adversary can choose (asm::call_poseidon2_len in this repo's assembler; in circuits' guest SDK at aeacf31, poseidon2_len is the safe call and the legacy poseidon2 is unsafe). 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.

Call input envelopes

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).

Call limits and a program's public input

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 size max(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_PUB at deploy and verify_call compares the proof's pv::PUB0..7 with it after verify.
  • The wallet: rand program deploy <image> --public <words file | ELF>; rand call fetches the public input, checks it against the program id, and proves over it; --expect-public <FILE> refuses before proving on a mismatch; a proof over max_proof_bytes is 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.

Chains without confidential computation

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.

Guest programs

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 = 1 otherwise. 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_asset mask 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.

Bundle guest v3 and the auth guest (split authorisation, v0.6.3)

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).

GPU proving (--cuda)

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.