Skip to content

V12 high security accounts - #639

Merged
illuzen merged 25 commits into
mainfrom
illuzen/v12-high-security
Aug 5, 2026
Merged

V12 high security accounts#639
illuzen merged 25 commits into
mainfrom
illuzen/v12-high-security

Conversation

@illuzen

@illuzen illuzen commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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_recovery fund loss (dac2bd0c): close_recovery discarded
    repatriate_reserved errors and deleted the active-recovery state anyway, silently
    losing 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_deposit phantom deposits (3ac6acef): on an unreserve shortfall the call
    recorded the new (larger) deposit anyway, overstating claims on shared reserves. It
    now fails transactionally with BadState, reverting everything.
  • set_recovered consumer reference (df70c6db): root-created proxies skipped the
    consumer reference that claim_recovery takes, so the rescuer account could be
    reaped while holding an active proxy. set_recovered now takes the same reference
    (once per proxy, maintained across replacement).

Balances pallet

  • can_reserve / reserve disagreement (467cb548): can_reserve used a simpler
    rule than reserve and mispredicted it in provider/consumer edge cases.
    ensure_can_reserve now exactly models reserve's behavior.
  • Genesis collisions with dev accounts (d7e1ffa2): explicitly endowed genesis
    balances that collided with derived dev accounts silently overwrote them; genesis now
    panics on the collision.
  • Benchmarks assumed zero ED (5a8f3c25): benchmark assertions were driven by the
    insecure_zero_ed feature rather than the configured ExistentialDeposit;
    expectations are now derived from the actual ED and real reaping rules.
  • Genesis dev accounts missing from TotalIssuance (a38c8a2a):
    derive_dev_account minted free balance without updating TotalIssuance, breaking
    the issuance invariant at genesis. Dev-account creation now maintains the delta and is
    opt-in for tests.
  • ensure_upgraded failsafe mint uncounted (a75d4025): the legacy-account ED
    top-up minted funds without recording them in TotalIssuance; now recorded.
  • derive_dev_account panics (570594d2): internal assert!s became structured
    errors; genesis surfaces them with a descriptive panic message.
  • Inactive-issuance migration duplicates (d209bccc): migrate_v0_to_v1 summed
    total_balance per list entry, so a duplicated account ID permanently overstated
    InactiveIssuance. Accounts are now deduplicated before summing.

Scheduler pallet

  • Non-atomic reschedule (f26c8331): a failed destination placement in
    do_reschedule(_named) could destroy the source task. Placement is now attempted
    first; source state is only vacated on success.
  • Orphaned retry config (c13f698d): the PermanentlyOverweight terminal path
    removed the task but left its Retries entry behind; it is now cleaned up like the
    unavailable-call path.
  • Invalid retry periods (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_derivative weight overcharge (2efdd309, c7aa17d1): every call charged
    for 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

  • Undercharged wormhole proof weight (94510c34): the proof-recording transaction
    extension did not recurse into Utility::if_else (now charges the worst-case branch)
    or Utility::dispatch_as_fallible, letting wrapped transfers evade the per-transfer
    weight surcharge.
  • Reversible-transfer weight vs ZK-tree depth (4cf2ef6c): execute_transfer had
    a fixed weight that ignored depth-dependent ZK-tree insertion costs; its weight now
    scales with the tree's current depth, mirroring pallet-wormhole.
  • recover_funds atomicity (8fd7b255): a failure in the final transfer_all
    sweep 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

  • Treasury defaulted to the keyless minting sentinel (4f258855): the genesis
    default set treasury_account to [1u8; 32] — the same keyless sentinel used as
    the 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 Default impl to exist and build); an unconfigured treasury
    fails loudly on first use and a half-configured genesis is rejected at build. All
    production presets already set distinct treasury accounts explicitly.

Node

  • Miner result-channel DoS (de3c79ca): all miner connections share one bounded
    result channel drained only while the mining loop waits for results; a blocking
    send let one flooding miner park every other connection handler, cutting honest
    miners off from jobs and seal submission. Forwarding now uses try_send (dropping
    overflow), and a connection that keeps overflowing an already-full channel is
    disconnected.
  • Unauthenticated peer-topology RPC (7286c0ac): peer_getBasicInfo returned the
    node'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 DenyUnsafe policy (local connections
    or --rpc-methods unsafe only), matching upstream's treatment of system_peers.
    Renamed to peer_getNetworkInfo to describe what it returns — breaking for any
    external tooling still calling the old method name.

Acknowledged without a code fix

  • Timestamp inherent wall-clock drift: check_inherent validates block timestamps
    against 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.
  • Header hash commits only 32 bits of the block number (344df456): unreachable
    with the runtime's BlockNumber = u32, and committing the full width would change
    every 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

  • Red test written before each fix where applicable; verified green after
  • Full test suites pass for every touched crate (pallet-recovery,
    pallet-balances, pallet-scheduler, pallet-utility,
    pallet-reversible-transfers, pallet-treasury, quantus-runtime, quantus-node,
    qp-header)

illuzen and others added 24 commits August 5, 2026 10:14
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 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.

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 explicit balances list runs before derive_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 in balances is caught with the right message.
  • ensure_can_reserve: the three checks (free sufficiency, consumer-ref availability when reserved == 0 && frozen == 0, and the ConsumerRemaining path when the reserve strips the last free-balance provider) mirror exactly what try_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-seeding KnownDerivatives) is what keeps the reveal out of the generated base — the three pieces are consistent, and the refund test asserts the exact writes(2) delta.
  • if_else worst-case charging: sound, because the nested main.dispatch(origin) runs in its own storage layer — a failed main rolls back, including any proof-recording effects, so max(main, fallback) covers the actual recorded transfers.
  • count_transfers coverage: with dispatch_as_fallible and if_else added, every call-wrapping extrinsic reachable by signed origins is now covered (batch variants sum, single-inner wrappers recurse, as_recovered included); ScheduleOrigin is EnsureRoot and 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_transfer weight: pallet_zk_tree::insert_leaf_db_ops() exists and reads the live depth; the depth-blind () impl prices at MAX_TREE_DEPTH and the test asserts it dominates SubstrateWeight at any depth.
  • Treasury: all runtime presets set treasury_account/treasury_portion explicitly; 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_safe on the jsonrpsee extensions matches upstream's treatment of system_peers; the CLI help and query_peers.sh document the breaking rename and the new --rpc-methods unsafe requirement.

Minor findings (non-blocking):

  1. pallets/recovery/src/lib.rs:481set_recovered maps the inc_consumers failure to BadState, 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 (or DispatchError::NoProviders passthrough) would make the root-caller diagnosis clearer. Fine to leave if you'd rather not grow the error enum.
  2. pallets/scheduler/src/lib.rs:860 / :970 — the reordered reschedule now emits Scheduled (from place_task) before Canceled, 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 the peer_getBasicInfo rename.
  3. pallets/reversible-transfers/src/weights.rs:66TREE_KEY_POV = 2600 duplicates the same hand-picked figure in pallet-wormhole's weights by copy. Exporting one shared constant from pallet-zk-tree would keep the two from drifting when the tree layout changes.
  4. 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>
@illuzen
illuzen merged commit b4b46f0 into main Aug 5, 2026
5 checks passed
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).
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