88628 bc critical balances transfer allow death debits the sender credits the recipient nothing and reports success
Submitted on Aug 14th 2026 at 08:55:03 UTC by @rev3rse for Audit Comp | Quantus
Report ID: #88628
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Direct loss of funds
Description
Brief/Intro
fungible::Mutate::transfer evaluates its precondition on one quantity and credits a different one. When a sender's whole balance is near the existential deposit, transfer_allow_death debits the sender, writes nothing to the recipient, and returns Ok — emitting a Transfer event for value that never moved.
At production parameters (ExistentialDeposit = 1,000,000,000 planck) a transfer of 500,000,000 planck destroys the sender's full 1,000,000,000 while the recipient's balance stays at zero. It also leaves total_issuance permanently higher than the sum of all account balances, by exactly the transferred amount.
This is a regression introduced by Quantus commit db94e2a "Harden FRAME primitives against audit findings (#616)". Upstream Substrate rejects the identical call.
No attacker is required and no attacker benefits — this is accidental loss for ordinary users whose accounts sit near dust, plus a permanent accounting divergence.
Vulnerability Details
Asset: immunefi-team/audit-comp-quantus-chain (Blockchain/DLT), frozen at 3b243b87
File: frame/support/src/traits/tokens/fungible/regular.rs
Entry point: Balances::transfer_allow_death (pallets/balances/src/lib.rs:671-679)
Bug class: Accounting desynchronisation — precondition checked on a different value than the one written
Severity claimed: C3 — "Direct loss of funds" (Blockchain/DLT), the row this defect actually occupies. I state the measured magnitude bound in the same breath and leave the tier designation to triage. See "Severity" below for why no Low row fits.
Root cause
frame/support/src/traits/tokens/fungible/regular.rs:317-343:
Three facts combine:
The gate and the credit use different quantities —
amount + dustversusdebited.The comment's premise no longer holds in this tree.
decrease_balance(:188-197) returnsold_balance.saturating_sub(new_balance)wherenew_balance = old − amount, i.e. exactlyamount. Dust is disposed of separately viawrite_balance/handle_dustand never appears in the return value, sodebitedcan never exceedamount.The guard was not written carelessly — it was correct for
pallets/assets, the one implementor that overrodedecrease_balancesuch thatdebited > amountwas reachable. That pallet was removed from this repository ine362a87("Remove assets pallet", #637), andpallets/assetsis absent from the tree at3b243b87. The widened gate therefore has no remaining beneficiary in this codebase, which is what makes the fix below lossless rather than a trade-off.The credit silently no-ops.
increase_balance(dest, debited, BestEffort)(:205-222) returnsOk(Default::default())writing nothing whennew_balance < minimum_balance, and the caller discards that result withlet _ =.
When amount < ED <= amount + dust, the widened gate admits the call and the credit does nothing.
The project's own commit and test both state the intended behaviour
This is not a disputed design call. The commit that introduced this code, db94e2a ("Harden FRAME primitives against audit findings", #616), describes its purpose as:
"Credit the full amount removed from the source in generic transfer helpers so expendable reaping cannot desync issuance from balances."
The change was written specifically to prevent the invariant that it breaks: after this defect fires, TotalIssuance exceeds the sum of account balances by exactly the destroyed amount.
The project's own test suite states the expected outcome for this exact call shape. pallets/balances/src/tests/dispatchable_tests.rs:31-45, with ExistentialDeposit = 10, a dead destination and amount = 9 < ED:
The suite says this call must fail with BelowMinimum. It does not catch the bug only because its sender (account 1, balance 100) is well funded, so no reap occurs and dust = 0, leaving the gate un-widened. Make the sender reapable and the same call returns Ok while destroying the funds — which is precisely what my PoC does.
Impact Details
An ordinary user whose account sits near the existential deposit — anyone sweeping a nearly-empty account, or any automated dust-consolidation service — loses their remaining balance while the chain reports the transfer succeeded and emits a Transfer event for value that never arrived. Off-chain indexers that credit on Transfer events will record a deposit that does not exist.
Each incident also permanently breaks total_issuance == Σ(balances) by the transferred amount. Nothing on-chain reconciles it, so the divergence accumulates for the life of the chain.
Second, latent site: frame/support/src/traits/tokens/fungibles/regular.rs:373-396 is a verbatim clone of the same defect for the multi-asset trait. It is unreachable today — no fungibles implementor exists in-tree — but it arms the moment one is added. Please fix both together.
References
frame/support/src/traits/tokens/fungible/regular.rs:317-343—transfer, the defectframe/support/src/traits/tokens/fungible/regular.rs:188-197—decrease_balance, provingdebited <= amountframe/support/src/traits/tokens/fungible/regular.rs:205-222—increase_balancereturningOk(0)below EDframe/support/src/traits/tokens/fungibles/regular.rs:373-396— the latent multi-asset clonepallets/balances/src/lib.rs:671-679—transfer_allow_death, passesExpendablepallets/balances/src/lib.rs:745—transfer_allwithkeep_alive = false, a second entry pointpallets/balances/src/impl_fungible.rs:191-211—impl fungible::Mutatedefines onlydone_*, so the defective default body is inheritedpallets/balances/src/impl_fungible.rs:204-210—done_transferemitsEvent::Transferwith the uncredited amountruntime/src/lib.rs:102—EXISTENTIAL_DEPOSIT = MILLI_UNIT = 1e9runtime/src/lib.rs:97—UNIT = 1e12Commit
db94e2a"Harden FRAME primitives against audit findings (#616)", 2026-07-03 — the introducing change;git log -Son this hunk returns only this commitUpstream Substrate
fungible::Mutate::transfer— gates onamountand creditsamountpallets/balances/src/tests/— the existing 277-test suite passes with the defect live; it never constructs theamount < ED <= amount + dustcasePrior audits checked for duplication:
quantus-substrate-audit-20260513.pdf(Equilibrium — scope was treasury/multisig/scheduler/reversible-transfers at commit0467a304, with "other pallets and runtime modules not listed above" explicitly out of scope;frame/support/was never in scope for any engagement),EigerWormholeAudit.pdf,Proof-of-Work and Poseidon Security Review 1.1.pdf,Neodyme-Audit.pdf. None reports this.chain/pallets/balances/src/tests/dispatchable_tests.rs:31-45— the project's own test assertingTokenError::BelowMinimumfor this call shapeCommit
db94e2a(#616) "Harden FRAME primitives against audit findings" — introduced the discarded-credit code; its message states the invariant it was meant to protectCommit
e362a87(#637) "Remove assets pallet" — removed the only implementor for which the widened gate was necessaryImmunefi program out-of-scope list, "Account reaping leading to replayed transactions" — addressed above; impact-scoped to replay, which this report does not claim
Link to Proof of Concept
https://gist.github.com/almog-shachar/1fa0520b5724fb3248b55a02a366786e
Proof of Concept
Single patch, applies to the frozen asset repo. Adds tests only — no production code is modified.
Measured at production parameters
runtime/tests/transactions/poc_f5_transfer.rs drives the real quantus_runtime::Runtime:
The test asserts the issuance relation exactly: issuance_after − sum_after == amount. Before the call the invariant total_issuance == Σ(balances) holds; afterwards it is broken by 500,000,000 planck, permanently.
Controls — included so the finding is bounded, not overclaimed
A funded sender is rejected, because without reaping there is no dust and the gate collapses back to amount. Preservation::Preserve — what transfer_keep_alive uses — is likewise rejected. The defect is confined to the Expendable path with a near-dust sender.
Worst case, measured by exhaustive sweep
The differential against upstream
pallets/balances/src/tests/poc_f5_tests.rs additionally shows the call is rejected upstream. I patched the single gate line to upstream's form and re-ran the identical test:
gate
transfer() result
Quantus: can_deposit(dest, amount + dust, Extant)
Ok(5) — funds destroyed
Upstream: can_deposit(dest, amount, Extant)
Err(Token(BelowMinimum)) — correctly rejected
That isolates the Quantus modification as the sole cause and shows the one-line revert is a complete fix. (regular.rs was restored to pristine afterwards; the patch does not modify it.)
Mainnet fork — four real on-chain accounts losing their entire balances
runtime/tests/transactions/poc_f5_mainnet_fork.rs forks real live state into the audited runtime. It loads the verbatim System::Account storage entries of the four Heisenberg accounts that currently sit in the vulnerable band, plus the live Balances::TotalIssuance, captured read-only at a pinned block:
The state is pinned so the PoC stays reproducible after the chain moves on. No transaction was ever sent to the live network — capture was state_getKeysPaged + state_queryStorageAt at that hash.
The loaded balances match the live chain exactly, which is the control proving the fork is real rather than synthetic. All four accounts lose their entire balance, and the total — 4,000,000,002 planck — reproduces the independent census figure above exactly.
Why a fork of live state and not of the live chain-spec. The chain-spec's raw genesis embeds its own 775,279-byte runtime WASM (:code), so forking it would execute the deployed runtime rather than the audited tree this report concerns. Forking state into the audited runtime tests the in-scope code against production reality, which is the property that matters.
One honest note on method. My first fork attempt loaded System::Account but not Balances::TotalIssuance, leaving accounts funded against zero issuance. The transfer then returned Err(Arithmetic(Underflow)) — which reads exactly like "the defect does not fire on mainnet". It was an inconsistent fork, not a refutation. State forks must be internally consistent or they manufacture false negatives.
This is live on both public networks, and accounts are sitting in the vulnerable band today
Read-only RPC only — no transaction sent.
The deployed runtime is unfixed. Live
specVersionis 144 on both endpoints;144(6f7094f0, #661 — Dilithium pubkey caching) touches **noframe/file at all. Public HEAD (specVersion 145) is byte-identical to the audit tree for this file. So the defect ships in production today.Complete census of both networks (
evidence/f5_exposure_census.py, paged + batched read-only queries):HeisenbergPlanckaccounts scanned
151 (complete)
2,806 (complete)
in the vulnerable
[ED, 2×ED)band4 (2.65%)
28 (1.00%)
balance at risk
4,000,000,002 planck
41,748,476,000 planck
= 0.004000 QUAN
= 0.041748 QUAN
total supply held in accounts
7,097,308 QUAN
7,052,535 QUAN
Combined currently at risk: 45,748,476,002 planck ≈ 0.0457 QUAN, against ~14.15M QUAN of supply — roughly 0.0000003%. I am reporting that figure rather than leaving it to be discovered: the monetary exposure is small and I am not going to imply otherwise.
At exactly ED the trap is total. Heisenberg's four sit at precisely
1,000,000,000,1,000,000,000,1,000,000,001,1,000,000,001planck — at the existential deposit, where every partial transfervalue ∈ (0, ED)destroys the whole balance. There is no safe partial send from such an account. Planck's cluster sits at 1.807–1.808e9.This is a snapshot, not lifetime exposure. Any account that spends down toward ED enters the band, and accounts pass through it routinely. I have not measured that flow and am not estimating it.
In scope: frame/support/src/traits/tokens/fungible/regular.rs carries two commits — 75d32f8 (the vendoring commit) and db94e2a (the Quantus modification that introduced this). It is Quantus-modified code, not the pristine upstream that the OOS clause excludes.
What I could NOT establish, and am not claiming: whether the defect has already fired on either network. A TotalIssuance vs Σ(free + reserved) reconciliation returned −249.77 QUAN — the opposite direction from this bug's signature, cause unestablished. It is unusable as a detector and no claim in this report rests on it.
Adding gist link here once again: https://gist.github.com/almog-shachar/1fa0520b5724fb3248b55a02a366786e
Was this helpful?