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.
The single-asset fungible::Balanced::deposit gained a pre-credit issuance headroom check, but the multi-asset fungibles counterpart kept crediting before any issuance check with the same saturating IncreaseIssuance OnDropDebt handler. ItemOf also routes the patched fungible entrypoint into this unpatched default. Mirror the same match-on-precision block: Exact fails on overflow, BestEffort caps the credit to remaining headroom. Co-authored-by: Cursor <cursoragent@cursor.com>
…debts Issuance only grows when a Debt drops, so two deposits with outstanding debts can each pass the checked_add individually yet jointly saturate on drop. Note this in both Balanced::deposit impls so the check is not read as a hard invariant. Co-authored-by: Cursor <cursoragent@cursor.com>
The dust-propagation fix made the source == dest early-return report amount + dust, but nothing is debited and no reap occurs on that path. Return the requested amount instead, matching the pre-fix behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
A bare should_panic would also pass if the block failed for an unrelated reason (e.g. a bad state root); expecting the ZK tree root assertion message makes the test verify the right check fires. Co-authored-by: Cursor <cursoragent@cursor.com>
The test helper count_prefixed_keys reimplemented the same next_key walk as count_prefixed_keys_up_to; make it a thin uncapped wrapper instead. Co-authored-by: Cursor <cursoragent@cursor.com>
10,000 derivations still allowed seconds of sr25519 work per build_state call. The largest legitimate in-repo use is 1,000 dev accounts, so cap at that. Co-authored-by: Cursor <cursoragent@cursor.com>
|
v12 pls audit |

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.