Repository navigation
Conversation
Address review on #711 — the CI `cargo clippy -- -D warnings` job failed with three errors the test/doctest jobs didn't surface: - `validate_exit_bundle_common` became dead code in the non-benchmark lib build once both production callers inlined the stateful checks. Restore its shared production use: the full `pre_validate_{private,public}_batch_proof` call it again, and the structural validators are now parse-only, with `validate_unsigned` adding `validate_exit_bundle_stateless` itself. Each path still runs the stateless checks exactly once (no double work), and admission still performs no per-nullifier reads. - `clippy::type_complexity` on both structural validators' return tuples: extract a shared `StructurallyValidatedProof` type alias, mirroring the existing `PreValidatedProof`. Admission and inclusion behavior are unchanged. cargo clippy --locked -p pallet-wormhole --lib -- -D warnings, scripts/fmt.sh --check, and the 85-test lib suite all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dispatch reliance Splitting the per-nullifier pass off pool admission also dropped the stateless half of `ensure_any_segment_valid`. Restore it: admission now rejects a bundle no segment of which could mint even with every nullifier fresh (all-dummy padding, or real segments whose slots all exit zero), without reading `UsedNullifiers`. Such junk carries fresh, distinct nullifiers, so it would otherwise earn a distinct pool tag per blob and reach the block author. Also state in the rustdoc of `verify_private_batch` / `verify_public_batch` that the body does not run the ZK verifier and relies on `ValidateUnsigned::pre_dispatch` having done so for a bare unsigned extrinsic. New test `validate_unsigned_rejects_bundle_with_no_mintable_slot` zeroes every exit-slot amount in the fixture's public inputs and asserts admission rejects it. Co-authored-by: Cursor <cursoragent@cursor.com>
…patch The settlement dispatchables do not run the ZK verifier; it runs in `ValidateUnsigned::pre_dispatch`, which the executive invokes only for a bare unsigned extrinsic. That makes "no mint without verify" a runtime property, so pin it at the runtime layer with the pallet's real private-batch fixture: - a genuine proof settles through `Executive::apply_extrinsic` (control); - the same proof with corrupted proof bytes and intact public inputs — the shape that passes every structural check — is rejected by the executive with `InvalidTransaction::Call`, mints nothing, and marks no nullifier; - dispatched directly with a `None` origin (bypassing `pre_dispatch`) the tampered proof mints, documenting why no in-runtime path may supply `None`; - `Root` and `Signed` origins are refused, `batch_all` cannot deliver `None` from either a `None` or `Root` outer origin, and the tech-referenda lane rejects `None` as a proposal origin; - an exhaustive match on `pallet_utility::Call` stops compiling if a `dispatch_as`-style combinator is reintroduced. Adds `hex` and `qp-wormhole-verifier` as runtime dev-dependencies to parse the fixture's public inputs. Co-authored-by: Cursor <cursoragent@cursor.com>
…marker `verify_private_batch` / `verify_public_batch` do not run the ZK verifier; only `ValidateUnsigned::pre_dispatch` does, and the executive invokes that solely for bare unsigned extrinsics. Until now "no mint without verify" therefore depended on no other `None`-origin dispatch path existing in the runtime. Make it a pallet property instead: - `pre_dispatch` writes `VerifiedProofHash = blake2_256(proof_bytes)` after a successful verify. - Both dispatchables consume the marker right after `ensure_none` and fail with the new `Error::ProofNotVerified` (weight: one read, `Pays::No`) if it is missing or bound to different bytes. - Weight accounts for the marker handoff (1 read, 2 writes). Tests: pallet tests for missing marker, mismatched bytes, full pre_dispatch -> dispatch flow consuming the marker, and that an unverifiable proof cannot be settled by any route; runtime test asserting a direct `None`-origin dispatch of the real fixture is refused. Docs updated. Co-authored-by: Cursor <cursoragent@cursor.com>
… marker Public items (ProofNotVerified, verify_*_batch) linked to the pub(super) storage item, failing rustdoc::private_intra_doc_links under -D warnings. Co-authored-by: Cursor <cursoragent@cursor.com>
A node started with --public-batch-aggregator intercepts private-batch wormhole proofs at transaction-pool submission, re-runs the block author's admission checks (runtime validate_transaction, UsedNullifiers, full ZK verify), pools the survivors by (block_hash, asset_id, volume_fee_bps) and proves them into a public batch bound to the configured aggregator address. Claimed proofs never enter the transaction pool, so they are neither propagated nor authored by this node. - forked sc-transaction-pool: SubmissionInterceptor hook on the wrapper/builder - pallet-wormhole: embed private-batch common/verifier bytes and a dummy private-batch proof for std builds; UsedNullifiers made pub - bump qp-wormhole-* / qp-zk-circuits-common to 4.4.0 for the reusable PublicBatchProver (no circuit changes) - eviction on new best block from ProofVerified events, expiry, and non-canonical anchors; pre-submit live-segment check - Prometheus metrics under quantus_public_batch_* Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
Reviewer: GPT-6.1 Sol
Reviewed head d0aa8fb0b1bfdf9c98c189eb855516850ccc57f5, including the inherited #711 changes. The current base is 9e6d01f8bbefd126d87be6325e8cfc1f09825ae9; the diff merge base is 482c5b9e02bec0adc70797eade0a67f60baf2619.
-
[P2] Keep repeat submissions of claimed proofs intercepted. node/src/public_batch_aggregator/interceptor.rs:117–138. After a proof is claimed, submitting the identical extrinsic again before settlement passes the runtime and on-chain-nullifier checks, but
ProofPool::pushrejects its already-staged nullifiers. This branch converts that rejection toInterception::Pass, and both wrapper submission methods then admit the private batch to the ordinary pool. The first submission never entered that pool, so its duplicate checks do not prevent admission. A normal RPC retry or subsequent gossip therefore makes this node propagate and potentially author a private batch it already claimed, defeating aggregation and wasting the public-batch proving work/rebate. The gossip handler's known-hash cache does not suppress incoming resubmissions; only concurrently pending imports are coalesced. Track the exact extrinsic hashes currently in custody and keep returningClaimedfor repeats, synchronizing that tracking with eviction. Cover sequential retries through both wrapper submission paths. With the real valid private-batch fixture, I reproduced first-push success followed by the duplicate-nullifier error on the second push; source tracing confirms its fall-through to the ordinary pool. -
[P2] Evict proofs when their anchor leaves the runtime acceptance window. node/src/public_batch_aggregator/worker.rs:117–124 and 175–180. Expiry measures time since pool admission, while the other eviction check only asks whether the anchor is a canonical node-database header. Such a header can remain canonical after
frame_system::BlockHashhas pruned it, at which point the runtime rejects every proof referencing it. For example, a still-valid, unspent proof anchored at #100 can be admitted near best #4196 and become unusable a few blocks later, yet stay pooled for the entire default 49,152 seconds after admission. It remains eligible for repeated expensive proving, followed by rejected submissions; enough such proofs occupy capacity and consume the single prover while useful work waits. An isolated probe using the real proof pool and the unchanged canonicality/eligibility helpers, with a mocked canonical-header lookup, confirmed that an anchor at #100 is retained and eligible at best #4200. Check the anchor against the current runtime acceptance window during eviction and before proving, and remove buckets that can no longer settle; admission age alone cannot enforce this bound.
Validation at the reviewed head, with production circuit dimensions (7 leaves, 53 private batches):
SKIP_WASM_BUILD=1 cargo test --locked --offline -p quantus-node -p sc-transaction-pool -p pallet-wormhole: 259 passed, 0 failed, 3 ignored.SKIP_WASM_BUILD=1 cargo test --locked --offline -p quantus-runtime --test mod --features fast-governance: 68 passed, 0 failed, 1 ignored, including the executive settlement/verification tests.SKIP_WASM_BUILD=1 cargo clippy --locked --offline -p quantus-node -p sc-transaction-pool -p pallet-wormhole -- -D warnings: passed.- Pinned Rust formatting for the four affected crates, Taplo on the three changed manifests, and
git diff --check: passed. - An additional
--all-targetsClippy run failed on existing transaction-pool test lint violations in unchanged files; this is separate from the passing production check above. - Both targeted proof-pool/worker probes reproduced the findings above. No live-network aggregation run or fresh local Wasm build was performed.
GitHub CI's formatting and Clippy/documentation jobs passed at this head; the Linux and macOS build/test jobs were still running when checked.
Nearly all private batches arrive via author_submitAndWatchExtrinsic (subxt, quantus-cli), which bypassed the interceptor, so the aggregator saw nothing in practice. Watched submissions are now claimable; the status stream follows the proof's nullifiers (Ready, InBlock/Finalized via the settling extrinsic, Dropped on unsettled eviction). Also enable qp-plonky2's rand feature, without which building the public-batch prover panics at startup, and cover the v4 bare extrinsic encoding that clients actually send. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Pushed e3692c2 after an end-to-end run against a
Result after the fix: 12 two-leaf private batches fired → 12 claimed → one public batch (84 nullifiers) proved in 78s → all 12 settled in one block, none leaked. |
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
Reviewer: GPT-6.1 Sol
Reviewed head e3692c2ff3c2eb376f5bfeeee8e8907c66468b36, including the inherited #711 changes. Base: 9e6d01f8bbefd126d87be6325e8cfc1f09825ae9; diff merge base: 482c5b9e02bec0adc70797eade0a67f60baf2619. The watched-submission change fixes interception, but introduces client compatibility and reorg notification failures. Both previously reported findings also remain.
-
[P1] Preserve the extrinsic identity promised by the standard watch RPC. watchers.rs:80–87, reached through interceptor.rs:168–171. A claimed private extrinsic never appears in the reported block: only the different public-batch extrinsic does. The legacy
author_submitAndWatchExtrinsicwire format serializesInBlock((hash, index))as just{"inBlock": hash}, so the replacement index is lost and clients receive no replacement transaction hash. Subxt retains the original hash and searches the reported block for it when fetching success/events. I executed the real Subxt 0.44.3wait_for_finalized_success()against a mocked block containing only the replacement; it returnsTransaction(BlockNotFound). quantus-cli'swait_tx_inclusion/check_execution_successlikewise searches for the original hash and returns “Extrinsic hash not found in reported block.” Thus aggregation can settle funds while ordinary clients report failure. Keep standard watched submissions on a path that preserves their identity, or introduce an explicit aggregation-aware RPC/client protocol carrying settlement identity and update consumers before intercepting their standard watches. Cover the actual client success/event-fetch path, not only receipt of status messages. -
[P2] Keep retries of proofs already in custody intercepted. interceptor.rs:122–143. A second identical submission before settlement passes runtime and on-chain-nullifier checks, but
ProofPool::pushrejects its already-staged nullifiers. This becomesNone/Pass, admitting the private batch to the ordinary pool throughsubmit_one,submit_at, or nowsubmit_and_watch. The first submission never entered that pool, so its duplicate checks cannot stop the leak. A normal RPC retry or subsequent gossip makes this node propagate and potentially author a proof it already claimed. Reproduced with the valid production fixture: first push succeeds; the second reports “a nullifier in this proof is already staged in the pool.” Track exact extrinsic identities in custody and keep claiming identical retries, attaching repeat watchers to the existing claim; synchronize identity tracking with eviction and test all three submission paths. -
[P2] Evict anchors that leave the runtime acceptance window. worker.rs:136–143 and 210–216. Expiry measures time since admission, while canonicality checks the node's persistent header database. A proof admitted near the end of its anchor's 4,096-block window soon becomes invalid when runtime
BlockHashstorage prunes that anchor, although the header remains canonical. It can then occupy capacity and repeatedly consume the single expensive prover for up to the default 49,152 seconds after admission. A probe using the real proof pool and unchanged worker helpers retained an anchor at #1 and considered it eligible at best #4101 with a mocked canonical header. Check current runtime anchor acceptance during eviction and before proving; drop buckets that can no longer settle. -
[P2] Process watch retractions before replacement settlements. watchers.rs:93–103. Retractions are detected only once finality reaches the old settlement height. Until then, the watcher remains in
in_block, whileon_settledexamines onlypending. Sequence: settle in A#200; reorg; settle again in B#201 before finality reaches #200; finalize the replacement chain. The B settlement is ignored, then finality moves the watcher back to pending for the already-completed spend. Running this sequence against the unchanged watcher module producesRetracted(A), noFinalized(B), and eventuallyDroppedafter expiry despite successful settlement. Handle retractions and enacted settlements when the best chain changes, or reconcile against retained settlement records/current chain state. Include replacement settlement before old-height finality in the lifecycle test.
Validation at this head, with production dimensions (7 leaves, 53 private batches):
SKIP_WASM_BUILD=1 cargo test --locked --offline -p quantus-node -p sc-transaction-pool -p pallet-wormhole: 264 passed, 0 failed, 4 ignored.SKIP_WASM_BUILD=1 cargo test --locked --offline -p quantus-runtime --test mod --features fast-governance: 68 passed, 0 failed, 1 ignored.- Additionally ran the ignored
build_prover_succeedsnode test: passed, 287 seconds. - Strict production Clippy for the three affected crates, pinned formatting for all four affected crates, Taplo on the changed manifests, and
git diff --check: passed. - Four isolated probes executed the real proof pool, unchanged watcher source, unchanged worker helpers with mocked header lookup, and legacy status serialization; all confirmed the behaviors above. The separate real Subxt probe confirmed the client error.
- GitHub formatting, dependency cooldown, and Clippy/documentation checks passed; Linux/macOS build/test jobs were still running when checked. No live-network aggregation or fresh local Wasm build was performed.
The node's own dependency graph only enabled rand and std; parallel was present just in the build-script graph, so the public-batch prover ran single-threaded. Proving a 12-segment batch in the node drops from ~78s to ~16s on a 10-core M4; the prover build from ~31s to ~12s. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Pushed one more: |
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
Reviewer: GPT-6.1 Sol
Reviewed head 0f4ba17b4a9f6970c90f2fbbe858861c69aede29, including the inherited #711 changes. Base: 9e6d01f8bbefd126d87be6325e8cfc1f09825ae9; diff merge base: 482c5b9e02bec0adc70797eade0a67f60baf2619. The latest commit correctly enables parallel proving in the node dependency graph, but all four previously reported correctness issues remain at this head.
-
[P1] Preserve transaction identity for standard watched submissions. watchers.rs:80–87, reached through interceptor.rs:168–171. The claimed private extrinsic is absent from the reported block; only the different public-batch extrinsic appears. The actual legacy status serializer emits only
{"inBlock": block_hash}, losing the replacement index and conveying no replacement transaction hash. Subxt 0.44.3'sfetch_events()retains the original hash and searches the block for it, returningTransaction(BlockNotFound); quantus-cli'scheck_execution_successsimilarly returns “Extrinsic hash not found in reported block.” Thus a successful aggregated settlement is reported as failure by ordinary clients. Keep standard watched submissions on an identity-preserving path, or introduce an explicit aggregation-aware RPC/client protocol carrying settlement identity before intercepting those watches. Test the client's success/event-fetch path. -
[P2] Continue claiming identical retries while their proof is in custody. interceptor.rs:122–143. A repeat submission before settlement passes runtime and on-chain-nullifier checks, but
ProofPool::pushrejects its already-staged nullifiers. That rejection becomesNone/Pass, sosubmit_one,submit_at, andsubmit_and_watchsend the private extrinsic to the ordinary pool. Its first submission never entered that pool, so ordinary duplicate detection cannot prevent this leak. RPC retries can therefore make this node propagate and author a proof it already claimed, defeating aggregation and wasting proving work. With the real valid fixture, the first push succeeded and the second produced the duplicate-nullifier error. Track exact extrinsic identities in custody, keep claiming identical retries, attach repeat watchers to the existing claim, and synchronize this tracking with eviction. Cover sequential retries through all three wrapper paths. -
[P2] Evict anchors when runtime block-hash storage prunes them. worker.rs:136–143 and 210–216. Expiry measures age since admission, while canonicality checks the node's persistent header database. A proof admitted near the end of its anchor's 4,096-block acceptance window soon becomes invalid when runtime
BlockHashstorage prunes the anchor, although its header remains canonical. It then occupies capacity and remains eligible for repeated expensive proving for up to the default 49,152 seconds after admission. A fresh probe admitted the valid fixture anchored at #1, finalized runtime state at #4098 to prune that anchor, and confirmed runtime admission then rejects it. The real proof pool and unchanged worker helpers, with a mocked canonical-header lookup, still retained it and allowed proving. Check current runtime anchor acceptance during eviction and before proving, and remove buckets that cannot settle. -
[P2] Handle retractions before replacement settlements can be missed. watchers.rs:93–103. A watcher remains in
in_blockuntil finality reaches its old settlement height, whileon_settledexamines onlypending. Sequence: settle in A#200; reorg; settle in B#201 before finality reaches #200; finalize B's chain. The replacement settlement is ignored, then finality returns the watcher to pending after the spend has already completed. Executing this sequence against the unchanged watcher source produced onlyRetracted(A), noFinalized(B), and subsequentlyDropped. Process retractions and enacted settlements on best-chain changes, or reconcile against retained settlement records. Add this replacement-before-old-height-finality lifecycle test.
Validation at this head, using production dimensions (7 leaves, 53 private batches):
SKIP_WASM_BUILD=1 cargo test --locked --offline -j 4 -p quantus-node -p sc-transaction-pool -p pallet-wormhole: 264 passed, 0 failed, 4 ignored.SKIP_WASM_BUILD=1 cargo test --locked --offline -j 4 -p quantus-runtime --test mod --features fast-governance: 68 passed, 0 failed, 1 ignored, including executive verification/settlement tests.- Standalone probes used the real proof pool, runtime block-hash pruning/admission, unchanged watcher source, unchanged worker helpers with mocked header lookup, and actual legacy status serialization; they reproduced the behaviors above. Client error paths were verified by source inspection; no new Subxt mock or live-network run was performed.
- Pinned formatting for all four affected crates, Taplo for the three changed manifests, and
git diff --check: passed. Cargo's node feature graph includes bothparallelandrandforqp-plonky2. - The ignored real-prover constructor test,
public_batch_aggregator::worker::tests::build_prover_succeeds, also passed (191.07 seconds, debug build).
GitHub's formatting, dependency cooldown, and Clippy/documentation checks passed at this head; the Linux and macOS build/test jobs were still running when checked. No fresh local Wasm build was performed.
n13
left a comment
There was a problem hiding this comment.
Request changes for two issues in the watched-submission path. The on-chain nullifier checks prevent the overlapping private/public batch scenarios from minting twice, but the new status stream does not reliably confirm the submitted transaction's intended settlement.
-
[P1] Match the actual settled segment before reporting inclusion or finalization.
Watcher::settled_byusesfind_map, so any one matching nullifier moves a watcher toInBlock;on_finalizedsubsequently reportsFinalizedfor that block. For example, a watched proof P spending{a, b}is reported included when a conflicting proof Q spending{a, c}settles, even though P's intended payouts did not execute. A targeted test requiring partial overlap not to report inclusion fails on this revision.Track and verify the intended private segment's public statement, including its exit recipients and amounts, against the segment that actually settled. Report a conflicting spend separately from successful settlement. Checking all nullifiers instead of any one is not sufficient: an owner can construct conflicting proofs spending the same inputs to different recipients. Add regression coverage for both partial overlap and identical inputs with different payouts.
-
[P1] Preserve transaction-watch semantics or provide an aggregation-aware confirmation API.
intercept_watchedclaims the submitted private extrinsic and replaces its normal status stream with notifications for the settling public extrinsic. The original private extrinsic never enters the block. Existing clients still retain its original hash: Subxt'sTxInBlock::fetch_eventssearches the reported block for that hash, and quantus-cli'sfind_extrinsic_event/collect_wormhole_events_for_extrinsicdo the same. These confirmation paths therefore fail even when the replacement public batch settled successfully; returning the replacement's extrinsic index in the node-side status does not change the hash those clients search for.Provide explicit aggregation-aware tracking that identifies the replacement transaction and verifies the intended segment, with corresponding client support, or leave watched submissions on the normal transaction-pool path until that support exists. Add an end-to-end test using the existing Subxt/CLI submission and confirmation flow.
What
An opt-in mode for
quantus-nodethat turns it into a wormhole public-batch aggregator:Private-batch proofs that reach this node (gossip or RPC) are intercepted at transaction-pool submission, pooled, proved into a public batch bound to the configured aggregator address, and the public batch is submitted instead. Claimed private batches never enter the transaction pool, so this node neither propagates nor authors them.
Admission
#711 moved the ZK verify and
UsedNullifiersreads out ofvalidate_unsigned, so pool admission alone no longer says anything about whether a private batch would make it into a block. The interceptor re-runs the author's checks before taking custody of a proof:TaggedTransactionQueue::validate_transaction(structural + stateless binding checks),UsedNullifiersstorage read for each nullifier at the current best block,ProofPool::push(full plonky2 verify, budget, pool-wide nullifier dedupe).Anything that fails falls through to the normal pool untouched.
TransactionSource::InBlockandsubmit_and_watchare never intercepted.Custody and eviction
Buckets are keyed by
(block_hash, asset_id, volume_fee_bps). On each new best block the worker evicts proofs whose nullifiers appear inProofVerifiedevents, drops proofs older than--public-batch-proof-expiry(default: theBlockHashCountacceptance window), and discards buckets anchored to a block that is no longer canonical. Before submitting a proved batch it re-checks each segment's nullifiers and skips the submission if nothing is left to settle.Policy
A bucket is proved when it is full, or when it holds
>= --public-batch-min-proofsand its oldest proof is older than--public-batch-max-wait. One proof at a time; a bucket snapshotted less than--public-batch-reprove-afterago is left alone.Changes
client/transaction-pool:SubmissionInterceptorhook onTransactionPoolWrapper+Builder::with_submission_interceptor.pallets/wormhole:build.rsemits a dummy private-batch proof for std builds;embedded_artifactsexposes the private-batch common/verifier/dummy bytes;UsedNullifiersispub.qp-wormhole-*/qp-zk-circuits-commonbumped4.3.0 → 4.4.0for the reusablePublicBatchProver::new_from_bytes/prove_batch(&self). No circuit changes; embedded verifier bytes are unchanged.node/src/public_batch_aggregator/: interceptor, worker (prover warm-up, eviction, policy, prove + submit), metrics (quantus_public_batch_*).docs/wormhole-zk.mdandMINING.md.Caveats
block_hash, so clients proving against different anchors won't batch together. A client-side anchor convention is a follow-up.Testing
./clippy.shclean (taplonot installed locally).cargo test -p quantus-node -p sc-transaction-pool -p pallet-wormholepass; new unit tests cover CLI config parsing/validation, extrinsic recognition, and the proving policy predicate.cargo check -p quantus-runtime(wasm build, palletno_std) passes.submit_athas no unit test (the fork's test harness isn't wired for a custom client).