V12 dilithium - #636
Conversation
The events metrics collector added an entry per submitted transaction but only removed it on a final status event. Transactions rejected during view submission never produce any status, so each unique invalid submission leaked a map entry, allowing unbounded memory growth outside pool limits. Report such rejections to the collector so their entries are dropped. Co-authored-by: Cursor <cursoragent@cursor.com>
The single-state pool validated every transaction in a caller-supplied batch before any ready/future limit was applied, so oversized batches consumed validation work and memory before pool limits could engage. Reject transactions exceeding the pool's total count/byte capacity up front with ImmediatelyDropped, before any validation is scheduled. Also extracts the test-only mock ChainApi into a shared module. Co-authored-by: Cursor <cursoragent@cursor.com>
… account from_phrase derived the keypair through the default HD path but returned the first 32 bytes of the raw mnemonic seed, which reconstructs a different account via from_seed. Users backing up the displayed secret seed could permanently lose access to their funds. Return the 32-byte entropy derived at the default HD path instead, so from_seed(seed) reconstructs the same pair, and expose it from from_string_with_seed too. Co-authored-by: Cursor <cursoragent@cursor.com>
Wrap the 64-byte mnemonic seed in SensitiveBytes64 so both the stack original and the working copy are wiped after HD derivation, instead of leaving the secret material in memory. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…d reorgs Co-authored-by: Cursor <cursoragent@cursor.com>
…usurped txs Co-authored-by: Cursor <cursoragent@cursor.com>
…channel backlog Co-authored-by: Cursor <cursoragent@cursor.com>
… eviction ordering Co-authored-by: Cursor <cursoragent@cursor.com>
…rcement Age-only future-queue eviction let low-priority flooders force out older high-priority futures that ValidatedPool then banned; match the ready-queue priority-first policy instead. Co-authored-by: Cursor <cursoragent@cursor.com>
is_banned previously treated any map entry as banned until clear_timeouts ran; fork-aware view clones then copied stale entries into new views. Co-authored-by: Cursor <cursoragent@cursor.com>
…chedule time cancel_transfer was reading live HighSecurityAccounts, so a later set_high_security could seize pre-existing one-time held funds. Co-authored-by: Cursor <cursoragent@cursor.com>
Align the pre-decode cap with the 5 MiB RuntimeBlockLength so RPC workers do not materialize attacker-controlled extrinsic payloads that can never be included on-chain. Co-authored-by: Cursor <cursoragent@cursor.com>
Pair::sign remains infallible by trait and still panics on signer failure; expose try_sign and reject oversized CLI messages so untrusted input yields a recoverable error instead. Co-authored-by: Cursor <cursoragent@cursor.com>
…ures create_watcher ran before import; AlreadyImported/TooLowPriority dropped the Watcher while leaving its sender in EventDispatcher until a later fire that never arrived. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep maintain's finish_revalidation path off the uncancellable mempool batch queue so a flood of expensive validations cannot stall chain-event maintenance. Co-authored-by: Cursor <cursoragent@cursor.com>
Advance one block per scheduled transfer in the recover_funds benchmark so cancel_named touches n Scheduler::Agenda keys, and charge Agenda DB/proof work as O(n) instead of a fixed two-bucket cluster. Co-authored-by: Cursor <cursoragent@cursor.com>
Replace unbounded view/MVL status channels with capacity-limited futures::mpsc queues so slow submit_and_watch consumers cannot grow heap without bound; drop intermediate notifications on full and close only when a final status cannot be delivered. Co-authored-by: Cursor <cursoragent@cursor.com>
When remove_subtree drops a ready transaction, restore its provides as missing tags on futures that still require them so a later provider of the remaining tags cannot promote an incompletely dependent transaction. Co-authored-by: Cursor <cursoragent@cursor.com>
Filter replacement unlocks to hashes that survived tag-filtered removal so BestIterator cannot treat removed descendants as satisfied dependencies when they are later resubmitted under a real provider. Co-authored-by: Cursor <cursoragent@cursor.com>
Prevent unbounded backlog during finalization bursts by using a bounded view channel and coalescing mempool revalidation to the newest tip. Co-authored-by: Cursor <cursoragent@cursor.com>
Use Configuration::transaction_pool so --pool-type can switch SingleState vs ForkAware, and keep Quantus stress-test pool size defaults on the CLI. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the extend_unwatched one-result-per-input contract on bridge recv failure so submit_local cannot panic on Vec::remove(0). Co-authored-by: Cursor <cursoragent@cursor.com>
Notify the event handler with the same timed-out block as the per-tx watcher when finality watchers are evicted past the cap. Co-authored-by: Cursor <cursoragent@cursor.com>
Enforce ValidateTransactionPriority by polling the maintained channel first so maintenance work is not starved by Submitted floods. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 43d2761. Configure here.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
AI Review
Verdict: Approve — with one should-fix and a benchmark re-run before release
Reviewed all 36 files against the base sources, not just the hunks. Every remediation claimed in the description checks out as a real pre-existing bug with a correct fix and a test pinning the behavior: the base_pool false-promotion hole (future tx with a satisfied-then-removed dep), the stale unlock edges in ready.rs, the submit_local panic on .remove(0) when the mempool sync bridge dies, the dropped-watcher view-map leak, the event-metrics leak for rejected txs, the watcher-fire finality-timeout hash inconsistency, and the permanently-killed import-notification subscriber on transient overflow were all confirmed present on main. Locking discipline in the new async paths is sound (guards dropped before every await, leaf mutexes with try_send under them), the --pool-type wiring is complete end-to-end with CLI defaults byte-identical to the previously hardcoded Quantus sizing, the reversible-transfers guardian freeze is invariant-safe with no storage migration needed, and the commit factoring made this reviewable. CI is green on both matrices.
Should fix (before A/B-ing ForkAware on RPC nodes)
AddViewStream rides the drop-on-full controller lane — multi_view_listener.rs, send_controller_command. If ≥1024 non-final commands (realistically a Broadcast flood from gossip) queue between listener-task polls, a new view's AddViewStream is silently discarded: that view's stream never enters aggregated_streams_map, and every watcher misses all Ready/InBlock/Retracted events from that view for its lifetime (terminal events still arrive on the finals lane; it self-heals at the next view). AddViewStream/RemoveViewStream are intrinsically bounded (~1–2 per block) — route them over the loss-less finals lane instead. Low probability, trivial fix, and it's the one hole left in exactly the flood scenario this PR hardens against. Only reachable with --pool-type fork-aware, hence not merge-blocking for the SingleState default.
Weights: re-run the recover_funds benchmark
- The per-
nproof size charges only the Agenda MEL (12,493) but the function's own storage comments declarePendingTransfers(2,640) +Lookup(2,528) +Agenda(12,493) reads andRetries(2,515) writes pern— FRAME-generated weights sum these (~20,176/n), so a full 16-transfer recovery under-declares proof size by ~120 KB. The new test pins the wrong constant (step.proof_size() == 12493). - The per-
nref-time slope (56,328,935) is carried over from the pre-fix benchmark where up to 8 transfers shared one agenda bucket; with one bucket per transfer each cancel does strictly more agenda decode/write work. The code comment acknowledges this — please add the re-run to the test plan rather than shipping the stale slope.
Worth addressing (minors)
rotator.rsis_bannedTOCTOU: after the read lock sees an expired entry, the unconditionalwrite().remove(hash)can delete a fresh re-ban that landed in between (e.g. fromremove_invalidafter failed revalidation). Consequence is wasted revalidation, not inclusion of invalid txs — re-check expiry under the write lock.submit_atcap uses static total capacity, not remaining, and the rejected tail is dropped even when earlier batch entries fail validation and would have left room — a near-capacity batch with an invalid prefix now loses valid tail txs the old path would have imported. Fine as a DoS tradeoff, but it's a semantics change worth a sentence in the PR body.- Terminal event on a full per-tx channel:
try_send_external_watcher_commandremoves the watcher controller, so the external stream ends after the consumer drains its buffer without ever deliveringFinalized/Invalid. Documented in-code, but it's API-visible to RPC clients — call it out in the description. revalidation_worker.rsview-queue-full fallback runs revalidation inline on the maintain path — the exact long-blocking behavior this PR removes elsewhere. Awaiting queue capacity (backpressure) would preserve maintain latency; pathological-only as-is.- Dropped-status backpressure vs
dropped_watcherfallback: if aReadyregistration for view B is dropped under backlog, a laterDroppedfrom view A finds no tracked views and evicts a tx that B still holds ready. Accepted degradation, but the two sites should reference each other in comments. - Dilithium zeroization is only as complete as the upstream crate:
try_sign/from_seed/from_rawmaterialize an externalml_dsa_87::Keypaircopy of the secret (create_keypairper sign call) that drops outside the newZeroizeOnDropcoverage. If qp-rusty-crystals-dilithium 3.0.1 doesn't zeroize internally, every signature still leaves secret bytes in freed memory — worth verifying upstream or noting the residual gap. from_phrasenow returns the HD-derived secret as the displayed seed. Derivation itself is unchanged (pinned bytest_from_phrase_matches_hd_derivation), but previously displayed "secret seed" values for mnemonic accounts never reconstructed the real account and still won't — this deserves a release note telling users to re-derive backups from the mnemonic.recover_fundsvs frozen guardian asymmetry: cancel authority is now frozen inpending.guardian(new test enforcesNotOwnerfor the live guardian on pre-enrollment one-time transfers), butrecover_fundsstill seizes those same held funds for the live guardian, fee applied. Presumably intended — recovery is seize-the-account by design — but worth an explicit comment so the cancel test isn't read as a stronger guarantee than it is.
Nits
extend_unwatched_syncreports a dead sync bridge asError::InvalidBlockId;Unactionablefits better.MAX_ENCODED_EXTRINSIC_LENintransaction-payment-rpchardcodes the 5 MiB operational-class block length; a governance change wouldn't propagate. Fine as a loose DoS bound.- The hex path in
message_params.rscaps the hex text, so hex-encoded messages max out at ~32 MiB vs 64 MiB raw — asymmetry worth a doc line. sign<P>discardsPand hardcodesDilithiumPair. Safe today (with_crypto_scheme!only wires Dilithium), but if a second scheme is ever added,signsilently keeps signing with Dilithium — a static assertion tying the macro to the hardcode would future-proof it.view.rsdroppable_events_in_flightcheck-then-act (load+fetch_add) can overshoot the cap by the number of concurrent senders;fetch_updatewould be exact.submit_atcomputeshash_and_lengthper accepted tx just for the length, and the hash is recomputed during validation — duplicated blake2 work, negligible at Dilithium tx sizes.- Utility subcommands (
import-blocksetc.) now get default pool options instead of the previously hardcoded Quantus sizing since theirCliConfigurationimpls don't overridetransaction_pool()— harmless (they don't exercise the pool), just noting the behavior change.
Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
GPT-5.6 review
Verdict: Request changes.
I reviewed current head f509d286 against main. The PR contains several valuable correctness fixes and all five CI checks are green, but the current head still has merge-blocking liveness/resource-accounting problems:
- The biased multi-view listener loop can starve the terminal-status lane under a continuously ready view stream, allowing the supposedly lossless unbounded lane to grow and leaving watchers without finals.
- View dependency restoration is quadratic in removed ready tags times future entries on a pool write path; the configured limits make a nine-figure scan feasible during a large subtree removal.
AddViewStream/RemoveViewStreamare structural lifecycle commands but still use the drop-on-full lane, so one overflow can detach a view for its lifetime or retain stale state.recover_fundschanges the benchmark's worst-case scheduler access pattern but deliberately retains the old measured CPU slope and manually edits generated weights. Runtime weights need to be regenerated from the corrected benchmark.
Please also resolve the existing CLI genericity regression (sign<P> ignores P and hardcodes ML-DSA-87) and re-check expiry under the rotator write lock so an expired-ban cleanup cannot delete a concurrent fresh re-ban. The current per-watcher full-buffer behavior also closes the stream without delivering its terminal status; if that API degradation is intentional, it needs explicit release/API documentation and a test for a final arriving while the per-watcher queue is still full.
The current tests cover a full controller before the listener starts, but not a permanently-ready aggregated stream competing with finals, which is the starvation case above.
— Reviewed by GPT-5.6
This reverts commit 490c0e2.
…enda re-run Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
GPT-5.6 re-review
Verdict: Request changes — one new blocking correctness case remains.
I re-reviewed head 428d1b1e against the previously reviewed f509d286. The reported starvation, lifecycle-delivery, complexity, generated-weight, CLI-genericity, and ban-expiry race issues are materially fixed:
- the lossless lane is polled before hot view streams and now carries
AddViewStream/RemoveViewStream; - lost tags are hashed and each future transaction is scanned once;
recover_fundsuses newly generated ref-time, DB, and proof terms from the distinct-Agenda benchmark;- the hard-coded Dilithium CLI signing change was reverted;
is_bannedre-checks expiry under the write lock.
Focused validation on this head: cargo test -p sc-transaction-pool --lib passed 91/91, and cargo test -p pallet-reversible-transfers passed 54/54. Format, config, and Clippy/doc checks are green; the two full build/test matrices are still pending at review time.
The remaining blocker is in the replacement/promotion interaction described inline: a future transaction can leave self.future before a replacement removes one of its previously satisfied requirements, so the repair scan misses it and it is admitted to Ready incompletely. A focused regression test reproduces the failure on this head.
Non-blocking housekeeping: the PR description still says this change adds try_sign and CLI message-size rejection, but those changes were reverted.
— Reviewed by GPT-5.6
satisfy_tags can pull a future into to_import before ready.import replaces its other providers; repair those candidates too so they are not admitted incomplete. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
GPT-5.6 final re-review
Verdict: Approve.
Reviewed current head 021529ef against the previously reviewed 428d1b1e and against the current main merge result.
The last blocker is fixed correctly: queued promotions are now reconciled against tags lost during replacement, incomplete candidates are returned to Future, and the focused regression test covers the exact R {conflict, lost} / F requires {lost, unlock} / A {conflict, unlock} construction that failed on the prior head. The earlier starvation, lifecycle-delivery, complexity, generated-weight, CLI-genericity, and ban-expiry findings remain resolved.
Validation:
- All five GitHub checks are green, including both full build/test matrices.
- PR head:
cargo test -p sc-transaction-pool --libpassed 92/92. - Clean local merge onto current
main(e362a877): transaction-pool 92/92, reversible-transfers 46/46, transaction-payment RPC 3/3, and Dilithium crypto 12/12 passed. - The merged
quantus-nodenative compile check passed.
No blocking findings remain.
Non-blocking housekeeping: the PR description still says the change adds try_sign and CLI message-size rejection, but commit a875c098 reverted that work.
— Reviewed by GPT-5.6
The merge of main resolved the scheme macro in favor of the branch, silently dropping three changes from the V12 dilithium PR (#636) whose tests were kept: Zeroize/ZeroizeOnDrop on the pair, a from_phrase that returns a seed which reconstructs the same pair, and from_string_with_seed exposing that seed. Port all three into define_dilithium_scheme! (adapted to the hdwallet 4.0.0 API) and rename DilithiumPair to Dilithium87Pair in the merged-in tests. Co-authored-by: Cursor <cursoragent@cursor.com>

Summary
Remediates a series of correctness, resource-bounding, and DoS-isolation findings in the vendored
sc-transaction-pool, and wires--pool-typethrough the Quantus node so SingleState vs ForkAware can be A/B tested without a rebuild.Default pool type remains SingleState (per #479: ForkAware mempool retention until finality saturated the pool under stress on our ~99-block PoW finality lag). Quantus pool sizing from #141 is preserved as CLI defaults (
--pool-limit 36772,--pool-kbytes 262144).Transaction-pool remediations
Resource bounds / DoS
submit_atbatches at pool capacity before validationRevalidateMempoolbacklog during finalization bursts)Maintainedvalidation lane withbiasedtokio::select!so Submitted floods cannot starve maintain/block-production validationCorrectness
finish_revalidationcannot stall behind uncancellable mempool batchesis_banned; drop expired bans on rotator clonesubmit_and_watchwatchers after eventless import failuresErrper input fromextend_unwatched_syncwhen the mempool sync bridge is dead (avoidssubmit_localpanic on.remove(0))Ops / A/B
Configuration::transaction_poolinstead of hardcoding SingleState--pool-type fork-awareto exercise ForkAwareSingleState vs ForkAware (context)
ImmediatelyDropped(RPC 1016)active_views)Recommendation: keep SingleState for miners/validators; A/B ForkAware on RPC nodes (and optionally Planck stress) with larger sizing if needed, e.g.
--pool-type fork-aware --pool-limit 52000 --pool-kbytes 409600.Test plan
cargo test -p sc-transaction-pool --libCreating transaction pool txpool_type=SingleState--pool-type fork-awareselects ForkAware and exportssubstrate_sub_txpool_active_viewssubstrate_ready_transactions_number/substrate_sub_txpool_unwatched_txs, maintain duration p95, CPU during fork burstsNote
High Risk
Touches core mempool maintain/validation paths, PQ signing and key handling, and on-chain cancel/weight semantics—bugs could affect liveness, memory, or fee/cancel correctness.
Overview
Hardens the vendored transaction pool against DoS and correctness bugs: caps batch admission and maintenance tree routes, bounds status/import/revalidation channels (separate view vs mempool workers, latest-wins mempool jobs), prefers the Maintained validation lane, and fixes leaks in watchers, dropped-view maps, and event metrics. Graph-layer fixes cover future dependency tracking after ready removals, priority-based future eviction, ban expiry on clone/check, and related
submit_and_watchcleanup.Node ops: pool sizing and type come from CLI (
--pool-type,--pool-limit,--pool-kbytes) viaConfiguration::transaction_poolinstead of hardcoded SingleState limits; defaults stay Quantus-oriented (SingleState, ~256 MiB / large PQ tx count).Dilithium / CLI: adds fallible
try_sign, secret zeroization, faithful HD seed from mnemonics; CLI sign/verify rejects oversized messages before signing panics.Runtime / RPC:
reversible-transfersfreezes cancel authority inpending.guardianand chargesrecover_fundsper distinct scheduler agenda bucket; fee-query RPC rejects extrinsics above block-length before SCALE decode.Reviewed by Cursor Bugbot for commit 43d2761. Configure here.