Skip to content

V12 ZK-E2E - #648

Merged
illuzen merged 18 commits into
mainfrom
illuzen/v12-zk-e2e
Aug 7, 2026
Merged

V12 ZK-E2E#648
illuzen merged 18 commits into
mainfrom
illuzen/v12-zk-e2e

Conversation

@illuzen

@illuzen illuzen commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Security review: wormhole, settlement, and metering fixes

This PR addresses a batch of security-review findings against the V12 wormhole / ZK-tree
stack. Each item was first verified against the code, reproduced with a red test where
applicable, and fixed at the root cause; findings that turned out to be intended design or
operator-trusted boundaries were documented where the next reviewer will look instead.
Findings touching the turnstile (PotentialWormholeBalance reveal/ambiguity machinery) were
skipped here — the turnstile is removed in a separate PR.

Fixes

Proof recording completeness

  • Exit credits pinned against double recording (903c82e3) — Verified the reported
    double-record (extension event scan + process_exit_bundle) is not reachable: bare
    extrinsics never run post_dispatch, and increase_balance emits no Minted event.
    Removed the dead bare_post_dispatch override that suggested otherwise and added
    regression tests pinning both facts.
  • Hold-transfers recorded (0e7f4b6f) — Reversible-transfer guardian seizures and
    recoveries move value with transfer_on_hold, which emits Balances::TransferOnHold
    rather than Transfer; the recorder ignored it, so the guardian's credit got no ZK-tree
    leaf. The event matcher now records it like any transfer.
  • Reserve repatriations recorded (1cbc1b4b) — Same class:
    recovery::close_recovery seizes the rescuer's deposit via repatriate_reserved
    (Balances::ReserveRepatriated), which the recorder also ignored. A sweep of all other
    balance-crediting APIs found no further gaps (referenda slashes burn to (), fee flows
    are excluded by design, TransferAndHold is unused).
  • Zero-amount credits dropped (14d7fbe0) — Zero-value transfers (reachable from
    plain transfer_keep_alive(0) and scheduled transfers) created pure-state-growth leaves.
    record_transfer_proof now drops zero-amount credits at the single chokepoint, and
    pallet-reversible-transfers rejects zero-amount schedules outright.

Genesis

  • Genesis proofs derived from balances (86612f34) — The wormhole pallet's
    independent endowed_addresses genesis vector could disagree with pallet_balances
    (duplicate, oversized, or missing entries), creating exit capacity for value never
    issued. The vector is gone; the pallet now derives its block-1 transfer proofs directly
    from the actual balances genesis, making mismatch unrepresentable.

Settlement admission

  • Aggregator rebate address binding pinned (ca9da896) — The rebate to the
    public-batch aggregator is intended design (a portion of the volume fee), but the
    aggregator address binding is now pinned by a pre-dispatch regression test against
    redirection.
  • Settlement proof bytes bounded and canonicalized (bc66b510) — proof_bytes
    reached to_vec() + plonky2 from_bytes unbounded on the fee-free unsigned path, and
    the parser silently ignores trailing bytes, giving one proof unboundedly many byte
    representations that each re-cost a full copy + parse at pool admission. Pre-validation
    now gates length at MAX_PROOF_BYTES (512 KiB vs ~151/224 KB real proofs) before any
    copy, and requires the bytes to round-trip through to_bytes() so each proof has exactly
    one accepted encoding. Release-mode recalibration confirmed the existing pre-validation
    weight constants still cover the added serialize pass.

RPC

  • zk-tree proof RPC requires canonical hashes (cd045424) — resolve_proof_block
    accepted any backend-resolvable hash, so side-fork blocks (and fork blocks above best,
    where the one-sided window check saturates to zero) produced proof material that
    settlement always rejects against frame_system::BlockHash. The resolver now rejects
    heights above best and requires the requested hash to equal the canonical hash at its
    height.

Weight / metering

  • ZK-tree Poseidon hashing charged in leaf-recording weights (80802efa) — Wormhole
    settlement, the proof-recorder extension, and reversible-transfer execution all charged
    the leaf insert's DB ops but not its depth-proportional Poseidon hashing. A new
    insert_leaf_hash_ref_time() helper on pallet-zk-tree is now charged on all three
    routes, tracking the live tree depth.
  • High-security policy reads charged (50c5f0b1) — is_call_allowed hides an
    is_high_security storage read; as_derivative and as_recovered enforced the policy
    without charging it, and Multisig::propose paid it twice while documenting one read.
    The policy predicate is split into is_call_allowed_given so propose reuses its
    fetched classification, and both wrapper weights now charge the read.
  • Post-dispatch event scan metered (c35ec302) — The recorder's event scan
    stream-decodes every event record present at scan time (Iterator::skip still decodes
    the prefix it discards), which was unmetered — batched remark_with_event traffic
    produced free decode work. The scan is now registered against the block
    (event_scan_weight: one Events read + a conservative 1µs/record ceiling) alongside
    the existing recording-shortfall mechanism.

Consensus

  • RuntimeEnvironmentUpdated no longer deposited (9dffec26) — The QPoW header
    commits a fixed 110-byte digest window that the pre-runtime item and seal fill exactly,
    and import rejects anything larger. Upstream frame-system's 1-byte
    RuntimeEnvironmentUpdated deposit on set_code / set_heap_pages therefore made every
    environment-changing block unimportable network-wide — runtime upgrades could not land
    through normal block production. Nothing in the node stack consumes the item (clients
    detect upgrades from the :code state key), so the fork's deposits are removed and the
    no-runtime-digest-items invariant is documented on deposit_log and DIGEST_LOGS_SIZE.

Documented as accepted limitations (no code change needed)

  • Circuit tree-depth limit (b570b391) — The on-chain tree may grow to depth 32 while
    the circuits fix MAX_DEPTH = 16. Documented as a deliberate "fix it when we get close"
    trade-off on MAX_TREE_DEPTH and in docs/zk-trie-architecture.md: timeline to
    exhaustion is years-to-centuries, LeafCount makes it observable far in advance, and the
    circuit-update path is spelled out.
  • Genesis-builder trust model (60b2d4a1, 84ee98f8) — No input-size bound and
    panic-on-malformed-input in the genesis builder are not vulnerabilities: genesis
    construction is an operator-trusted, chain-setup-time boundary, and panicking is the
    intended FRAME error channel for BuildGenesisConfig::build. Documented in
    genesis_config_presets.rs.
  • Proof-recorder coverage boundary (ab976ca9) — The extension's doc overclaimed that
    scheduler-dispatched transfers were covered; extensions never run for hook-context
    dispatch. The doc now states the real contract: transaction events are scanned,
    hook-context credits record explicitly (reversible-transfer execution, mining rewards),
    and governance-enacted calls via the scheduler are a known, accepted gap — only Root can
    reach that path, and Root can already forge leaves outright via set_storage.

Reviewed, no change

  • Guardian existence at set_high_security — Rejected the proposed existence check: a
    guardian account may legitimately be funded after enrollment.
  • Turnstile-related findings — Deferred to the separate turnstile-removal PR.

Test plan

  • Red test written for every applicable fix before the change; green after.
  • pallet-wormhole (75), pallet-reversible-transfers, pallet-utility (38),
    pallet-recovery (35), pallet-multisig (57), frame-system (76) suites pass.
  • quantus-runtime lib (45) + integration (28) suites pass.
  • quantus-node zk-tree RPC resolver tests pass (7).
  • Release-mode pre-validation timing harness re-run after the proof-framing change;
    weight constants hold with large margin.

illuzen and others added 18 commits August 7, 2026 13:18
The reviewed finding (exit credits recorded twice via the event-scanning
WormholeProofRecorderExtension) is not reproducible: exits are bare
extrinsics, so the extension's post_dispatch never runs for them, and
Unbalanced::increase_balance emits no Minted/Transfer event to scan.
Add regression tests pinning both guards and document why the exit
credit must stay on the event-free increase_balance path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the independent endowed_addresses genesis vector and
GenesisEndowmentsPending staging. At block 1, record transfer proofs
from every account that exists with a balance, so exit capacity cannot
disagree with actually issued genesis value.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ches

The aggregator rebate is deliberately permissionless (whoever aggregates
names its own payout address), which is safe because the address is a
proof public input: redirecting it invalidates the proof at the
pre_dispatch block-inclusion gate. Add a regression test pinning that
property.

Co-authored-by: Cursor <cursoragent@cursor.com>
The on-chain zk-tree can grow to depth 32 but the wormhole circuits
accept Merkle paths only up to depth 16 (~4.3B leaves). Document that
this is a deliberate proving-cost trade-off, the timeline to exhaustion
(years to centuries depending on transfer volume), and the circuit
update + runtime upgrade planned when the limit approaches. Also fixes
the MAX_TREE_DEPTH comment that wrongly claimed circuits support
depth 32.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ights

The wormhole settlement weights, the transfer-proof extension's
per_transfer_weight, and reversible-transfers' execute_transfer charged
the leaf insert's DB ops but not its depth-dependent Poseidon hashing
(one hash per tree level), under-declaring execution work per recorded
transfer. Add a live-depth insert_leaf_hash_ref_time helper to
pallet-zk-tree (mirroring insert_leaf_db_ops) and charge it on all
three routes, pinned by depth-sensitivity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
A zero-value transfer_keep_alive (or zero-value scheduled transfer)
emitted a Transfer event that the proof recorder turned into a useless
ZK-tree leaf, advancing transfer counts and growing the tree with no
value movement. Guard the recorder chokepoint (report the credit as
deliberately dropped) and reject zero-amount schedules at the
reversible-transfers entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ign)

Genesis JSON is supplied by the node operator building their own chain
spec; it is not an untrusted boundary, so size limits there would
protect no one. Note this on prepare_genesis_build_input where the next
reviewer will look.

Co-authored-by: Cursor <cursoragent@cursor.com>
BuildGenesisConfig::build returns () — panicking assertions are the only
failure channel for invalid genesis data, inherited verbatim from
upstream Substrate, and they abort the operator's own chain-spec build.
Extend the trust-model note so the next reviewer finds this answered.

Co-authored-by: Cursor <cursoragent@cursor.com>
… wormhole

The proof-recorder extension only matched Balances::Transfer and
Balances::Minted, but reversible-transfers releases seized/recovered held
funds with transfer_on_hold, which emits Balances::TransferOnHold. The
guardian therefore received spendable free balance with no ZK-tree leaf.
Match TransferOnHold in the event scan; weight is already reconciled
post-dispatch for statically uncountable paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
…re) in wormhole

Same class as the TransferOnHold fix: pallet_recovery::close_recovery moves
the rescuer's reserved deposit to the rescued account with
repatriate_reserved, which emits Balances::ReserveRepatriated — an event the
proof-recorder extension ignored, so the credit got no ZK-tree leaf. Match
ReserveRepatriated in the event scan. Sweep of other balance-crediting APIs
found no further gaps (referenda slashes burn to (), fee flows are excluded
by design, TransferAndHold is unused).

Co-authored-by: Cursor <cursoragent@cursor.com>
The extension doc claimed scheduler-dispatched transfers were automatically
covered, but extensions never run for hook-context dispatch. Spell out the
actual contract: signed-extrinsic events are scanned; hook-context credits
record explicitly (reversible-transfers execution, mining rewards/treasury);
and governance enactment via the scheduler is a known, accepted gap because
only Root can reach it and Root can already forge leaves via set_storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
resolve_proof_block accepted any backend-resolvable hash, so side-fork
blocks (and fork blocks above best, where the one-sided window check
saturates to 0) produced proof material that settlement always rejects
against frame_system::BlockHash. Reject heights above best and require
the requested hash to match the canonical hash at its height. The test
mock now distinguishes imported blocks from the canonical index so
fork-block cases are expressible.

Co-authored-by: Cursor <cursoragent@cursor.com>
pre_validate_{private,public}_batch_proof copied and parsed unbounded
attacker bytes on the fee-free unsigned path, and plonky2's from_bytes
silently ignores trailing bytes, giving one proof unboundedly many byte
representations that each re-cost copy+parse at pool admission. Gate
length at MAX_PROOF_BYTES (512 KiB vs ~151/224 KB real proofs) before
any copy, and require the bytes to round-trip through to_bytes so each
proof has exactly one accepted encoding. Release-mode recalibration
shows the existing pre-validation weight constants still cover the
added serialize pass with large margin.

Co-authored-by: Cursor <cursoragent@cursor.com>
…h weights

is_call_allowed hides an is_high_security classification read; as_derivative
and as_recovered enforced the policy without charging the read, and
Multisig::propose paid it twice (weight selection + is_call_allowed) while
its weights document one read. Split the policy predicate into
is_call_allowed_given so propose reuses its fetched classification, and add
the read to the two wrapper weight declarations (and as_derivative's
actual-weight mirror). Recovery's mock gets a non-zero DbWeight so the new
weight assertions are meaningful.

Co-authored-by: Cursor <cursoragent@cursor.com>
record_proofs_from_events_since stream-decodes every event record present
at scan time (Iterator::skip discards but still decodes the pre-snapshot
prefix), yet weight() only charges per counted transfer and the post-hoc
registration only fired for recording shortfalls — batched
remark_with_event traffic produced unmetered linear decode work. Register
event_scan_weight (one Events read + a conservative 1µs/record decode
ceiling) alongside the existing shortfall in post_dispatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
…st budget)

The QPoW header commits a fixed 110-byte digest window that the pre-runtime
item and seal fill exactly, and import rejects anything larger. Upstream
frame-system's RuntimeEnvironmentUpdated deposit on set_code/set_heap_pages
therefore made every environment-changing block unimportable network-wide —
runtime upgrades could not be finalized through normal block production.
Nothing in the node stack consumes the item (clients detect upgrades from the
:code state key, not the digest), so remove both deposits in the fork,
document the no-runtime-digest-items invariant on deposit_log and
DIGEST_LOGS_SIZE, and flip the pallet tests to assert the digest stays empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — security fixes: wormhole, settlement, metering

Reviewed the full diff on the checked-out branch (illuzen/v12-zk-e2e), verified the load-bearing claims against the code, and ran the affected suites locally. Together with qp-zk-circuits#169 (reviewed there as well).

Verified independently

  • QPoW digest window: block import rejects oversized digests at client/consensus/qpow/src/lib.rs:238 and :407, and nothing in the node stack consumes RuntimeEnvironmentUpdated. The frame-executive deposit_log (frame/executive/src/lib.rs:1018) re-deposits the block's own pre-runtime digest, so removing the runtime deposits is safe — and the fix removes a genuine "runtime upgrades can't land" liveness bug.
  • Event semantics: TransferOnHold / ReserveRepatriated field names and credit direction match the vendored balances pallet (pallets/balances/src/lib.rs:418, :373) and the seizure (pallets/reversible-transfers/src/lib.rs:936) / close_recovery call sites. TransferAndHold is indeed never emitted outside balances' own tests.
  • Zero-amount rejection covers both scheduling entry points via the shared do_schedule_transfer_inner (pallets/reversible-transfers/src/lib.rs:762).
  • Policy-read weights: as_recovered (pallets/recovery/src/lib.rs:456) and as_derivative do call is_call_allowed → one classification read each, matching the added DbWeight::reads(1). Multisig::propose reuses the classification already fetched for the same account — sound.
  • Event-scan metering: events_at_scan is captured before recording deposits new events, and equals the number of records the scan actually decodes (skip decodes the discarded prefix). Zero-amount credits returning false keeps weight reconciliation consistent.
  • Proof framing: MAX_PROOF_BYTES (512 KiB) vs. real fixture sizes (~151/224 KB) leaves sane headroom, and the round-trip check pins exactly one accepted encoding per proof.

Test runs (this machine)

  • pallet-zk-tree, pallet-reversible-transfers, pallet-utility, pallet-recovery, pallet-multisig, frame-system: 281 passed, 0 failed.
  • pallet-wormhole --release --lib: 68 passed, 0 failed (3 ignored fixture-regen tests).
  • quantus-runtime --release --lib: 33 passed, 0 failed.

Non-blocking observations

  1. on_initialize(1) (pallets/wormhole/src/lib.rs): the declared weight counts account iteration + 2r/2w per recorded proof but not the per-leaf ZK-tree insert (DB ops + Poseidon hashing). Same under-counting shape as the old code, one-time hook at block 1, harmless in practice — noting for the next weight pass.
  2. Genesis derivation changes the recorded set from "explicit endowed list" to "every account with a balance at block 1". For the shipped presets the sets are identical (the old config was fed the same balances list); any pallet account pre-funded outside balances would now gain a leaf keyed to an address nobody holds a secret for — dead weight, not a risk, since ZK-spending requires the account secret.
  3. node/src/zktree_rpc.rs: error code 9006 now covers two distinct backend-failure modes (number resolution and canonical-hash resolution). Both are backend failures so it's arguably consistent, just slightly less precise for API consumers.
  4. The per-block quadratic scan cost (each post_dispatch re-decodes every event record present) is now metered per transaction, so block-weight accounting is sound — worth keeping an eye on as event volumes grow.

Verdict

APPROVE — root-cause fixes, each pinned by a regression test, claims hold up under independent checking. No blocking issues.

@illuzen
illuzen merged commit 855ea68 into main Aug 7, 2026
5 checks passed
@n13

n13 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Ok Codex 5.6 also approved this (post merge) - just ran a check

n13 added a commit that referenced this pull request Aug 7, 2026
Resolve genesis_config_presets conflict: keep vesting genesis schedules and
pot endowment; drop wormhole endowed_addresses (proofs now derive from
balances at block 1 per #648).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants