Harden FRAME primitives against audit findings#616
Conversation
…in halt These OnRuntimeUpgrade helpers cleared an entire prefix with clear_prefix(.., None) in a mandatory upgrade hook. For a prefix whose key count users can inflate, the first post-upgrade block performs unbounded deletion work, never fits the block weight limit, and re-runs every attempt, halting the chain. Add a `Limit: Get<u32>` parameter and clear at most `Limit` keys per upgrade so the work is bounded. Excess keys remain (harmless orphaned storage) and are logged; raise the limit or use a multi-block migration to remove the rest. Co-authored-by: Cursor <cursoragent@cursor.com>
move_prefix drains and re-writes every key below a prefix in one call with no limit, cursor, or progress feedback. Used in a mandatory runtime-upgrade hook on a user-growable prefix, an attacker can inflate the prefix beforehand and force the first post-upgrade block to perform unbounded reads/deletes/writes, stalling the upgrade. Add `move_prefix_bounded(from, to, limit, maybe_cursor) -> MovePrefixResult` mirroring `clear_storage_prefix`'s bounded/cursored pattern, so a multi-block migration can move keys in bounded chunks and charge weight per moved key. `move_prefix` now delegates to it unbounded, preserving existing behaviour, and its docs (plus `move_storage_from_pallet`) warn about the unbounded call. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… balance check Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…gs at compile time Co-authored-by: Cursor <cursoragent@cursor.com>
Credit the full amount removed from the source in generic transfer helpers so expendable reaping cannot desync issuance from balances. Burn reaping dust in transfer_approved so delegates cannot move more than the approved amount to their chosen destination. 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 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 8ef8b60. Configure here.
n13
left a comment
There was a problem hiding this comment.
Review
Reviewed all 14 commits, tracing each fix into the underlying primitives (Unbalanced::decrease_balance, pallet-assets prep_debit/prep_credit, pallet-balances dust & freeze plumbing, PrefixIterator internals), and ran the test suites for all six touched crates locally (frame-support incl. try-runtime feature, frame-executive, frame-system, pallet-balances, pallet-assets, frame-support-procedural) — all green, consistent with the passing CI matrix.
Verified correct
zk_tree_rooton import — mirrors thestate_rootpattern infinal_checks, and is correctly gated behindstate_root_checkintry_execute_block(the root is state-derived).frame_system::finalize()recomputes it fromZkTreeRootstorage, so honest headers always match and forged roots panic on import. No retroactive sync risk: historical blocks execute under their original runtime.CheckNonce— bothvalidate_nonce_for_accountandprepare_nonce_for_accountrejectnonce == MAX, so the stored nonce can never wrap to 0 and re-validate archived low-nonce transactions.Staleis the right error (such a tx can never become valid). Test covers theMAX-1boundary too.RemovePallet/RemoveStoragebounding —clear_prefix(Some(Limit))usage is correct, and the try-runtime hooks (updated in a0dd58e) tolerate a remainder exactly whenpre_upgradesaw more thanLimitkeys — this resolves the earlier Bugbot finding. Nice detail committing keys to the backend in the tests, since theclear_prefixlimit only bounds backend keys.move_prefix_bounded— cursor semantics check out:last_raw_key()works as a cursor even though that key was just drained (next_keyoperates on the underlying ordering), remainder detection peeks past the last key with a prefix check, andmove_prefixis preserved as au32::MAX-limit wrapper.- Transfer debit propagation — correct for both implementation styles: pallet-assets'
decrease_balanceoverride returnsamount + dustwith no separate dust handling, so crediting the actual debit is exactly what restoressupply == Σ balances; pallet-balances' defaultUnbalanced::decrease_balancestill returns exactlyamount(dust is routed throughhandle_dust→DustRemoval), so native-token transfer behavior is unchanged. - Approved-transfer
burn_dust: true—prep_creditburns the reap dust from supply instead of letting the delegate direct it to an arbitrary destination beyond the approval. - Freeze fixes — the relaxation actually takes effect (pallet-balances'
extend_freeze/set_freezedon't re-check freezable balance), and routingfungibles::decrease_frozenthroughset_freezealigns it with the pre-existingfungiblecounterpart. Freeze increases remain gated. rationhalving — a single halving suffices ((a>>1)+(b>>1) ≤ u32::MAX), ratio preserved up to rounding;SplitTwoWays::TOTALturning invalid configs into post-monomorphization compile errors is a nice guard.decode_allhardening, dev-account cap,dynamic_paramsprecedence — all sound; the precedence change matches the documented intent and has focused unit tests. No in-repo users are affected by any of the breaking changes (noRemovePallet/RemoveStorage/move_prefix/SplitTwoWaysusers in runtime/pallets/node).- The wormhole "investigated, not changed" conclusion matches the code: wormhole extrinsics are gated by
#[pallet::validate_unsigned], and bare extrinsics never enter theTransactionExtensionpipeline thatReversibleTransactionExtension::validateparticipates in.
Findings
fungibles::Balanced::depositdidn't get the mirrored headroom check (should fix; fine as a fast follow-up).frame/support/src/traits/tokens/fungibles/regular.rsdepositstill credits before any issuance check, and itsOnDropDebtis the same saturatingIncreaseIssuance. NoteItemOfroutes the patchedfungible::Balanced::depositinto this unpatched default. Practical in-repo impact is ~zero because pallet-assets'can_increase(.., increase_supply: true)already rejects supply overflow at deposit time, but as a hardening of the vendored generic primitive the fix is only half-applied — recommend mirroring the samematch precisionblock.- Headroom check is best-effort against outstanding debts (doc note). Issuance only grows when a
Debtdrops, so two deposits with live debts can each passchecked_addindividually yet jointly saturate on drop. Not fixable without reserving headroom; worth a sentence in the comment so it isn't read as a hard invariant. - Self-transfer now reports
amount + dust(minor). In both patchedMutate::transferfns thesource == destearly-return now returnsactualeven though nothing is debited and no reap occurs (previouslyamount). Relatedly, thecan_deposit(dest, actual, ..)pre-check includes dust that pallet-balances-style impls never credit (theirdecrease_balancereturnsamount) — an overflow-edge-only over-restriction. Suggest returningamounton the self path. - Nits:
block_import_of_bad_zk_tree_root_failswould be tighter with#[should_panic(expected = "ZK tree root")];count_prefixed_keys_up_toduplicates the test helpercount_prefixed_keysin the same file; themisc.rsdoctestshould_panic→no_runflips are unrelated to the audit batch and reduce those doctests to compile-only (deserves a mention in the PR description);MAX_DEV_ACCOUNTS = 10_000still allows seconds of sr25519 derivation perbuild_statecall — bounded, but arguably could be lower.
Verdict
Approve. Every fix is correct as implemented and regression-tested, the consensus-critical zk_tree_root and CheckNonce changes behave exactly as described, and the breaking API changes are documented with no in-repo fallout. Finding 1 is the only substantive gap and is safe to land as an immediate follow-up (or a quick addition here), since pallet-assets' internal supply check covers the practical case.

Summary
This PR addresses a batch of security and correctness issues in vendored FRAME code and pallet-assets. The changes are intentionally minimal and localized: each fix targets a specific failure mode, adds regression tests where appropriate, and preserves existing behavior for valid configurations.
Block import & consensus
zk_tree_rooton block import —frame-executivenow asserts the imported header'szk_tree_rootmatches the value recomputed from state in bothfinal_checksandtry_execute_block, closing a gap where a block producer could forge the ZK Merkle root while keepingstate_roothonest.Macro & genesis hardening
dynamic_paramsinner precedence — outerruntime_params/params_palletarguments are only injected into bare or empty inner#[dynamic_pallet_params]attributes, honoring documented inner-over-outer precedence.pallet-balancesrejectsnum_accounts > MAX_DEV_ACCOUNTS(10_000) before expensive sr25519 key derivation, bounding work reachable viaGenesisBuilder::build_state.Runtime upgrade / migration safety
RemovePallet/RemoveStorage— both helpers now require aLimit: Get<u32>and pass it toclear_prefix, preventing unbounded mandatory deletion during runtime upgrades.move_prefix_bounded— new cursor/limit API for staging attacker-growable prefix moves across blocks;move_prefixpreserved as an all-at-once wrapper with documentation warnings.Token & balance accounting
Balanced::deposit— rejectsExactdeposits that would overflowtotal_issuance; capsBestEffortdeposits to remaining headroom, preventing supply desync via saturating debt-drop handlers.fungibles::Mutate::transferandfungible::Mutate::transfernow credit and report the amount actually removed from the source (including reaping dust), keepingtotal_supply == Σ balances.do_transfer_approvedsetsburn_dust: trueso delegates cannot move more than the approved amount to their chosen destination when the owner's account is reaped.rationdenominator overflow — halve ratio parts whenfirst + secondwould overflowu32, preventing a saturated denominator from sending the entire imbalance to the first leg.SplitTwoWaysconfigs at compile time —PART1 + PART2 == 0or overflow is a const-eval panic (build failure), not a runtime abort during fee/slash handling.Freeze, nonce, and decode hardening
ensure_frozenonly checks freezable balance when the call would increase the freeze;decrease_frozen(fungibles) routes throughset_freezeinstead ofset_frozen(Polite), allowing partial release of legitimately over-freeze balances.CheckNoncerejects transactions atnonce == MAXinstead of wrapping stored nonce back to 0, preventing replay of archived low-nonce transactions.Callback::curryand alltyped_attribute*readers usedecode_all, rejecting prefix-compatible decodes with trailing attacker-controlled bytes.Investigated, not changed
ReversibleTransactionExtension— report is incorrect for the actual submission path. Bare extrinsics usebare_validate()(a no-op default), bypassing the signed-onlyvalidate()check. Wormhole txs work as intended; no code change.Test plan
frame-executive—block_import_of_bad_zk_tree_root_failsframe-support-procedural— dynamic params precedence (3 unit tests)pallet-balances— dev account cap, deposit issuance headroom, freeze testsframe-support— migration bounds,move_prefix_bounded, freeze helpers, imbalance ration/split, decode_all, curryframe-system— nonce overflow rejectionpallet-assets— reaped-dust transfer accounting, approved-transfer dust burnRemovePallet/RemoveStorage/move_prefixagainst user-growable storage without switching to bounded/chunked variantsStats
Branch:
illuzen/frame-fixes(11 commits, +981 / −110 across 23 files)Note
High Risk
Touches consensus-critical block import (
zk_tree_root), transaction validity (CheckNonce), and runtime-upgrade deletion APIs (breakingRemovePallet/RemoveStoragetype arity); runtimes must adopt the newLimitparameter and verify migration configs.Overview
Hardens vendored FRAME primitives against several audit-style failure modes: forged header fields, unbounded upgrade work, token accounting edge cases, and nonce replay.
Block import —
frame-executivenow recomputes and asserts the headerzk_tree_rootmatches state infinal_checksand (when state checks are on)try_execute_block, so a producer cannot lie about the ZK Merkle root while keepingstate_roothonest.qp-headeris a normal dependency; regression testblock_import_of_bad_zk_tree_root_fails.Macros —
#[dynamic_params]only injects outerruntime_params/params_palletinto bare or empty inner#[dynamic_pallet_params]; explicit inner args win.Migrations —
RemovePalletandRemoveStoragetake aLimit: Get<u32>and pass it toclear_prefix, capping keys removed per upgrade (warn if more remain). Newmove_prefix_boundedwith cursor/limit for chunked moves;move_prefixwraps it with docs warning on unbounded user-growable prefixes.Tokens & genesis —
Balanced::depositcheckstotal_issuanceheadroom before crediting (Exacterrors,BestEffortcaps).pallet-balancesrejectsnum_accounts > MAX_DEV_ACCOUNTS(10_000) inderive_dev_accountbefore sr25519 derivation.Transactions —
CheckNoncerejectsnonce == MAXand refuses to wrap stored nonce to zero on increment, blocking replay of old low-nonce txs.Reviewed by Cursor Bugbot for commit 8ef8b60. Configure here.