recursion — adversarial soundness review of PR #53, 2026-10-09
superseded by hemera 0.4 (2026-10-16): the hemera figures below (x⁻¹ partial rounds, 8 + 16 rounds, 144 constants, ~736 constraints) describe the 0.3 record; the frozen profile is x⁷ in every round, 6 + 6 + 48 rounds, 240 constants, ~1,440 constraints — hemera/audit/profile-0.4-2026-10.md.
Target: zheng feat/ivc 5650094 (PR #53, stacked on feat/accumulation,
PR #51): rs/src/recursion/, rs/src/fs.rs, rs/tests/recursion.rs,
specs/recursion.md, specs/soundness.md § recursion,
audit/recursion-2026-10.md. Stand: the siblings of stand B (lens and joy
feat/accumulation 3cf2aaa / 69a15ef, hemera cf28a64, nox 2f09ca3).
Fixes and attack tests: branch fix/ivc-review. Machine: Apple M4 Max,
--release, shared with other agents.
findings
| id | severity | finding | status |
|---|---|---|---|
| F1 | soundness (verifier) | the final verifier hashed the proof's state with a native interpreter and ignored its failure; an extension value in a base slot made the "digest" raw, prover-chosen lanes, unbinding the state from the last step's public input | fixed |
| F2 | malleability | grinding nonces were absorbed as Goldilocks::new(n) without a range check: n and n + p verify alike |
fixed |
| F3 | ledger gap | the τ, shift-reduction and boundary-batching rounds had no ledger row | rows added (all ≥ 188 bits) |
F1 — the final state was not bound to the last public input
ivc::verify_prepared computed x = state::digest_native(proof.state) and
verified the last step's proof against x. digest_native ran the generic
digest on a fresh Native interpreter and returned its output without
looking at Native::error. Native::permute records "a base lane holds an
extension value" and from then on skips every permutation (the outcome is
"fixed" — but nobody read it), so the four digest limbs were the raw rate
lanes 0..4 of the state sponge's last block: pv.point[20] (three lanes)
and pv.point[21].c0 — coordinates of the carried circuit-key claim.
The native step verifier reads the state's spot points ω^s only through
eq_pow, so an extension value there trips nothing else. The forger:
- runs a base step (live = 0, verifying nothing) for the last segment;
its circuit outputs the initial state, digest
d; - writes a state by hand — step count
S − 1, any chain prefix, any boundary rows, the zero accumulator withspot[0].x = 1 + T, deferred claims at points of its choice with their true values — and setspv.point[20..22]so the raw lanes equald(pv.valueis recomputed asP̄_V(pv.point), which the key's public evaluation allows); - proves the last segment with the honest prover from that state and
d.
Every final check passes: context, chain, step count, cyclic boundary, the
three folded deferred claims, the decider. Test
recursion::review::a_state_unbound_from_the_last_public_input_is_refused
does this on tree-12 (two segments) with segment 0's pre-committed word a
replaced by a zero table: accepted on 5650094 (run against the
unpatched tree: a state unbound from the step's public input was accepted), refused after the fix. Consequence: the prover proves only the
last segment, from a machine row (b_last), memory running sum and
deferred claims it chose — a false output follows from any segment that
satisfies the constraints from a chosen start.
Reach: the wire form (IvcProof::from_bytes) encodes a base item as one
canonical limb, so a byte proof could not carry the extension value; the
struct API ivc::verify(&MachineStatement, &IvcProof, _) accepted it.
Fix: state::is_canonical (every base item has zero extension
coefficients) is part of the final verifier's shape check; digest_native
returns Err when the native interpreter recorded a failure (it is also
used by the decider transcript). With base items base, the native digest
equals the circuit's (absorb_free takes the base limb).
F2 — grinding nonces
AccProof::{comb_nonce, query_nonce} are u64, absorbed as
Goldilocks::new(n) (no reduction) and hashed through as_u64 (reduced),
and the wire reader takes them as raw u64: n + p is a second encoding
of a proof. step::check_shape now requires both < p
(recursion::review::a_non_canonical_grinding_nonce_is_refused).
items checked sound
- native = circuit. One program over
Ops; the circuit's checks that the builder only records at witness time are each forced by the AIR: base lanes by memory (a slot fingerprints the base column, the cell must be base),bitsof an extension by memory onBVAL, Merkle direction booleanity (node·bit·(bit − 1)), roots (live·ROOTCHK). Hemera gadget: rows 0–3 computeMDS, full rounds 0–3, the 16 partial rounds throughw = u⁻¹pinned uniquely byuwu = u,wuw = w(w = 0iffu = 0), full rounds 20–23 — checked against hemera bythe_row_layout_computes_hemera. Decompositions: 64 boolean digits, 16-bit chunk accumulation,hi = 2^32 − 1 ⇒ lo = 0(the_decomposition_refuses_non_canonical_and_non_boolean_digits:v + ppasses every accumulation row and is refused by canonicity only; a digit 3 compensated by its neighbour is refused by booleanity). Memory: write-once cells with preprocessed addresses and multiplicities (−readsat the writer), cyclic running sum through the boundary row.every_cell_the_circuit_reads_is_constrainedtampers every phase-1 and phase-2 cell of an honest run (gates, wide/free/keep lanes, a decomposition, a Merkle opening with root check): every cell breaks a constraint or the memory except the ones free by design (unused operands, unread free outputs, decomposition scratch outside its phase, root/direction cells with no check, padding). live.n[LIVE] = LIVEon every row including the wrap (the circuit's successor of the last row is its own row 0 viab_v), boolean, written to memory at row 0 slot 16, so the select gate and the assertion gating read one value.live = 0makes the step output the initial state of the context it was given (step count 0, chain 0):the_base_step_outputs_the_initial_state_whatever_it_is_given. A base step after the first restarts the count; reachingstep = S,chain = Dagain needsSverified steps. The second half of the F1 test proves a base step in last position under its honest state: refused.- binding.
x = H(state)is the step's first absorb and the circuit's output row (OUT·(o8 − pin), deferred inG); the state carries the context (statement digest: program, input, output, cycles, geometry; the chainD;g0, pn0, pv0), checked once at the end;P̄_noxis evaluated atbits(step), the chain absorbs word a's root in step order and must equal the header'sD, from which(α, β)and the context derive — no splicing of programs or reordering of segments. The circuit key is bound by theP̄_Vclaim against the parameters' key. (F1 broke this link for the final state only.) - deferred claims. Each step folds the carried claim and the new one
along their line:
h(0)= carried value (bound byx),h(1)= new value (from absorbed messages),h(2..=deg)absorbed beforer. One claim of each kind lives in the state; it cannot be dropped or zeroed without changingx; the final verifier evaluates all three exactly (Relation::g,pbar_nox,pbar_v). Degrees 32 / 52 / 22 matchG(8 + 3·8) and the multilinear claims (n + 32 + 5,n + 7); an honest fold would fail otherwise. - base case. The initial state is a function of the context only:
zero accumulator (claims true for the zero word at any point), claims
at the zero point with values
g0, pn0, pv0hashed into the context. The first verified step takesb_infree and records it asb_first; the cyclic checkb_first = b_out(last)closes the run. - Fiat–Shamir. Step order:
x; root a, OOD answers (points from the root alone); root b,ζ_b, answers;(α_V, β_V); root c, answers; boundary rows;τ, seeds; zerocheck rounds; column and public values;γ_n, γ_v; line folds; shift batching, rounds, values; boundary batching; accumulation (γ, rounds, evaluations, comb grinding,r, new root, OOD, query grinding, indices). Every value a challenge depends on is absorbed before it, in the circuit and natively (one program). Indices exclude the limbp − 1. - ledger. Recomputed below from
lens::rspcs::soundnessformulas (Johnsonm = 19at rate 1/16,m = 7at 1/64;|K| = p³, 192.00 bits). The "IVC composition" row is stated as conjectured with the right reason (recursive Fiat–Shamir with hemera evaluated in the relation); its argument "a final state is accepted only if it is a successor of the initial state throughSverified steps" was false for the struct API until F1's fix. fs.rsrefactor. The trait forwards to lens's ownabsorb_fp3,squeeze_fp3,absorb_fp3_slice; the generic provers changed only their signatures.tests/transcript_pin.rshashes the machine proof bytes ofaddand oftree-7in 2^8-row segments: identical on #51 (feat/accumulation75f3846) and #53 (196debd3…,ce2fd453…), now pinned.
ledger, recomputed (recursion::params::tests)
| row | rate 1/16 | rate 1/64 | recomputed |
|---|---|---|---|
| accumulation batch | 177.77 | 177.67 | 192 − log₂ L − log₂(J − 1), L = 19·16 / 7·64, J = 64 / 47 |
| accumulation sumcheck | 182.75 | 182.19 | 192 − log₂ L − 1 |
| accumulation combine (22 / 20 grinding bits) | 128.00 | 128.65 | `−log₂(3·(ε_mca + L/ |
| accumulation OOD | 155.50 | 154.39 | −(2 log₂ L − 1 + s(ℓ − 192)), ℓ = 21 |
| accumulation spot (24 grinding bits) | 128.01 | 128.42 | −t·log₂(1 − δ) + 24, t = 53 / 36, 1 − δ = (1 + 1/2m)·√ρ = 128.014 / 128.417 |
| decider WHIR | 128.01 | 128.24 | lens security_bits |
zerocheck μ (structured powers, B = 9) |
187.42 | 187.42 | 192 − log₂ 24 |
| zerocheck τ | 188.09 | 188.09 | 192 − log₂ 15 (added) |
| zerocheck round (degree 9) | 188.68 | 188.68 | 192 − log₂ 10 |
| shift round (degree 2); ζ, β | 191.00 | 191.00 | 192 − 1 (added) |
| shift / boundary column batching | 189.42 | 189.42 | 192 − log₂ 6 (added) |
| line folds G / P̄_nox / P̄_V | 187.00 / 186.30 / 187.54 | same | 192 − log₂ {32, 52, 22} |
| column batching γ_n, γ_v | 189.19 | 189.19 | 192 − log₂ 7 |
| circuit memory α | 172.91 | 172.91 | 192 − log₂(17·2^15) |
| circuit memory β (pairs) | 154.83 | 154.83 | 192 − 2 log₂(17·2^15) + 1 |
Tightest: combine 128.002 and spot 128.014 at rate 1/16 — at the floor by
construction (comb_pow, query_pow are the least grinding reaching 128).
tests added (fix/ivc-review)
recursion::review::a_state_unbound_from_the_last_public_input_is_refused(F1; ~130 s)recursion::review::the_base_step_outputs_the_initial_state_whatever_it_is_givenrecursion::review::a_non_canonical_grinding_nonce_is_refused(F2)recursion::circuit::tests::every_cell_the_circuit_reads_is_constrainedrecursion::circuit::tests::the_decomposition_refuses_non_canonical_and_non_boolean_digitstests/transcript_pin.rs::machine_proofs_are_byte_identical_to_the_pre_recursion_prover- ledger rows τ, shift, boundary batching in
recursion::params::tests
residual (not defects)
Nativestops hashing after its first failed check; every remaining caller that does not inspect the error (ctx_native,statement_digest,run_challenges,pre_pointsin the prover,leaf_digestin the wire decoder) hashes base-typed inputs only.- The single-cell scan shows no unconstrained cell; it does not prove the absence of multi-cell under-constraint — the "recursion circuit = the step verifier" row stays tested, not proven.