V12 high security accounts - #639
Merged
Merged
Conversation
close_recovery previously checked the deposit repatriation result only via debug_assert!, which compiles out in production. Make the closure explicitly best-effort (it must not be blockable via the rescuer's balance state), log any shortfall or failure, and release the deposit back to the rescuer when the rescued account cannot receive it so the funds are never stranded in reserve with no state left to release them. Co-authored-by: Cursor <cursoragent@cursor.com>
… depth The benchmarked weight charged a flat 10 reads / 9 writes measured on a near-empty tree, but executing a transfer records a wormhole proof whose ZK-tree leaf insert walks the tree leaf-to-root (3 sibling reads and one node write per level, ~100 reads / 37 writes at max depth). Price the tree component from the live depth via insert_leaf_db_ops (worst-case MAX_TREE_DEPTH in the depth-blind () impl), mirroring pallet-wormhole, so deep-tree executions can no longer exceed the block weight model. Co-authored-by: Cursor <cursoragent@cursor.com>
The lifecycle docs promised the Proxy link is removed when the recovered account is reaped, but no such cleanup exists (inherited from upstream, where the line is a fossil of the pre-2020 OnReapedAccount era). Document the actual design - the link persists until cancel_recovered - and pin it with a characterization test. Co-authored-by: Cursor <cursoragent@cursor.com>
can_reserve required the existential deposit to remain in free balance while reserve enforced no such check (its internal ensure_can_reserve call with check_existential_deposit=false was fully redundant), so the query could reject reserves the runtime would accept, e.g. reserving below the ED on an account kept alive by an extra provider reference. Rewrite ensure_can_reserve as an exact predictor of reserve's outcome - free balance, the new consumer reference, and the provider bookkeeping that governs dropping free below the ED - leaving reserve's consensus-visible behavior unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
Genesis deduplicated only the explicit balances list, so an endowed account that also appeared in the dev_accounts derivation was silently overwritten (last write wins) and its provider reference double-bumped, corrupting the endowed state with no error. Assert disjointness in the balances loop so any collision fails the genesis build loudly. Co-authored-by: Cursor <cursoragent@cursor.com>
…cording a phantom deposit When lowering a deposit, both poke helpers logged an unreserve shortfall via defensive! but still committed the target deposit, leaving the stored deposit claiming more than is actually reserved. Since reserves are an unnamed pool, later release paths could then effectively consume other pallets' reserved funds. Fail the poke with BadState instead: dispatch is transactional, so the partial unreserve reverts and the books stay exactly consistent. Co-authored-by: Cursor <cursoragent@cursor.com>
…not insecure_zero_ed The insecure_zero_ed feature only permits a zero existential deposit, it does not guarantee one, yet minimum_balance() hardcoded 100 under the feature and the transfer/burn post-conditions assumed the caller is never reaped. With the feature enabled and an ED above the leftover the caller is reaped and the benchmarks fail, breaking weight-generation runs. Derive the scaling unit from the real ED (100 stand-in only when it is actually zero) and compute the expected caller balance from actual reaping behavior. Add a benchmark-runner test under ED=150 so a feature-based expectation cannot pass by luck. Co-authored-by: Cursor <cursoragent@cursor.com>
…s_fallible wrappers count_transfers had no case for Utility::if_else (signed-reachable) or dispatch_as_fallible, so transfers wrapped in them were charged zero proof-recording weight and post_dispatch billed the work to block capacity instead of the signer. if_else is priced at the worst case across its two branches (exactly one branch ever commits, and overcharge is never refunded), and dispatch_as_fallible recurses like dispatch_as. Proof recording itself was and remains event-based and unaffected. Co-authored-by: Cursor <cursoragent@cursor.com>
…recovery Every live Proxy entry must be backed by exactly one frame_system consumer reference: claim_recovery takes it and cancel_recovered unconditionally releases it. Root's set_recovered inserted the mapping without one, so the rescuer stayed reapable while the proxy existed and a later cancel either underflowed or released a reference owned by other pallet state. Take the reference on insert; a Root replacement of an existing mapping carries the entry's reference over instead of taking a second one. The benchmark now funds the rescuer since the call requires the account to exist. Co-authored-by: Cursor <cursoragent@cursor.com>
Genesis computed TotalIssuance from the explicit balances list only, while derive_dev_account credited accounts through mutate_account_handling_dust, which leaves issuance maintenance to the caller that never did it. Chains enabling dev_accounts therefore started with stored balances exceeding recorded issuance. Maintain issuance delta-based inside derive_dev_account, make ensure_ti_valid reconcile every account instead of skipping dev accounts, and make ExtBuilder dev accounts opt-in: the suite's pristine-genesis issuance assumptions (including the fungible conformance tests) are only valid without them, and dev-account behavior now has dedicated coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
…uance The legacy-account failsafe (reserved funds, no provider refs) tops the account up to the existential deposit through a raw account write whose contract leaves issuance maintenance to the caller, so each such upgrade minted spendable balance without increasing TotalIssuance, silently inflating effective supply. Record the minted delta in TotalIssuance before applying the top-up. Co-authored-by: Cursor <cursoragent@cursor.com>
…ting
derive_dev_account documents a Result contract but asserted on three
reachable inputs (zero count, balance below ED, derivation without {}),
all of which come from the chain specification, and the genesis caller
flattened returned errors into a generic assert. Turn the asserts into
structured errors and have genesis panic with the specific propagated
reason (genesis build cannot return errors), so tooling sees a real
configuration failure instead of a bare abort.
Co-authored-by: Cursor <cursoragent@cursor.com>
do_reschedule and do_reschedule_named removed the source task, cleaned the agenda and emitted Canceled before placing at the destination, so a placement failure (Exhausted target agenda) destroyed the task outright, leaked its preimage reference, left Retries keyed to the vacated slot and, for named tasks, a Lookup entry dangling at an empty address. Since reschedule is a trait API, callers handling the error keep that corrupt state. Reorder to validate and clone the task, place it first, and only vacate the source on success, making the failure path a strict no-op. Co-authored-by: Cursor <cursoragent@cursor.com>
…verweight The PermanentlyOverweight terminal path removed the Lookup entry and dropped the preimage but left the Retries row keyed to the vacated address, unlike the sibling CallUnavailable path. Every such task with a retry config leaked an orphaned RetryConfig into storage forever. Mirror the unavailable-call cleanup. Co-authored-by: Cursor <cursoragent@cursor.com>
set_retry/set_retry_named validated only that the period variant matched the task's scheduling domain, not its value. A zero period made schedule_retry place the retry clone into the agenda currently being serviced, whose stale in-memory copy service_agenda writes back afterwards -- silently losing the retry while its Retries row survived, orphaned at an address a later task could inherit. Timestamp periods that are not a whole multiple of TimestampBucketSize were equally lethal: schedule_retry computes wake = now + period without re-normalizing, landing the retry in an agenda key the bucket-stepping servicing loop never visits. Reject both shapes upfront with a new InvalidRetryPeriod error. Co-authored-by: Cursor <cursoragent@cursor.com>
The pre-dispatch weight correctly charges the worst-case first-use reveal path, but the returned actual_weight also added the KnownDerivatives insert and wormhole pool write unconditionally. Repeat invocations of an already-known pseudonym skip that work, so only charge writes(2) when the reveal branch actually executed; the membership read still happens every time and is never refunded. Co-authored-by: Cursor <cursoragent@cursor.com>
…tion migrate_v0_to_v1 summed total_balance over the raw configured account list, so a MigrateManyToTrackInactive config with repeated IDs counted the same balance multiple times. deactivate only caps the result at TotalIssuance, so duplicates could overstate InactiveIssuance up to TI and permanently deflate active_issuance() once the one-shot version bump lands. Sum over a deduplicated set and charge weight for the unique reads. Co-authored-by: Cursor <cursoragent@cursor.com>
…fort The closing transfer_all used ?, so any sweep failure aborted the whole (transactional) extrinsic and rolled back every hold release, fee burn, metadata removal and scheduler cancellation performed by the loop -- re-arming the pending transfers a compromised account's guardian is trying to stop and letting them execute at their scheduled time. Keep the cancellation work on sweep failure, emit RecoverySweepFailed instead of FundsRecovered, and let the guardian retry the sweep; repeated recovery is already a supported pattern. Co-authored-by: Cursor <cursoragent@cursor.com>
The benchmark used a fresh caller, so the measured execution always included the first-use derivative reveal. Regenerating weights would bake that reveal into the opaque base weight while the dispatch annotation separately adds it as an explicit DbWeight surcharge -- double-charging first use and overcharging repeat use beyond what the post-dispatch refund can compensate. Pre-seed KnownDerivatives (and whitelist the key) so the generated base measures repeat use and the annotation's explicit terms remain the sole first-use surcharge. Co-authored-by: Cursor <cursoragent@cursor.com>
All miner connections share one bounded result channel that is drained only while the mining loop actively waits for results. The blocking send let one miner fill the channel and park every other miner's connection handler inside the send, which also stopped them from receiving new jobs -- a one-miner DoS against the external mining path. Forward with try_send instead, dropping overflow results, and disconnect a connection that keeps overflowing an already-full channel. Co-authored-by: Cursor <cursoragent@cursor.com>
…nting sentinel The genesis default named [1u8; 32] -- the keyless minting sentinel -- as the treasury account, so a chain spec that omitted the treasury section silently sent every treasury payout to an address nobody can sign for. The default now configures nothing (FRAME requires it to exist and build); an unconfigured treasury fails loudly via the existing account_id()/portion() panics, and a half-configured genesis is rejected at build. Co-authored-by: Cursor <cursoragent@cursor.com>
peer_getBasicInfo returned the node's peer ID, connected peer IDs and external/listen addresses to any caller who could reach the RPC listener -- the same topology data upstream Substrate only serves via unsafe-gated RPCs (system_peers, system_unstable_networkState), useful for eclipse/partition reconnaissance. The handler now checks the per-connection DenyUnsafe policy first, so it is served only to local connections or under --rpc-methods unsafe. Also renamed to peer_getNetworkInfo to describe what it actually returns. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
approved these changes
Aug 5, 2026
n13
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the full diff with the PR checked out, tracing each fix into the surrounding code. Every claim in the description matches the implementation, and each fix ships with a test that actually exercises the failure mode it claims to prevent. Details of what I verified, then findings (all minor, none blocking).
Verified against the code, beyond the diff hunks:
- Balances genesis:
TotalIssuance::put(total)from the explicitbalanceslist runs beforederive_dev_account, so the per-account TI deltas correctly add on top rather than being overwritten (pallets/balances/src/lib.rs:587→:610). The pre-existing duplicate-balances assert still runs before the new dev-collision assert, so a plain duplicate inbalancesis caught with the right message. ensure_can_reserve: the three checks (free sufficiency, consumer-ref availability whenreserved == 0 && frozen == 0, and theConsumerRemainingpath when the reserve strips the last free-balance provider) mirror exactly whattry_mutate_account's provider/consumer bookkeeping enforces, and the new boundary test pins the agreement in both directions (extra provider ref flips both answers).as_derivative: the declared weight annotation (pallets/utility/src/lib.rs:283-290) still charges the first-use reveal (reads_writes(1, 2)), the post-dispatch path refunds only the two writes on repeat use, and the benchmark change (pre-seedingKnownDerivatives) is what keeps the reveal out of the generated base — the three pieces are consistent, and the refund test asserts the exactwrites(2)delta.if_elseworst-case charging: sound, because the nestedmain.dispatch(origin)runs in its own storage layer — a failed main rolls back, including any proof-recording effects, somax(main, fallback)covers the actual recorded transfers.count_transferscoverage: withdispatch_as_fallibleandif_elseadded, every call-wrapping extrinsic reachable by signed origins is now covered (batch variants sum, single-inner wrappers recurse,as_recoveredincluded);ScheduleOriginisEnsureRootand there is no sudo pallet, so scheduler-wrapped calls are not an open evasion path. Non-statically-countable wrappers are reconciled post-dispatch per the existing note.- Depth-scaled
execute_transferweight:pallet_zk_tree::insert_leaf_db_ops()exists and reads the live depth; the depth-blind()impl prices atMAX_TREE_DEPTHand the test asserts it dominatesSubstrateWeightat any depth. - Treasury: all runtime presets set
treasury_account/treasury_portionexplicitly;account_id()/portion()panic with a clear message when unconfigured; half-configured genesis is rejected at build. - Miner flood fix:
try_send+ consecutive-drop bound with reset-on-success is the right shape — an honest miner losing occasional results to someone else's flood can never accumulate to a disconnect (tested). - RPC gating:
check_if_safeon the jsonrpsee extensions matches upstream's treatment ofsystem_peers; the CLI help andquery_peers.shdocument the breaking rename and the new--rpc-methods unsaferequirement.
Minor findings (non-blocking):
pallets/recovery/src/lib.rs:481—set_recoveredmaps theinc_consumersfailure toBadState, but the reachable cause is the rescuer account simply not existing (no provider refs), which is a caller-input error rather than corrupted pallet state. A dedicated error (orDispatchError::NoProviderspassthrough) would make the root-caller diagnosis clearer. Fine to leave if you'd rather not grow the error enum.pallets/scheduler/src/lib.rs:860/:970— the reordered reschedule now emitsScheduled(fromplace_task) beforeCanceled, the reverse of the previous order. Purely cosmetic, but indexers that reconstruct task lifecycles from event order will see the flip; worth a mention in release notes alongside thepeer_getBasicInforename.pallets/reversible-transfers/src/weights.rs:66—TREE_KEY_POV = 2600duplicates the same hand-picked figure inpallet-wormhole's weights by copy. Exporting one shared constant frompallet-zk-treewould keep the two from drifting when the tree layout changes.- The Build & Test matrix was still pending at review time; format/clippy/doc checks pass. Merge should wait for the matrix as usual.
Verdict: approve. The fixes are correct, conservatively designed (over-charge rather than under-charge, best-effort only where rollback would be strictly worse, fail-loud genesis), and the test coverage is exemplary — red-test-first shows in how precisely the tests pin the failure modes.
…dState The inc_consumers failure in set_recovered was masked as BadState, which implies corrupted pallet state. The reachable cause is the rescuer account simply not existing (no provider references) -- a caller-input error. Propagate the precise frame_system error (NoProviders) so the root caller can diagnose it directly. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
added a commit
that referenced
this pull request
Aug 5, 2026
Resolve overlap with #639 (V12 high security accounts), which independently fixed three of the same scheduler findings: - #162523 (failed reschedule): keep main's copy-first approach - a failed placement is now a complete no-op. Drop this branch's restore_task helper; keep its two extra regression tests (retry-config survival, named task not bricked + subsequent successful reschedule), which pass under both implementations. - #162524 (PermanentlyOverweight Retries): identical code on both sides; keep the V12-tagged comment. - #162526 (unaligned timestamp retries): superseded by main's fail-fast validation rejecting non-bucket-aligned periods at set_retry time (InvalidRetryPeriod). Drop this branch's schedule_retry normalization and its test. This branch's remaining fixes are unaffected: #161704/#162455 (set_alarm retry), #161743 (ghost-queue flag), #162501 (nudge weight), #162534 (service_task weights + benchmark meter), #162453 (conditional preimage request), #162411 (validate_unsigned check_version), #162546 (heap-pages range).
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security review fixes (v12)
Addresses the items from the security review. Each item was first verified as a real
issue, then covered by a red (failing) test where applicable, fixed, and re-verified
green. One item was acknowledged without a code fix and one required only
documentation (details at the bottom).
Recovery pallet
close_recoveryfund loss (dac2bd0c):close_recoverydiscardedrepatriate_reservederrors and deleted the active-recovery state anyway, silentlylosing the rescuer's deposit. The repatriation is now infallible best-effort: on a
shortfall the remainder is unreserved back to the rescuer, and errors are logged
instead of corrupting state. Also documented that the recovery link survives account
reaping (
9e34e3d1).poke_depositphantom deposits (3ac6acef): on an unreserve shortfall the callrecorded the new (larger) deposit anyway, overstating claims on shared reserves. It
now fails transactionally with
BadState, reverting everything.set_recoveredconsumer reference (df70c6db): root-created proxies skipped theconsumer reference that
claim_recoverytakes, so the rescuer account could bereaped while holding an active proxy.
set_recoverednow takes the same reference(once per proxy, maintained across replacement).
Balances pallet
can_reserve/reservedisagreement (467cb548):can_reserveused a simplerrule than
reserveand mispredicted it in provider/consumer edge cases.ensure_can_reservenow exactly modelsreserve's behavior.d7e1ffa2): explicitly endowed genesisbalances that collided with derived dev accounts silently overwrote them; genesis now
panics on the collision.
5a8f3c25): benchmark assertions were driven by theinsecure_zero_edfeature rather than the configuredExistentialDeposit;expectations are now derived from the actual ED and real reaping rules.
TotalIssuance(a38c8a2a):derive_dev_accountminted free balance without updatingTotalIssuance, breakingthe issuance invariant at genesis. Dev-account creation now maintains the delta and is
opt-in for tests.
ensure_upgradedfailsafe mint uncounted (a75d4025): the legacy-account EDtop-up minted funds without recording them in
TotalIssuance; now recorded.derive_dev_accountpanics (570594d2): internalassert!s became structurederrors; genesis surfaces them with a descriptive panic message.
d209bccc):migrate_v0_to_v1summedtotal_balanceper list entry, so a duplicated account ID permanently overstatedInactiveIssuance. Accounts are now deduplicated before summing.Scheduler pallet
f26c8331): a failed destination placement indo_reschedule(_named)could destroy the source task. Placement is now attemptedfirst; source state is only vacated on success.
c13f698d): thePermanentlyOverweightterminal pathremoved the task but left its
Retriesentry behind; it is now cleaned up like theunavailable-call path.
c30cbac7):set_retry(_named)accepted zero periods(retry clone silently discarded) and timestamp periods not aligned to
TimestampBucketSize(retry lands in an agenda key the servicing loop never visits,holding its preimage forever). Both are now rejected with
InvalidRetryPeriod.Utility pallet
as_derivativeweight overcharge (2efdd309,c7aa17d1): every call chargedfor the first-use pseudonym reveal (two storage writes) even though repeat uses skip
it. Repeat uses now refund the reveal writes post-dispatch, and the benchmark
measures the repeat-use path so the reveal cost is not also baked into the base
weight (which would have double-charged first use).
Runtime
94510c34): the proof-recording transactionextension did not recurse into
Utility::if_else(now charges the worst-case branch)or
Utility::dispatch_as_fallible, letting wrapped transfers evade the per-transferweight surcharge.
4cf2ef6c):execute_transferhada fixed weight that ignored depth-dependent ZK-tree insertion costs; its weight now
scales with the tree's current depth, mirroring
pallet-wormhole.recover_fundsatomicity (8fd7b255): a failure in the finaltransfer_allsweep rolled back the entire extrinsic — re-arming the very pending transfers the
guardian was cancelling on a compromised account. The sweep is now best-effort:
cancellations and hold releases stick, a failed sweep emits
RecoverySweepFailed,and the guardian can retry.
Treasury / mining rewards
4f258855): the genesisdefault set
treasury_accountto[1u8; 32]— the same keyless sentinel used asthe minting source — so a chain spec omitting the treasury section silently sent
every treasury payout to an unspendable address. The default now configures nothing
(FRAME requires the
Defaultimpl to exist and build); an unconfigured treasuryfails loudly on first use and a half-configured genesis is rejected at build. All
production presets already set distinct treasury accounts explicitly.
Node
de3c79ca): all miner connections share one boundedresult channel drained only while the mining loop waits for results; a blocking
sendlet one flooding miner park every other connection handler, cutting honestminers off from jobs and seal submission. Forwarding now uses
try_send(droppingoverflow), and a connection that keeps overflowing an already-full channel is
disconnected.
7286c0ac):peer_getBasicInforeturned thenode's peer ID, connected peers, and external/listen addresses to any caller who
could reach the RPC listener — reconnaissance data for eclipse/partition attacks.
The handler now enforces the per-connection
DenyUnsafepolicy (local connectionsor
--rpc-methods unsafeonly), matching upstream's treatment ofsystem_peers.Renamed to
peer_getNetworkInfoto describe what it returns — breaking for anyexternal tooling still calling the old method name.
Acknowledged without a code fix
check_inherentvalidates block timestampsagainst the importing node's local clock with a tight drift bound, which can reject
valid blocks on skewed clocks. Acked as inherent to distributed timekeeping; no
change.
344df456): unreachablewith the runtime's
BlockNumber = u32, and committing the full width would changeevery block hash and the wormhole circuit layout. Documented the constraint at the
truncation site instead; the generic header must not be instantiated with a wider
number type.
Test plan
pallet-recovery,pallet-balances,pallet-scheduler,pallet-utility,pallet-reversible-transfers,pallet-treasury,quantus-runtime,quantus-node,qp-header)