wrap steps — adversarial review, 2026-10-10
Review of zheng #56 (feat/wrap 5f39786, stacked on #53 feat/ivc, #52
merged in) and lens #21 (feat/field-native-whir c91f55a). Contract:
specs/recursion.md § final verifier, § field-native WHIR, § wrap;
ledger: specs/soundness.md § field-native WHIR and wrap; build report:
audit/wrap-2026-10.md. Branch fix/wrap-review (off feat/wrap).
Apple M4 Max, 48 GB, --release.
verdict
No soundness hole found. Two hardening fixes (H1, H2), two spec corrections (D1, D2), one informational note (I1). Every new test passes on the fixed branch; the PR's own tests are unchanged and pass.
method
The previous review (#53) found a native-vs-circuit divergence (a non-base value stopping the native sponge, so the state the native verifier read was not the one the last public input hashed). This review looked for that class everywhere the wrap adds an interpreter path, and at every new protocol step:
- every
Opsmethod natively (Native, plain and batched) and in the circuit (Builder+ the circuit AIR) on adversarial values: Fp3 values in base lanes, non-bit Merkle directions (binary and 4-ary), bits of an Fp3 value, an Fp3 Merkle root, a zero inverse, thep − 1index limb, nonces≥ p; - every
o.value(…)in verifier code (data the circuit would bake as a constant):ops::inv,step::is_zero(sound gadgets), and the final mode's key evaluation and wiring weight (native only — H2); - the circuit's cells under the final mode (no memory argument): which cells the linear wiring pins, against the memory argument;
- transcript order of every wrap message against the challenges it feeds;
- the decider, the deferred claims, the public digest
Xand the header.
findings
H1 — a wrap key was not bound to the recursive proof it verifies (fixed)
wrap::verify_statement(st, ivc_whir, k, fp) prepared the statement under
key(ivc_whir, fp.log_rows) and verified the final wrap under k, whose
inner circuit runs the IVC final verifier for the step size and WHIR
parameters k was derived from. Nothing compared the two. A header with
another step size was refused only incidentally: the deferred nox-public
claim, split at the header's n + 32 instead of the key's, did not
evaluate to the carried value (pbar_nox), and for a key more than five
step bits below the header point.split_at(n + 32) would panic — not
reachable today, the step circuit needs n ≥ 15 and prepare caps
n ≤ 20. Not exploitable (the statement digest, X and the claim's
evaluation all differ), but the binding rested on an accident.
Fix: WrapKey::ivc = (WhirParams, n), set by derive_key_ivc, carried by
derive_key_wrap; verify_final refuses (prep.key.params.whir, prep.key.params.n) ≠ k.ivc before evaluating anything. Tests:
recursion::wrap::review::a_final_proof_under_another_step_size_is_refused_by_its_key,
tests/wrap_review.rs (header log_rows + 1).
H2 — a final-mode key could be put inside a circuit (fixed)
The final verifier evaluates the key's columns at ρ and the wiring
weight's closing term in the field (o.value), as native verifiers may.
derive_key_wrap accepted a final-mode inner key: inside a circuit those
values become constant gates of the key derivation's dummy proof, so the
level would check the inner zerocheck against a key that is not the
circuit's. Completeness failed (the prover's layout check refuses), so no
deployed chain was affected; soundness of such a key would be void.
Fix: derive_key_wrap refuses a final-mode inner key. Tests: both review
tests above assert the refusal.
D1 — "the binary node's tag and the leaf sponge's capacity tag keep the three input kinds apart" (corrected)
A 4-ary node's input is its four children, sixteen lanes, no tag, and the
siblings are the prover's: with siblings (NODE_TAG, 0, 0, 0) and
(0, 0, 0, 0) a 4-ary input equals a binary node's input; a leaf block's
likewise. The trees stay binding because the verifier's level schedule
(depth and fan-in per level, from the shape) never parses one position
two ways, and the truncated permutation is collision resistant (2^128
generic). specs/soundness.md § field-native words now says so.
D2 — grinding priced in; the ledger test pins another profile (corrected)
Every wrap row is ≥ 128 with grinding priced as hemera work. Removing
grinding, the weakest rounds are 104.42 (inner 1/64), 104.29 (inner
1/256, 24 bits) and 98.02 (final 1/256: shift_2 after 30 bits of
query grinding). The spec now states the interactive values and that the
Q·2^-128 bound counts grinding permutations in Q. The PR's
every_ledger_row_of_the_wrap_profiles_reaches_128_bits checks the inner
1/256 level with 30 grinding bits while the measured chain
(ZHENG_WRAP=6i,8i,8f:30) ran it with 24; the new test checks the shipped
chain.
I1 — base symbols' extension limbs (informational)
A round-0 opening of a base word is hashed and folded from c0 only; an
in-memory WrapProof whose base symbols carry non-zero c1, c2 verifies
as the same proof. The wire form carries one limb per base symbol, so the
bytes are identical and canonical (tests/wrap_review.rs asserts it).
checked sound
| item | argument | test |
|---|---|---|
Ops native vs circuit on refused values |
each value refused natively, when computed (the prover cannot re-choose it), breaks the circuit's rows in both modes: a local constraint, or the memory argument (inner) and the wiring (final) | values_the_native_verifier_refuses_break_both_wrap_modes |
G compiled (8,212 gates) vs Graph::eval |
the compiler rewrites one polynomial (affine forms, fused products; the only inverse is of a nonzero constant coefficient) | PR's expr tests |
| final mode, linear wiring | slot_values is linear (no constant, checked per row); every recorded read is wired (reads = Σ builder reads); λ after W1's root and OOD; u_λ and the key from the verifier's own key; every phase-1 cell pinned by a local constraint or a nonzero u_λ coefficient, and none the memory argument pins is left to the wiring's choice |
the_linear_wiring_pins_every_cell_the_memory_argument_pins |
| successor columns sent | next_cols from Graph::used_inputs of the final graph (structural, an over-approximation); unsent successors enter as the verifier's zero, never the prover's; the sent ones bound by the nxt row-column weight |
tests/wrap_review.rs (each of 20 tampered) |
| claims as WHIR weights | OOD, row × column (eq and cyclic nxt), wiring: every value absorbed and every weight's challenge squeezed before WHIR's γ; weights multilinear (WHIR Thm 5.2, d* = 3) |
tests/wrap_review.rs |
| 4-ary levels, shared trees | directions from transcript limbs ≠ p − 1; fixed level schedule; a group's leaf hashes its members in order, split by width; the verifier supplies one root per group |
PR's circuit, whir tests; D1 |
| decider in circuit | instance (accumulator root, claims, key claim) absorbed under tag DECIDE; key root a constant; (1 − g_6)K̃_lo + g_6K̃_hi checked on the line |
PR's tests |
| deferred nox-public claim | carried in FinalProof, evaluated natively from the statement (pbar_nox) before the wrap; its point and value enter X, so a true claim at another point is refused by the wrap |
tests/wrap_review.rs |
| level binding | each level outputs X = H_PUBLIC(publics, claim); the IVC level asserts context, chain, step count, cyclic boundary against them and uses the challenges and constants in G; a wrap level recomputes X for the level below |
tests/wrap_review.rs (inner and final proofs under any other X, under the other level's key) |
| inner-mode memory | cyclic successor: the running sum telescopes (Σ δ = 0); every slot row has MEM; multiplicities preprocessed |
PR's circuit tests |
| grinding nonces | < p in shape, absorbed as one limb; unused nonces zero |
PR's whir tests |
tests/wrap_review.rs also tampers every AIR and opening message of a
final proof alone (local, successor, zerocheck, OOD, WHIR OOD, folding
sumchecks, final polynomial; +1 and +T): 546 tampers, 546
refused, 0 panics.
ledger, recomputed
shift_2 of the final level (rate 1/256, ℓ = 20, folding 4): round 1
has rate 1/2^11, Johnson m = 13, 18 queries, 30 query-grinding bits;
round 2 rate 1/2^14, Johnson m = 3, one OOD sample.
log(1 − δ_1) = −11/2 + log2(1 + 1/26) = −5.4456; queries
18 × −5.4456 = −98.02; the list term log2 3 + 14 + log2 19 − 192 = −172.2 is negligible; 98.02 + 30 = 128.02. Fresh-word binding
−(2·(log2 9 + 8) − 1 + (20 − 192)) = 150.66. Wiring 192 − 18 = 174.0. All agree with wrap::ledger.
| level | weakest (grinding priced) | interactive weakest |
|---|---|---|
inner 1/64, 24 bits, n = 16 |
fold_0 128.24 | 104.42 |
inner 1/256, 24 bits, n = 15 |
shift_1 128.29 | 104.29 |
final 1/256, 30 bits, n = 14 |
shift_2 128.02 | 98.02 |
tests and cost
| test | time | peak RSS |
|---|---|---|
recursion::wrap::review (4 tests) |
12 s | 4.9 GB |
tests/wrap_review.rs (one chain: IVC 1/16 → inner 1/16 → final 1/16) |
149 s | 11.4 GB |
PR's tests/wrap.rs (same chain, unchanged) |
102 s | 12.7 GB |
cargo test --lib |
274 passed, 3 ignored | — |
not covered
- the shipped 1/256 levels end to end (32–38 GB, 13–20 min a chain); the review chain runs every level at 1/16, the same code paths;
- the wrap composition and recursive Fiat–Shamir stay conjectured;
- lens #21 adds only soundness terms and a spec section; checked against the formulas above, no change needed.