89250 bc high missing quantum alignment in the wormhole deposit chokepoint leads to permanent freezing of funds
Submitted on Aug 18th 2026 at 08:38:21 UTC by @FaLLenSKiLL for Audit Comp | Quantus
Report ID: #89250
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of funds
Description
Brief/Intro
The wormhole system's only deposit mechanism, an ordinary balances.transfer recorded via record_transfer_proof, commits ZK Merkle leaves at the raw, un-quantized deposit amount, but a spend proof can only ever attest to floor(amount / 10^10). Any deposit that isn't an exact 0.01 UNIT multiple permanently loses the remainder, and on-chain storage still shows the full amount as if it were spendable, which is misleading. In production this means ordinary users lose a fraction of their principal on essentially any deposit whose amount wasn't hand-rounded (invoice totals, fee-adjusted transfers, percentage splits), with zero attacker action required, and the same unguarded chokepoint independently strands value in three further places: mining rewards, delayed reversible-transfer payouts, and the chain's own genesis initialization.
Vulnerability Details
hash_leaf in pallets/zk-tree/src/tree.rs (lines 147-149) builds the leaf commitment from amount_quantized = u32::try_from(amount / 10^10). That's all the exit circuit ever gets to see. Storage keeps the full raw amount in ZkLeaf.amount (pallets/zk-tree/src/lib.rs), but that field is not what a ZK proof can spend, so anyone reading it directly, a wallet or an indexer, ends up overstating what's actually recoverable.
record_transfer_proof at pallets/wormhole/src/lib.rs:1341 is, in the code's own words, "the single chokepoint every event-scan / call-site recorder goes through." It takes the amount it's given and passes it straight down into record_transfer and then hash_leaf, unchanged.
What does the job is WormholeProofRecorderExtension:: record_proofs_from_events_since (runtime/src/transaction_extensions.rs: 309), a transaction extension that scans every block for Transfer, Minted, TransferOnHold and ReserveRepatriated events. That covers plain transfers, utility.batch, multisig.execute, recovery. as_recovered, all of it, and calls record_transfer_proof with whatever raw amount the event carried, for whatever address it went to.
I recorded deposit of 100.005 UNIT (100 * UNIT + SCALE/2) to TEST_ADDRESS, a genuine wormhole address with a known secret, then computed hash_leaf again for a second, smaller deposit (100 * UNIT + 1) that happens to floor to the same value. The two deposits are literally indistinguishable to anything that can only see the committed hash, which is the only thing a spend proof can ever reference.
on_finalize in the mining-rewards pallet calls mint_reward twice per block, once for the tx-fee remint and once for the miner's share of the block reward, and both go through Currency::mint_into plus record_transfer_proof with the raw amount: no rounding, no remainder handling. The fee remint in particular tends to land under one quantum by itself on quiet blocks.
do_execute_transfer in pallets/reversible-transfers/src/lib.rs (around line 726) runs when a delayed transfer's execution window elapses. It's a scheduler-driven hook, which the event-scan extension can't see (the same reason vesting records its own transfers explicitly), but unlike vesting, it just forwards pending.amount straight into record_transfer_proof. The only check anywhere in scheduling or execution is that the amount isn't zero. Any high-security account that schedules a delayed transfer of a non-round amount to a wormhole address loses the remainder the moment it executes.
Wormhole::on_initialize, at block 1 only, walks every account in genesis storage and calls record_transfer with each account's full balance, again with no rounding. A genesis distribution, whether validator allocations, private-sale amounts, or anything computed as a percentage or a token-sale conversion rather than hand-picked to be round, will very plausibly credit at least one wormhole address with a non-aligned amount, and that remainder is gone before a single user transaction has ever been submitted.
Impact Details
Any deposit to any real wormhole address, made through the system's ordinary deposit mechanism, permanently loses whatever fraction of the amount falls below one 0.01-UNIT quantum. That's up to just under 0.01 UNIT per non-aligned deposit, or the entire amount if the whole deposit is smaller than one quantum. This is proven with a leaf-hash collision. Two deposits differing only below one quantum commit to the exact same leaf hash, so nothing can ever tell them apart or recover the difference.
Fee remints are stranded entirely on quiet blocks, since the real per-transaction fee is itself under one quantum, and block-reward remainders are stranded every block. The PoC measured a fee of 1e9 planck fully stranded and a reward remainder of 2449530288 planck lost out of a 692449530288 planck reward.
So does reversible-transfers. any delayed transfer for a non-aligned amount loses its remainder the moment it executes, since scheduling only checks that the amount isn't zero.
And so does genesis. every non-aligned account balance at block 1 loses its remainder before a single user transaction has run. The repo's own test helpers already exercise this code path, just with round numbers, so it's never actually been tested with realistic ones.
Whatever's already stranded stays stranded even after a fix, unless someone runs a storage migration to sweep it up.
A single transfer_keep_alive to a wormhole address costs 4.765 billion planck in base weight and length fee (dominated by the ~4627-byte ML-DSA-87 signature), plus WormholeProofRecorderExtension's own reserved weight for recording the resulting leaf (tree reads/writes, Poseidon hashing), modeled from the pallet's own weight constants at about 4.425 billion planck more, for a floor around 9.19 billion planck. A real transaction measured on a live node (send 100.005 UNIT from crystal_alice to TEST_ADDRESS, the same fixture as the PoC) paid 12.96 billion planck; the gap is most likely extra event-scan cost specific to that transaction landing in block #1, alongside genesis-leaf-recording, so both numbers are given rather than picking one.
The most you can strand in one deposit is just under ten billion planck, so the worst case returns about 1.09x what you spent using the modeled floor, or about 0.77x using the real measured fee: it costs slightly more than the maximum possible damage from that one transaction. Post-quantum signatures make a single deliberate trigger meaningfully less free than it would be on a chain with small ECDSA signatures.
That same per-transaction fee is also what gets reminted to the miner every block that has at least one signed transaction in it. Since the fee is well above one quantum by itself, a block with even one signed transaction strands the fee remint's sub-quantum portion at minimum.
Batching goes through the same event scanner. WormholeProofRecorderExtension::weight() (per_transfer_weight().saturating_mul(n), runtime/src/transaction_extensions.rs) charges each wrapped transfer its own full recording weight; only the outer extrinsic's signature and base call weight are paid once. For N=1000 wrapped transfers: cost is roughly the one-time base (4.765 billion) plus 1000 times the modeled per-transfer recording weight (4.425 billion), around 4.43 trillion planck, against damage of up to 1000 times 9,999,999,999 planck, about 10 trillion planck, a ratio around 2.3x. The per-transfer recording weight dominates over the one-time signature cost, so this ratio holds roughly flat regardless of batch size.
References
https://github.com/immunefi-team/audit-comp-quantus-chain/tree/3b243b870a5442a6e5f443056f67bbba87d472f5https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L1280-L1382https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L503https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L661https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/transaction_extensions.rs#L309-L390https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/zk-tree/src/tree.rs#L134-L149https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/docs/wormhole-zk.mdhttps://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/mining-rewards/src/lib.rs#L192-L237https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/reversible-transfers/src/lib.rs#L726-L790https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/vesting/src/lib.rs#L561-L564https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/configs/mod.rs#L564-L591
Link to Proof of Concept
https://gist.github.com/FaLLenSKiLL1/d9c2c7b4528599e1b0da68105ea7c89c
Proof of Concept
https://gist.github.com/FaLLenSKiLL1/d9c2c7b4528599e1b0da68105ea7c89c
https://gist.github.com/FaLLenSKiLL1/a96ac0b8b653bce3f6a8b401e050fe08
https://gist.github.com/FaLLenSKiLL1/98ac9f1d0f1b955929daf87e77646303
Verify the live-node leaf collision
Paste wormhole_tests.rs's second test (from the Gist) into pallets/wormhole/src/tests.rs, inside mod wormhole_tests, then:
This independently recomputes hash_leaf on the exact live-fetched bytes and on a hypothetical second deposit of 100*UNIT+1 (a smaller remainder, same floor). Both recomputations produce the identical hash, proving the remainder is cryptographically absent from the leaf, not just unrounded for display.
The same live query also shows TEST_ADDRESS's own genesis leaf (index 2, transfer_count 0), confirming instance 4's mechanism fires on a real chain too; that genesis amount happened to be round, so nothing was stranded in that specific leaf.
Was this helpful?