Status: built (2026-09-15, the aggregation-spec branch): the chain-side spec
(docs/superpowers/specs/2026-09-15-block-aggregation.md) is implemented in full — the
register, the nine-step admission, the subsidy and fee split, sealing and pruning, sealed-form
sync, the RPC and CLI, and the chain-9 genesis tooling. The measured numbers are collected
in §6; the text of §1–§4 below is the approved design as written, kept because the build
followed it almost word for word.
The original status line, for the record: approved design as a starting point, not built
(decided and approved 2026-09-12; the spec and plan build on §1–§4 and settle the open
questions in §5). This is the remedy chosen in docs/block-space.md §6 for the 80-query proof
size: a block carries one recursive proof for all of its bundles instead of one ~1.3 MB proof
per bundle. Proof pruning was rejected because it makes a syncing node trust finality
signatures for old history and so opens the long-range attack a proof-carrying chain does not
have.
| role | hardware | admitted by | does | paid by |
|---|---|---|---|---|
| proposer (HotStuff leader) | CPU | stake (docs/staking.md, the S2 register) |
orders transactions, verifies one aggregate proof per sealed block (~0.8 s cold, ~16 ms warm) | the verification share of fees, as today |
| aggregator (prover) | GPU; measured: ≥ 512 GB host memory for one production proof (§ "Before enabling aggregation", item 3) | permissionless, a registered payout address | proves one recursive STARK that verifies N bundle proofs; submits it for a sealing block | the proving share of fees plus a block subsidy in newly minted RAND |
Validators stay CPU-cheap, so a home node can still validate. GPU capital competes in its own market and is never a requirement for consensus. The two roles may be the same operator, but nothing in the protocol couples them.
Nobody proves an aggregate inside a 2 s slot: a single bundle proof takes ~100 s today and a recursive proof over a block is minutes of GPU. So aggregation is pipelined:
- Unaggregated blocks. Senders submit bundles as today. A block at height
horders them and validators verify each bundle proof (three per block fit the 4 MiB cap). Consensus and finality run on these blocks unchanged. - Sealing. Aggregators watch the chain, take the bundles of a window of finalised blocks,
and prove one aggregate. The aggregate is submitted to the network and included in a later
block
h + kas a sealing record:Aggregate { covers: [bundle digests], proof, payout, r }. - Sealed history. Once a block's bundles are covered by a finalised aggregate, every node may drop the individual bundle proofs and keep the aggregate; the ~3 KB of public fields per transfer stay. History remains proof-carrying end to end, which is the whole point.
The forward path (throughput above three per block) is the second step of the same design: bundles travel sender → aggregator, the aggregator's proof enters the block, and the individual proofs never do. That needs the aggregator to be on the critical path of inclusion, so it comes after the sealing pipeline has run on the fleet.
A bundle's fee (docs/fees.md) today prices one verification and goes to the proposer. With an
aggregator it prices proving too. The fee splits into a verification share (proposer) and a
proving share (the aggregator whose aggregate first covers the bundle). A sender who attaches
more than the floor is aggregated first; that is the fee-ordered mempool the block-space doc
asks for, with GPU operators doing the ordering.
Under the genesis fees.burn_base (docs/fees.md §1.3) the verification share is destroyed
instead of paid: the proposer keeps nothing at inclusion, BUNDLE_BASE joins burned and
base_fees_burned, and the proving share — fee − BUNDLE_BASE, bucketed exactly as without the
flag — reaches the covering aggregator, or the recorded proposer at the sweep, as it always has.
Under fees.burn_floor beside it (issue #135) the whole floor is destroyed instead — a Deploy's
per-word term and a Call's tier-exact gas and byte terms with the base — and the proving share is
fee − floor, so an aggregator is never paid a priced term the chain burned.
Under the genesis fees.proposer_share_bps (docs/fees.md §1.3, docs/compute-optimization.md
§6.2; aggregating chains only) the base itself splits: the proposer keeps proposer_share_bps / 10 000 of the base it keeps today (BUNDLE_BASE, nothing under burn_base) and the remainder
joins the proving share in the bundle's one bucket entry — covered, it pays the aggregator; expired,
the sweep returns it to the proposer like any excess. Under fees.prove_base (§6.3) every bundle's
floor rises by prove_base, which is bucketed whole and never burned — and the node's pool prices
with it too (Pooled.floor, current_floor), so it is never read as surplus and a call priced out
by it is not offered. At 4 000 bps and 0.0006 RAND
a 0.0018 RAND transfer pays the proposer 0.0004 at inclusion and buckets 0.0014.
At launch fee volume is near zero and nobody runs a GPU for it. So the sealing block mints a subsidy for the aggregate it includes, the way a Bitcoin coinbase pays a miner before fees matter, on a decaying schedule (halving on a fixed block count) that hands over to fees as volume grows. This is the chain's first issuance; today supply is genesis allocation plus faucet mints only.
What the subsidy is not, and why the design differs from proof-of-work:
- The work is useful and bounded by demand. Hashing is wasted by design and scales with price; proving scales with the number of bundles waiting. There is no difficulty and nothing to adjust it against. So the subsidy rewards coverage, not compute: a fixed amount per sealed block, paid to one aggregate.
- Per block, never per bundle. A per-bundle subsidy would let a prover farm it by filling blocks with its own transfers. Per block, filling does not raise the reward; it only helps win the selection.
- Most coverage wins, not first. A first-valid race hands every block to the fastest GPU. Instead the sealing proposer picks, among the valid aggregates it received in the block's window, the one covering the most bundles; ties go to the lowest proof hash. A slower prover with a fuller aggregate stays competitive.
- Security is not what the subsidy buys. Consensus security comes from bonded stake; the subsidy buys throughput and bootstraps the prover set. It can be small and can decay to zero.
A dollar-indexed schedule (genesis fees.usd_subsidy, docs/fees.md §1.3). A RAND amount buys
a different number of GPU-hours at every price; the fee study's provers stayed for dollars. Under
the sub-section the schedule of a sealed block is min(⌊usd_micros_per_sealed_block · 10⁹ / price⌋, max_subsidy_per_block) while the validator set's voted RAND/USD price is fresh (at most
price_max_age_blocks old at the sealing block), and subsidy(n) otherwise —
aggregation::schedule_subsidy, the one function admission, apply, rand_getEmission and the
daemon share. The price is a vote (SetRandPrice, docs/staking.md "The RAND price vote"), not an
oracle; the cap is genesis, not votable. Everything below — how the payment lands, the netting,
supply accounting — reads "the schedule" and is unchanged.
The subsidy and the proving share are paid as one deposit note to the aggregator's shielded
payout address, exactly like a validator's Withdraw: the amount is public in that block, the
aggregate carries the note's blinding r, and the ledger derives the commitment itself, so an
aggregator cannot mint more than the schedule says. The note's later spend is unlinkable as any
other.
Under the genesis fees.subsidy_net_of_fees (docs/fees.md §1.3) the shares pay the schedule
first (spec §5.4's derivation, Ledger::aggregate_payment): the note carries
max(subsidy(n), shares), and only subsidy(n) − shares — nothing once the shares reach the
schedule — is minted. Under fees.usd_subsidy read the dollar schedule for subsidy(n) wherever
it appears here (§3.2): the netting is the same function over whichever schedule applies. subsidised and rand_getAggregate's subsidy carry that minted part;
sealed_blocks advances by one either way. Admission and apply still derive one note from one
state: the rule lives in that one function.
An aggregator must net the subsidy too. The ledger never reads the envelope's amount: it
derives the commitment from its own max(subsidy(n), shares), admits the aggregate and appends
that note whatever the envelope says. An envelope sealed at subsidy(n) + shares therefore opens
to a commitment matching no leaf, and the payout is lost to the wallet that holds it. The flag is
read off rand_getLimits.fee_rules.subsidy_net_of_fees (null — no rule on — a reply that
predates the section and a node with no rand_getLimits at all are the old sum).
rand-node aggregate reads it through its client's cached limits (RpcClient::fee_rules, the
same one rand_getLimits read the envelope format comes from: the section is fixed at genesis,
so one read per daemon, never one per pass that a transient RPC failure could abort — issue
#132), takes rand_status.aggregation's schedule beside it, and seals at
aggregation::minted_subsidy(subsidy(n), shares, &fees) + shares, the ledger's own function
(pinned by the_aggregate_pass_seals_the_ledgers_payout_under_subsidy_net_of_fees). A third-party
aggregator must do the same.
And the dollar schedule. Under fees.usd_subsidy the daemon reads the voted price off
rand_getRandPrice after the prove, beside rand_status, and seals at
aggregation::schedule_subsidy over fee_rules.usd_subsidy and that price when the node reports
it fresh — judged for the next block, the height the note's time names and the earliest the
aggregate lands — and at subsidy(n) clamped to max_subsidy_per_block when it does not (pinned
by the_aggregate_pass_seals_the_ledgers_payout_under_a_voted_price).
The payout is signed, and a moved schedule is refused, not underpaid (review 2026-10-09).
Action::Aggregate carries payout_total, the amount the envelope was sealed for, inside the
aggregator's signature (aggregate_signing_hash, domain rand-aggregate-4; genesis-bound
rand-aggregate-5). validate_aggregate — admission and apply alike — refuses an aggregate whose
payout_total is not aggregate_payment(..).total on its state, AggregationError::PayoutMismatch { expected, got }: a state verdict, never in the admission cache. So the schedule moving between
sealing and inclusion — a SetRandPrice (in any earlier block, or in the aggregate's own: a
proposer applies ordinary transactions before aggregates), the price going stale, sealed_blocks
crossing a halving (the case that predates the dollar subsidy) — refuses the aggregate; nothing
leaves the bucket and nothing is minted. The pool re-asks at every tip (check_aggregate_payout)
and evicts a pooled aggregate whose payout moved. rand-node aggregate re-seals — the proof binds
(chain, aggregator, nonce) and the covers, never the payout, so it is reused — and resubmits, up
to four times a pass, after a PayoutMismatch refusal or an eviction. An unknown status alone
is not an eviction: the node prunes its pool against the tip, the certified but uncommitted block,
so an aggregate inside such a block answers unknown until the three-chain rule commits it — the
daemon keeps polling, takes the committed register nonce moving past the one it signed as the
commit, and re-seals only after 200 unknown polls (a minute) with that nonce unspent. Before
this an aggregate landing across such an edge was paid the ledger's amount into a note its envelope
could not open: subsidy and covered shares lost. (payout_total moved the Aggregate encoding and
every aggregate txid; no chain has carried one.)
The shares are always the node's bucket entries, rand_getUnsealed's excess — never recomputed
from a raw transaction. Under fees.proposer_share_bps and fees.prove_base an entry already
carries the aggregator's part of the base and prove_base beside the tip, so nothing in what the
daemon seals changes (pinned by the_aggregate_pass_seals_shares_that_include_prove_base_and_the_base_part);
a share re-derived as fee − BUNDLE_BASE would miss the base part and seal a note the ledger never
appends.
docs/supply.md's value-balance invariant gains a term:
supply = genesis notes + faucet mints + Σ subsidies − burns
rand_getSupply reports issuance separately from faucet mints so an auditor can check the
schedule against the sealed-block count.
The payout goes to the aggregator that signed the Aggregate transaction. The proof itself
must therefore say who made it; otherwise a registered aggregator could copy another's valid
aggregate from the pool, re-sign it under its own identity and nonce, and be paid for the work.
The rVM's aggregate program absorbs eight binding words into its interface digest:
interface = [inner_vk_digest(4) ‖ N ‖ B(8) ‖ 34·N public values] -- pre-constraint-set-8
As of constraint set 8 (chain 18's pv::NUM = 35, docs/confidential.md's "Constraint set 8",
built on feat/gas-chain18, not yet cut) the width is 35·N — a chain built after chain 18
carries [vk ‖ N ‖ B(8) ‖ 35·N], not the 34·N this section was written against. No live chain
runs aggregation, and chain 18's genesis rejects gas.dynamic beside an aggregation section
(docs/fees.md §1.2), so the two have never yet had to coexist.
B = aggregate_binding(chain_id, aggregator, nonce) // H("rand-aggregate-bind-1", …), 8 LE u32
The prover (aggregate --watch) reads its register nonce before proving and binds its
own (chain, address, nonce). Admission step 8 recomputes B from the transaction, never
from the proof, and a copy of the proof under any other triple fails: re-signed by another
aggregator, or replayed at another nonce. aggregate_binding is in
randprotocol-core/src/types/actions.rs, and the program change is circuits 573ef2e.
Because the program digest changed, a chain's admitted_shapes[].aggregate_program_digest
must be measured on a build that carries it, and the production proof batch must use this
program.
SlashAggregator is refused on every aggregating chain (AggregationError::SlashingRetired,
permanent). The register nonce advances only when an aggregate commits, so every retry after a
lost race, a refusal or censorship re-signs the same nonce over new content — the --watch
daemon draws a fresh r and time each pass — and the spec's equivocation proof was exactly
two such headers, available to anyone. Exactly one aggregate can consume a nonce, so
equivocation harmed nothing; the bond is what prices spam. The action's wire variant stays (its
bincode index is part of every txid); slashed stays in the supply identity at 0.
rand-node aggregate runs one pass — aggregate_pass (the work list, the raw bundles, the
prove, the signed aggregate) and the submission — and exits; with --watch it loops the pass on
--interval-secs (15). The chain id is read once at start; from the second pass on, each pass
first re-reads rand_chainId and compares. A node restarted onto another chain during a prove
therefore surfaces one pass later: that pass's aggregate is refused at the mempool (a retryable
failure), and the next pass's check stops the daemon.
In --watch mode a failed pass is logged at warn with its error chain and the daemon sleeps its
interval and tries again: an RPC failure or malformed reply, a key not yet in the aggregator
register (logged as "not registered yet (pending register?)": a daemon started right after
aggregator register was submitted recovers once it commits), the register nonce moving while
proving (§3.5: the pass is abandoned, since its proof can never verify), a failed prove, a
refusal at the mempool, a commit that does not arrive within the wait. A sealed or empty pass
resets the count of consecutive failures; at --watch-max-failures — default 20 consecutive
failed passes, at least five minutes at the default interval (a hanging node also costs the read
timeout per call) — the daemon exits non-zero with an error naming the cap and the last failure,
so a permanently broken setup (a key that never registers among them) does not spin. An idle
chain never reads the register, so an unregistered key on it fails nothing until work appears.
The stop class — errors no retry cures, one enum (StopError in
crates/randprotocol-node/src/main.rs) — exits on the pass it occurs in: the node now serves
another chain id than the daemon started on (a chain restart behind the same RPC; the key must be
registered on the new chain), and the node reports no binding_domain for a chain cut after it
(BIND-1, require_binding_domain; a genesis cannot change while the daemon runs). A bad key file
is read once, before the first pass. Without --watch a failed pass is the command's error,
exactly as before.
A flat aggregate verifies at most N ≤ 5 bundle proofs at production (§"Before enabling
aggregation", the reduce chip's N ceiling). A tree goes past it at a constant per-step cost:
rv32n leaves of L bundle proofs each (today's aggregate program at N = L), then d levels of
rv32t, the 2-to-1 interior step, whose child shape is compiled in and whose child key is a
published tape value. Its root covers exactly L·2^d bundles. Built on circuits 92b750e
(branch feat/tree-aggregation; re-pinned at 4a244b6, the final review's test and docs fixes,
no src/ change), vendored into crates/randprotocol-rvm (2026-10-09). Like every
aggregation rule it is genesis-gated and on no chain.
The genesis section. aggregation_tree (top level, beside aggregation, which it requires):
the admitted shape it aggregates, leaf_size (L), max_depth, the leaf's rVM heights
(leaf) and vk_leaf, and a per-level steps list — steps[k − 1] is level k's step:
its rVM heights (tier, reg/ram/poseidon2/reduce_log_height), its program digest and the key
digest of its proofs. The last entry repeats for every deeper level, which is sound only when
it is a fixed point (the step built over its own proofs' shape is itself; R3). The genesis hash
appends "aggregation_tree" ‖ bincode(section) after multisig, only when the section is set,
so no existing genesis hash moves. TreeConfig::check bounds it: 1 ≤ L ≤ 5, 1 ≤ max_depth ≤ 8, a non-empty list no longer than max_depth, an admitted shape, no zero pin.
The cover cap is the layout's. A flat aggregate is bounded by aggregation.max_covers
(unchanged, and ≤ the rVM's N ceiling, rand-node genesis's check_admitted_covers); a tree by
its own section, L·2^max_depth, whatever max_covers says.
The layout rule. An Aggregate carries layout: flat | tree (a trailing 4-byte enum index on
the wire, serde(default) flat). A tree covers exactly L·2^d bundles with 1 ≤ d ≤ max_depth;
any other count is TreeLayout (or TreeDepth past max_depth), refused at preflight before
the register is read or the signature checked. A smaller set takes the flat path. The layout is
signed: rand-aggregate-tree-1 (chain-id form) / rand-aggregate-tree-2 (genesis-bound), the flat
preimage under another tag, so flipping it is BadSignature.
Admission, a tree's steps. After step 6 (the covered shapes): 6t the section must exist
for the covered shape (TreeNotAdmitted) and the count must be a tree (tree_depth). 7a
(AGG-3 on the root's exact tier, R6): the root's declared tier, read off its header alone, must
equal the tier pinned for level d (TreeTier) — nothing is built first. 7b (AGG-6 per
level): the executor rebuilds the leaf and every listed step program and key from the pinned
heights (AggExecutor::built_tree, memoised), and the root's program must be level d's pinned
digest (AggregateProgramMismatch), then vk_leaf and each step key a depth-d recompute uses
must be the pins (TreeKeyPin { level }, numbered by the level that consumes the key: vk_leaf
is level 1, steps[k − 1].key level k + 1). A build that cannot rebuild the section at all is
TreeRebuild (the startup check refuses to start on such a section, so a running node does not
reach it). 8 verify_tree: first the root's other four declared heights against level d's
pins, off the header (aggregate_header_tree, before any key is built), then the bottom-up recompute from
the covered bundles' public values in cover order — leaf i's digest is the flat bound list
[inner_vk ‖ L ‖ B ‖ 35·L] over its L runs, and each level j hashes adjacent pairs as
[vk_c ‖ 2 ‖ B ‖ D_a ‖ D_b] with vk_c = vk_leaf at level 1 and steps[j − 2].key above it
(the last repeating) — compared with the root's four public values (TreeRootDigest), then
Machine::verify_n(root program, root, 2), which holds the root's reduce height to the canonical
one. A covered run that cannot be a bundle's (an OUT word past u32, a non-canonical word) is
CoveredPublicValues { index }, before the recompute.
ZKQ-5, implemented. There is no in-program B_out == B_in: a step does not check that its
children were made under its own binding. Admission recomputes every level from the covered
bundles under the transaction's one binding B (§3.5), so a root whose any level was made under
another binding — another aggregator's, another nonce — digests differently and is
TreeRootDigest. Swapped children, a foreign leaf program, a flat proof presented as a tree and
a wrong-shape child are refused the same way or in-program (circuits tests/tree.rs).
The errors, all AggregationError: TreeNotAdmitted, TreeLayout { covers, leaf_size },
TreeDepth { depth, max }, TreeTier { declared, pinned }, TreeKeyPin { level },
CoveredPublicValues { index }, TreeRootDigest, plus the flat path's
AggregateProgramMismatch for the root's program, and TreeRebuild when this build cannot
rebuild the section. Every one but TreeKeyPin and TreeRebuild is a verdict on the bytes
against genesis constants and is cached by the admission refusal cache
(admission::is_permanent); those two stay out, as AggregateProgramMismatch does (a
build/genesis disagreement, not the transaction's fault).
At the cut. rand-node genesis --aggregation-tree <TREE.JSON> writes the section after the
genesis bounds and this build's check_tree_pins (below) both pass; nothing is written otherwise.
TREE.JSON is the TreeConfig JSON — every digest four canonical u64 limbs, as JSON numbers:
{
"shape": { "profile": "Production", "tier": 14, "program_log_height": …, "input_log_height": …,
"keccak_log_height": …, "sha256_log_height": …, "public_log_height": …, "mem_log_height": … },
"leaf_size": 2, "max_depth": 2,
"leaf": { "tier": …, "reg_log_height": …, "ram_log_height": …, "poseidon2_log_height": …, "reduce_log_height": … },
"vk_leaf": [u64, u64, u64, u64],
"steps": [ { "heights": { … }, "program_digest": [u64, u64, u64, u64], "key": [u64, u64, u64, u64] }, … ]
}circuits production_tree_genesis_values prints exactly this between its == aggregation_tree
markers (save that block as TREE.JSON); the spec's names map onto it as step_tiers[k] =
steps[k].heights.tier, step_digests[k] = steps[k].program_digest, step_keys[k] =
steps[k].key. The test tree's section, in the same form, is circuits
recursion/tests/tree-test-genesis.json (vendored at
crates/randprotocol-rvm/tests/tree-test-genesis.json; agg_executor's
the_printed_tree_section_is_a_tree_config deserializes it). rand_status.aggregation.tree is the
same section for humans, its digests as 64-character hex (the four limbs, 16 hex digits each, in
order — a limb past 2^53 would lose bits in a JavaScript reader); rpc::TreeStatus::to_config
converts it back, and --aggregation-tree takes only the limb form.
At startup. A node whose genesis carries the section rebuilds it and compares every pin —
each listed level's program digest and key, and vk_leaf, whatever depth a tree may later take —
before it opens its store (agg_executor::check_tree_pins, after the disk guard, on a blocking
thread that startup waits on):
aggregation_tree pins: <error>; refusing to start on a mismatch, aggregation_tree pins: ok in
the log otherwise. It also refuses a section the rVM could never serve: a leaf size past the rVM's
N ceiling for the shape (production N ≤ 5), a pinned reduce height that is not canonical for its
program (the leaf at N = L, every step at N = 2), and a max_depth past the listed levels whose
last entry is not a fixed point. The warm thread then builds one rVM key per listed level, the
root's at that level's tier and canonical height (warm_tree). Today this path is behind the
pre-existing refusal of every aggregation genesis (below).
The RPC and the daemon. rand_status.aggregation.tree is the section, its digests as
64-character hex strings (absent without one). rand-node aggregate --layout tree reads it once,
checks that its own build reproduces every pin (check_tree_pins, the node's startup check —
refused before any proving otherwise), takes L·2^d covers for the largest
d ≤ max_depth the unsealed work list fills (nothing if fewer than 2L are unsealed), proves the
tree on one machine (randprotocol_rvm::aggregate::aggregate_tree: leaves, then each level,
every proof checked against its pinned shape before the next level is built on it) and signs
under the tree domain. --layout flat (the default) is today's daemon.
The test-profile values (circuits docs/08 §5; the node's suite rebuilds and checks them,
agg_executor::tests): leaf rv32n, L = 1, tier 18, heights 19/19/14/16; level 1 rv32t_leaf
tier 19, 20/20/15/16; level 2 rv32t_int tier 19, 20/20/15/17 (the reduce table crosses 2^16:
the step is not its own fixed point); the fixed point is reached at level 3 (rv32t at the root's
shape). vk_leaf 188d4b14…acfdcb, vk_t_leaf 6b042444…d8507a, vk_int a92a0baa…3efbb70,
vk_fix 4203726c…7e6e355; programs T_LEAF_TEST 2c586636…206ccd34, T_INT_TEST
04b16771…dc3c226, T_FIX_TEST 08abf188…795ff396. A test-profile step proves at ≈ 34 GB
maximum RSS on a 48 GB laptop. Production values: pending (droplet; circuits docs/08 §4) — the
production depth-2 tree (L = 2, tier-21 steps, ≈ 122–197 GB projected at rate ¼, the 256 GB
droplet) has not been proved, so no production section, digest or host class is recorded here yet.
Aggregation does not remove the auth proof (audit v6, AGG-7; recorded 2026-09-30). On a
split-authorisation chain (hc_auth set: chains 17 and 18) a bundle carries two proofs: the
tier-14 bundle proof (~1.49 MB) and the tier-10 auth proof (~1.36 MB). The aggregate program
verifies bundle proofs only, and the pruned form replaces only Bundle.proof
(Transaction::hash's doc comment: "The pruned form replaces only proof, never auth_proof").
So sealing a window shrinks a transfer from ~2.85 MB to ~1.36 MB, not to nothing, and a node
syncing sealed history still verifies every auth proof itself. Either the aggregate program is
extended to verify the auth proof too, so the pruned form can replace both — a program change,
with the digest re-measured and re-pinned — or storage is planned on 1.36 MB a transfer. Decide
before aggregation is switched on.
Aggregation is off on every live chain, and rand-node refuses to start on any genesis that
carries an aggregation section (node::check_build_runs_genesis). Two reasons hold it there;
both have to clear before a genesis may switch it on.
-
The hidden-asset bundle's shape. The admitted shapes and the recursion fixtures were measured for the retired 2-in-2-out guest; they are re-measured for the current bundle guest, and
admitted_shapes[].aggregate_program_digestis taken on the build that will run. -
The rVM's own soundness (2026-09-27). The recursion-VM security report's RVM-1 let a prover choose the high lane (column
D1) of every extension value a STOREE writes to memory — no register read bound it — and the aggregate verifier stores 14 370 of them per inner proof (the REDUCE descriptors, the register allocator's spills). The same day's zk scan added the reduce chip's free per-row clock (OPCODES-1/TABLES-1), its padding rows that could write (V-OPCODES-1), runs that could stop before their write-back (ZKR-4), the public table's optional rows (OPCODES-4) and the unchecked base addresses andr31pairs (ZKQ-3). All are fixed in circuitsb786aaeandcd4b788..971b96b(on circuits main after the memo commitc6cdef4), vendored here at971b96b, each with a red-first cheating vector. The aggregate program's digest does not change with any of them (a pinned test says so); the rVM verifier does, so a proof from an unfixed prover no longer verifies. What has not been done, and has to be before a genesis enables aggregation:- An end-to-end forged-aggregate exercise against the fixed verifier — run 2026-09-30
(issue #45, closed; circuits
feat/issue-45d38d6cf) on a 503 GB machine. A malicious inner proof never reaches the prover, and a forged stored lane inside a real tier-19 aggregate's trace is refused both ways it was built. With the RVM-1 fix reverted (scratch copy, never committed) the isolating vectors accept the forgery (the forged run VERIFIED … publishing [12648430, 11, 11, 22]), so they have teeth; the aggregate-scale test is stopped by RAM read-after-write instead, and a lane chosen to cancel a failing final check (the report's §6 path) was not built — issue #119. - The report's §11, what its review did not cover — rechecked on 2026-09-28 by the zk scan's
"not covered" pass, which found no soundness break in any of them:
- the generated constraint evaluation (
programs/constraints.rs) against Plonky3 0.7.0's verifier folder, term by term (fold order, selectors, quotient recomposition, LogUp terminals,X² = 7): no mismatch. The Production-profile differential (all 9 instances) and a test that breaks a single AIR constraint on a non-first instance now exist and pass (#45); - the
rv32nabsorb schedule ([vk ‖ N ‖ B ‖ 34·N]at the time of this review,35·Nsince constraint set 8 — the same schedule, re-checked at the new width by circuits18c2627's fix for the sponge's block-boundary bug,docs/confidential.md's "Constraint set 8"), the length in capacity, the final permutation) and hostile cover sets (N = 0, a wrong N, duplicates, mixed shapes): no desynchronisation — read, not fuzzed. ZKQ-5, decided 2026-09-28 and implemented for trees in circuits docs/08: no in-programB_out == B_in; admission recomputes every level from the covered bundles under the transaction's one binding (§3.8 Trees); - the CUDA and reference backends: no verifier path differs under their features;
- the witness tape and host-side replay: admission read every cover before checking the count and signature (ZKQ-1, fixed in this release);
- timestamps: proven collision-free at every tier (
16·CLK + slot ≤ 2^27, integralCLKon every real row once the reduce clock is carried).
- the generated constraint evaluation (
- Deferred from the zk scan (ZKQ-6): a DSL-side check that a loop body never reads a register before writing it.
- An end-to-end forged-aggregate exercise against the fixed verifier — run 2026-09-30
(issue #45, closed; circuits
-
The hardware (measured 2026-09-30, #45). A production aggregate of one bundle proof (tier 21) peaked at 376.9 GB and took 8 131 s; N = 2 (tier 22, 2.4 % row headroom) is estimated at ~750 GB and N = 3 at over 1 TB — beyond any single CPU host, with or without the GPU backend (host-resident traces). An aggregator therefore needs ≥ 512 GB for N = 1, and N ≥ 2 needs a design change first (#119). Production N = 2 and N = 3 emulation (no proving) is within the memory and timestamp bounds (
2^27), and register pressure does not grow with N. The full table isdocs/node-hardware.md§4. Phase 2 row cuts (circuits75b7893, 2026-10-04): the production inner proof is 893 606 cpu rows, so N = 1 lands at tier 20 (N = 2 at 21, N = 3/4 at 22) and projects to ≈ 240 GB — a ≥ 256 GB host, tight, and a projection until proved. On that treeadmitted_shapes[].aggregate_program_digestisc90b3f0a…74d8and the node admits tiers {20, 21, 22} (recursion/docs/04-phase2-row-cuts.md,docs/node-hardware.md§4). rVM phase 3 and rate ¼ (circuits71e1a04, vendored 2026-10-08;recursion/docs/06-phase3-fold-reduce.md,docs/07-rvm-rate-quarter.md): the production inner proof is 585 686 cpu rows (still tier 20) andadmitted_shapes[].aggregate_program_digestat the production bundle shape isdc350ecf6b60af74f4bb032bdf607c3fa0fbd6317705f0b1077e71b455e38ba0(wasc90b3f0a…74d8; phase 3 moved it, rate ¼ did not;inner_vk_digestand the interface vectors are unchanged). Tiers stay {20, 21, 22} production (N = 1 at 20, N = 2–3 at 21, N = 4–5 at 22) and {18, 19, 20} test. rVM phase 4 and constraint set 9 (circuits57fb9dc, vendored atd217252, 2026-10-11;recursion/docs/09-phase4-spans.md): Merkle spans (opcodes 25/27 now take an absolute address in their 4th word, under an unchanged key) bring the production inner proof to 315 391 rows, so N = 1 lands at tier 19 and the node admitted tiers {19, 20, 21} production ({18, 19, 20} test); the MODQ chip then changed the inner shape. Current pins (agg_executor.rs's tests): productionadmitted_shapes[].aggregate_program_digest8bc2cecbd30b975ec15a5e11354434bd7e1c2814d46dd0acc1d92b3ff944d8c7(dc350ecf…8ba0→e0eede8b…b6c3at phase 4 →8bc2cecb…d8c7with the MODQ chip) andinner_vk_digest1b0c4b0043dd5d00d710f10048bfde989a955034d7fd52f1a85595a61339ddc7(moved for the first time since constraint set 8; the admission vectors were re-proved with it). Every earlier value in this list is superseded;crates/randprotocol-node/tests/no_pre_phase4_recursion_pins.rsrefuses the stale full literals in code. No modq-bearing inner proof reaches the chip-free recursion verifier (docs/zkvm.md§8a). The reduce chip runs 196 960 rows a production proof (39 392 test) with the MODQ chip; N ≤ 5 unchanged (5 × 196 960 + 1 ≤ 2^20); tier 22 admits nothing. rVM phase 5 (circuitsb3e21d9, vendored 2026-10-15;recursion/docs/10-phase5-n2-tier19.md): four program cuts and a newQueryBitstape layout, no ISA, AIR or key change, bring one inner verification to 235 418 rows: production N = 1 is 235 744 rows at tier 18, N = 2 471 105 at 19, N = 3 706 466 at 20, N = 5 at 21, so the node admits {18, 19, 20, 21} production ({18, 19, 20} test). Current pins: productionadmitted_shapes[].aggregate_program_digest792a66d4a995c7b3874d38ecb8c971d5494b57b4cce13db11e08a4f9b2f9a982(8bc2cecb…d8c7before),verify_rv32.digestc3065887…1b24;inner_vk_digestand the admission vectors are unchanged. The test tree'srv32t_intdigest and step keys wait on circuits' fixture regeneration (docs/10 §5).- The N ceiling: production N ≤ 5. The reduce chip runs a fixed 196 960 rows a production
inner proof (39 392 test; 196 480 / 39 296 before the MODQ chip) and
REDUCE_MAX_LOG_HEIGHTis 20, so an aggregate verifies only whileN × rows + 1 ≤ 2^20: N ≤ 5 production, N ≤ 26 test. Tiers {18, 19, 20, 21} are admitted (tier 22 is refused,agg_executor::admitted_tiers), and the ceiling inside them is the reduce chip's. The rVM refuses to prove past it, and the node mirrors the rVM's ownmachine::max_reduce_n:verify_aggregaterefuses an aggregate over more bundles by name before any key work (agg_executor::check_n_ceiling), andrand-node genesisrefuses an--aggregationwhosemax_coversexceeds it for an admitted shape (check_admitted_covers). Raising it is a key-shape change (one more rung of the constant doubles the range). - The reduce height is canonical in (program, N). The node verifies through the rVM's
verify_aggregate, which passes the list's N toMachine::verify_n; any declared reduce height butcanonical_reduce_log_height(program, N)is refused before a key is built (the key-cache DoS guard; it replaces the node's own reduce-flag check).warm_aggregationwarms one key per admitted tier and canonical height for N = 1..=max_covers— production heights2^18/2^19/2^20at N = 1 / 2 / 3–5, so amax_coversof 3 is 3 tiers × 3 heights. At rate ¼ the production startup warm therefore grows from 3 to up to 9 verifier-key builds, off the consensus thread; its timing is not yet measured at rate ¼. fri_profilebinds two parameter sets. A chain'sfri_profilename fixes the inner RV32 proofs' FRI parameters (80 queries, rate ⅛, 20 grinding bits) and the rVM's own (92 queries, rate ¼, 24 grinding bits at production), both constants of the vendored constraint set, not genesis fields; the proven floor is the same ≈ 86 bits (over the rVM's real chip shapes, under the whitepaper's unique-decoding bound: 86.41 at 80 / rate ⅛ / 20, 86.38 at 92 / rate ¼ / 24 — circuitsdocs/07§1).- Every node on this build. A chain with an
aggregationsection needs every node on this build or later: the rVM verifier keys (the reduce chip's preprocessed region, the rate-¼ profile) and the aggregate program digest changed, so an older node refuses every aggregate this build accepts and pins a digest no current program produces. No genesis indeploy/genesis-chain*.json(chains 6–20, the newest 20) has anaggregationsection, anddeploy/cut-chain21-genesis.shrefuses one, so no chain is affected today.
- The N ceiling: production N ≤ 5. The reduce chip runs a fixed 196 960 rows a production
inner proof (39 392 test; 196 480 / 39 296 before the MODQ chip) and
The node's fixture-backed tests (agg_executor::, the covered-assembly tests in node::, the
aggregation tests in rpc::, storage::seal_tests::)
read real bundle proofs through fixture_proof(k), <dir>/Test-{k}.proof. The set is exactly
Test-0, Test-1, Test-2: every literal caller uses k = 0, and
the_admission_recompute_reproduces_the_pinned_vectors_byte_for_byte reads Test-1 and Test-2.
The pinned set is committed, crates/randprotocol-node/fixtures/recursion (~1 MB), and is
fixture_proof's default directory; RECURSION_FIXTURES overrides it. So the suite needs no
setup (2026-10-06, no env var: 491 passed; 0 failed; 1 ignored). A missing file is a panic,
not a skip, by design.
cargo test -p randprotocol-node --lib # reads the in-repo set
scripts/recursion-fixtures.sh --check [<dir>] # check a set (default: the same dir)
scripts/recursion-fixtures.sh <circuits checkout> [<dir>] # re-prove (a re-pin), then checkWhy these bytes. A fixture's notes are random, and the pinned-vectors test pins the 118-word
interface list and digest (3534960f…) that one set of notes produces: the cache re-proved
2026-10-03 for the phase-2 re-vendor, which is what is committed. A freshly proved set passes
every other fixture-backed test and fails that one at "the pinned 118-word interface list"
(measured 2026-10-06: 490 passed; 1 failed; its inner_vk_digest assertion, the
data-independent half, passes). The script compares each file's sha256 with PINNED_SHA256 and
exits 0 when the set is the pinned bytes, 1 when a file is missing (or the checkout is refused,
or a generator failed), 3 when the set is complete but not the pinned bytes.
Re-pinning, when the vendored zkVM moves. The fixtures are proofs by circuits' zkVM
(research/ and the bundle guest in guests-compiled/), so a re-pin proves from a checkout
whose two trees are CIRCUITS_PIN in .github/workflows/ci.yml — the script refuses any other
(recursion/, the outer rVM that only hosts the generator, may drift). A set from another
constraint set fails the suite with "cs8 proofs carry pv::NUM public values" (the pre-cs8 cache
under circuits/recursion/target/recursion-fixtures does). The script runs circuits'
recursion/tests/fixtures.rs generator once per k in parallel (FIXTURE_PROFILE=Test FIXTURE_KS=k), which skips a fixture that still verifies and re-proves one that does not;
measured (2026-10-06, M4 Max): Test-1 and Test-2 proved in parallel in 4 min 36 s wall,
~4.6 min a proof. The new files then end at exit 3; in the same commit re-measure the test's
constants and circuits' recursion/docs/02-aggregate.md on them and move PINNED_SHA256.
CI. CI runs these tests (since 2026-10-08). check-and-test and nightly-proving run
scripts/recursion-fixtures.sh --check first, so a missing or edited fixture fails by name before
the suite, then the library suites read the in-repo set; scripts/ci-fixture-skips.sh now skips
only the rVM's two long proving tests, by full name (a_one_proof_aggregate_round_trips_…,
two_test_profile_bundle_proofs_…: tier 18 at N = 1 and tier 19 at N = 2, tens of GB; the N = 2
one is also #[ignore]d in source).
That closes PROC-7's open half for the node crate. (Until then the skip list removed these tests
on the grounds that a clean runner had no cache, which stopped being true when the set was
committed.)
A block whose bundles no aggregate ever covers stays valid: its raw bundle proofs are kept and three per block is the rate. No subsidy is paid for it; fees go to the proposer as today. The chain never depends on a GPU being online.
- The subsidy amount, the halving interval, and whether issuance has a hard cap.
Built, genesis-gated:
fees.usd_subsidyindexes the amount to a dollar target at a voted RAND/USD price, bounded per block by a genesis cap (docs/fees.md§1.3); the halving schedule remains the fallback. - The proving share: a fixed fraction of the bundle fee, or the whole fee above the floor.
Built, genesis-gated:
fees.proposer_share_bpsbuckets a fraction of the base beside the whole excess, andfees.prove_basefloors it (docs/fees.md§1.3; the proposal's values are 4 000 bps and 0.0006 RAND). - The sealing window
k: how many blocks an aggregate may lag, and whether a bundle can be covered twice (it should not; the first finalised aggregate wins, later ones covering it are invalid). - Submission spam: verifying an invalid aggregate costs ~0.8 s. Either aggregators register with a refundable bond and sign submissions, or nodes verify submissions off the consensus loop with a per-peer rate limit (the same machinery the RPC hardening task adds for bundles).
- The verifier guest: the STARK verifier (Poseidon2 Merkle paths, FRI folding) in RISC-V, its cycle count per inner 80-query proof, and the tier it lands in. This is the zkVM milestone the rest depends on.
Every number below was measured on the implementation branch, not projected. The test-profile
rows are the cluster capstone's (tests/cluster.rs's
a_fresh_node_syncs_pruned_history_with_one_rvm_verify_per_sealed_window); the production
rows are the activation checklist's placeholders (docs/deploy.md, "Chain 9 activation"),
filled at activation.
| measurement | value | source |
|---|---|---|
| bundle proof (register), test profile, tier 14 | 94.3–99.1 s across runs, 321 701–327 322 bytes (random notes vary the witness) | capstone register stage |
| aggregate prove, test profile, N=1 (tier 19) | 1568.2 s wall (~26 min) on a loaded shared box, 327 035 bytes | capstone aggregate stage |
| startup key-build, test profile (2¹⁹) | 13.0–13.4 s (the ops expectation at 2²¹ production: ~30–70 s) | warm_aggregation startup log, three nodes |
| warm aggregate verify, test profile | ~1–2 s (the first at a shape pays the 13 s key-build, once) | admission step 8 |
| seal → pruned | sealed at block 46; the pass at head 64 rewrote the record (every 16 blocks, gated at sealed_at + window) |
capstone |
| sealed resync, a fresh joiner | 1 rVM verification per sealed window (the covering aggregate's; a raw sync re-verifies each bundle); the 7-block sealed batch applied in under a second | capstone's verification counter |
| the capstone end to end | 1873.2 s (register 97 s → prove 1568 s → seal → prune → resync) | tests/cluster.rs |
| rVM aggregate proof size, test profile | 325–327 KB measured here (the circuits M5.3 record is 328 121 bytes; production measured 2026-09-30 at 1 563 226 bytes for N=1 under constraint set 8 — under the 2 MiB default cap, not the ~0.5–0.6 MB first estimated) | circuits/recursion/docs/02-aggregate.md |
| tree, test profile (circuits docs/08 §5, 2026-10-09, 48 GB laptop) | leaf rv32n L = 1: 169 640 rows, tier 18; step rv32t_leaf: 287 107 rows, tier 19, 280 133–280 772 bytes, 34.35 GB max RSS, 177 s; root rv32t_int: 306 985 rows, tier 19, 283 172 bytes, 33.21 GB, 182 s |
circuits recursion/tests/tree.rs, docs/measurements/2026-10-09-tree-test-fixtures-laptop.log |
| tree, production (circuits docs/08 §3) | step rv32t_leaf at the production leaf shape 1 294 577 rows, tier 21, emulated; proved leaf/step/root rows, memory and times pending (droplet) |
circuits recursion/tests/tree_measure.rs |
| the interface conformance vectors | inner_vk_digest 33a94ec6…92a1c8, the 115-word bound list (AGG-2; the stand-in binding of circuits/recursion/docs/02-aggregate.md), digest 9833ac5b…fdc868e — reproduced byte-for-byte by the fullnode's recompute |
the conformance suite (agg_executor.rs) |
Register an aggregator (one bond-burning bundle, proving share 7 over the floor) → the chain
halts for the prove (the cover window cannot scroll) → one rVM aggregate over the bundle's
proof (the proving slot's single hold) → submitted, admitted (its own verification), pooled
per §3.4's selection, committed — the sealing block — the mark lands atomically with the
commit → the prune pass rewrites the record (34 public values + the 7 declared shape bytes,
~3.3 KB against ~1.3 MB raw) → a fresh node joins and syncs the pruned block in sealed form,
reaching the same blocks and state roots at every height with exactly one rVM verification —
and the supply audit holds end to end (total_supply == issued − slashed, with one subsidy
minted against sealed_blocks == 1).
The capstone passed alone, twice (1873.2 s, 1876.4 s), and stalled inside the full cluster suite three times — the fresh joiner stuck part-way with nothing outstanding and nothing counted as failed. The mechanism, once the warn-level keeper diagnostics named it, was two give-ups compounding:
- A batch cut between a pruned block and its cover is unservable, deterministically. The sync client's batch count halves on wire failures (under suite load, toward 1) and the server's byte budget cuts where it cuts; either can end a batch after a sealed block (the window at 33) but before its covering aggregate (block 46). The joiner's coverage check refuses the batch — correctly, it is batch-atomic — and the raw-form fallback asked another peer, which pruned the same record and served the same split form. A give-up that can never succeed, retried forever.
- The sync peer picker had no fallback. When the fresh peer's connection flapped or its status had not landed, the picker returned nothing and the sync stopped silently until gossip happened to re-trigger it; a send that failed was not even counted.
The fixes, each with its node-lib regression test:
- Coverage-closed serving (
node.rs'sclose_batch_coverage): before aBlocksresponse goes out, every served pruned entry's seal mark is resolved and the batch extends — past the count asked, past the soft byte budget — until every cover it needs is in. The extension's only ceiling is the reader's own wire limit; a chain whose cover sits beyond that is the genuine archive case the raw-form fallback exists for. Pinned bya_batch_cut_short_of_its_cover_extends_until_the_coverage_closes(the count cut),a_batch_cut_by_bytes_short_of_its_cover_extends_within_the_reader_limit(the byte cut), andthe_extension_stops_at_the_reader_limit_and_serves_what_it_can(the ceiling). - The robust picker (
node.rs'spick_sync_peer): the freshest connected peer ahead wins, but when the chain is known ahead and no connected-and-fresh pair exists, any connected peer is worth one round trip — a possibly-stale answer costs a little, a silent stall costs the chain. A send that cannot go out warns, counts, and tries the next candidate. Pinned bypick_sync_peer_prefers_fresh_and_falls_back_to_any_connected.
A committed transaction is stored twice: inside its block's CF_BLOCKS row, and as its own
CF_TXS record. Until issue #51 the pruning pass rewrote only the record, so the block row kept
the raw ~1.3 MB proof and the disk §6.2 promises back never came back; and the pass found its
work by walking every seal mark the store had ever written, every 16 blocks, for ever. Both are
fixed node-side (no consensus, wire or genesis change; dormant while no chain carries an
aggregation section):
- The block row shrinks with the record, in the same synced batch (
Storage::stage_pruned): the transaction at the record's index becomes the record's marker form. That is exactly the row a block synced in sealed form has always been stored as (commitstores a block "as served"), so no reader meets anything new, and both copies now hold one form:- Serving never used the raw row for a pruned bundle —
sealed_form_ofserves the record's marker form and side entry whichever row it finds — so aBlocksresponse is byte for byte what it was, and coverage-closed serving (close_batch_coverage, above) reads only the seal marks. What the raw row could have offered, a raw proof to a syncer whose fallback wants one, this node never served; that fallback was and is an archive's job (--keep-raw-proofs, which never runs the pass). - The startup replay meets the marker and applies the block through the pruned branch
with the side table rebuilt from the records (INTERFACE-3, the sealed-synced path): each
bundle's public fields are bound to its record's
OUTandH_PUBwords, and the covering aggregate's block, replayed after it, verifies the rVM proof over those same records. That is the soundness a sealed-synced node already rests on, and the one §6.2 prunes for: once a window has passed its seal, the aggregate — not the bundle's own STARK — is the proof. - The block hash cannot move: the marker form hashes to the raw hash (the proof enters the
id by digest,
rand-txid-2), and the write refuses unless the slot's hash, the record's hash and the block's tx root all still agree. - Visible changes:
rand_getBlockByHeight/ByHashreport a pruned bundle'sproof_lenas the marker's 44 bytes, asrand_getTransactionalready did off the record; a by-hash block fetch of such a block returns the marker form, as a sealed-synced node's always has — by-hash fetches serve pending and recently committed blocks, never history a window past its seal.
- Serving never used the raw row for a pruned bundle —
- A height index for the pass:
CF_SEALSrowsh ‖ sealed_at (BE) ‖ bundle, written with every seal mark. The pass walks them from the lowestsealed_at, stops at the first still in its window, deletes each row it handles, and handles at mostPRUNE_SEALED_PASS_MAX(64) a pass — steady state is 16 ×max_covers, so the cap only paces a backlog. Its work is what came due since the last pass, never the sealed history. History pruning and truncation drop a mark's index row with the mark. - Upgrade: a store sealed by an older build is indexed once at open (
seal_heights_built); the next passes then also shrink the block rows an older pass left raw behind already-pruned records. An older build ignoreshrows and replays marker rows (INTERFACE-3), so a rollback needs nothing.
Pinned by the_sealed_pruning_pass_visits_only_the_marks_whose_window_passed_once,
the_sealed_pruning_pass_drains_a_backlog_a_bounded_step_at_a_time,
a_store_sealed_before_the_height_index_gets_it_at_open,
history_pruning_and_truncation_drop_the_height_index_rows,
a_pruned_bundle_shrinks_its_block_row_too_and_the_startup_replay_still_verifies,
a_block_row_left_raw_behind_a_pruned_record_shrinks_on_the_next_pass (storage.rs) and
a_block_row_this_node_pruned_serves_coverage_closed_like_a_sealed_synced_one (node.rs) — all
stub-proved, so they run without a recursion fixture.
The admitted shape chain 9 registers is the fleet's own measured classes for a 2-in/2-out
bundle — {tier, program 12, input 10, keccak 0, sha256 0, public 2, mem 16} — and NOT the
recursion fixtures' shape (13/12/18): the same guests::bundle() underlies both, but the
chain executor pins the tight declared classes (ZkExecutor::bundle_heights) while the
fixtures' auto-derived ones land a rung or two looser, and a declared height is part of the
shape. The cluster capstone pins this distinction by asserting the committed bundle's header
equals the admitted shape; a genesis cut with the fixtures' classes would admit a shape no
fleet bundle can ever match.
Re-measure before activating (2026-09-19). That shape's public 2 is the empty public
segment's height. Since the transaction binding (docs/confidential.md, "Transaction binding")
every bundle proof carries eight binding words and declares public 4, so no bundle proved after
that fork matches the chain-9 shape. Aggregation is inactive on every chain and chain 14 is cut
without it; re-measure the admitted shape (and the recursion fixtures) before any chain is cut
with an aggregation section.
Enforced at genesis (2026-09-28, IFACE-9). Genesis::validate now refuses an admitted shape
that is not the bundle header every accepted bundle carries — tier 14, no keccak or sha256 table,
public height 4 (BUNDLE_PROOF_TIER, BUNDLE_PUBLIC_LOG_HEIGHT in core, pinned to the zkVM's
own numbers by an agg_executor test) — and rand-node genesis refuses one the rVM cannot build
an inner verifier key for (InnerShape::try_of, via agg_executor::check_admitted_shape).
deploy/cut-chain9-genesis.sh's default public height is 4 accordingly; the program, input and
mem heights are still the re-measurement above.
Related: docs/block-space.md (the numbers and the three remedies), docs/fees.md,
docs/supply.md, docs/staking.md, docs/zkvm.md.