88771 bc high wormhole miner volume fee is credited to a key less address with no zk tree leaf permanently freezing it
Submitted on Aug 15th 2026 at 00:36:25 UTC by @Naresh_Kandula for Audit Comp | Quantus
Report ID: #88771
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of funds
Description
Summary
Every wormhole exit charges a volume fee. That fee is split three ways — burn, aggregator rebate, miner share — and the miner share is credited to the block author with Unbalanced::increase_balance.
The block author on this chain is, by construction, a wormhole address: hash(hash(salt + secret)). It has no private key and can never be signed for. In this design a balance sitting at such an address is not spendable on its own — the only way value there becomes spendable is by proving a ZK-tree leaf, which mints fresh funds elsewhere. Every other credit to this same class of address in the codebase pairs the credit with a leaf, for exactly this reason.
The miner-fee credit creates no leaf. The tokens are therefore permanently unspendable by anyone, by any path, forever — while TotalIssuance grows by that amount and the runtime emits MinerVolumeFeePaid, reporting the miner as paid.
At the shipped configuration this destroys 0.02% of all wormhole exit volume, continuously and without bound, and it does so silently.
Root cause
1 — the credit, with no record_transfer (chain/pallets/wormhole/src/lib.rs:955-965):
2 — the author is a key-less wormhole address. chain/pallets/wormhole/src/lib.rs:946 calls qp_wormhole::extract_author_from_digest, which reads the 32-byte QPoW pre-runtime preimage and returns derive_wormhole_address(preimage) (chain/primitives/wormhole/src/lib.rs:109-151). That file documents the construction itself, at :100-101:
This is precisely the circuit's unspendable-account construction. It cannot coincide with a Dilithium account — whose address is a hash of a public key — without a Poseidon2 preimage break, so no signature can ever move funds from it.
3 — the same function, twelve lines earlier, does it correctly (chain/pallets/wormhole/src/lib.rs:850-858):
4 — and the sibling pallet crediting the identical author address does it correctly too (chain/pallets/mining-rewards/src/lib.rs:220-241):
Both of those credit the same class of key-less address from the same minting sentinel, and both pair the credit with a leaf. Only the miner volume-fee branch omits it.
5 — no other mechanism compensates. The runtime's WormholeProofRecorderExtension cannot cover it:
It records proofs only from
Balances::Transfer,Balances::Minted,Balances::TransferOnHoldandBalances::ReserveRepatriatedevents (chain/runtime/src/transaction_extensions.rs:339-372).Unbalanced::increase_balanceresolves towrite_balance, which emits no event.Event::Mintedis emitted only fromdone_mint_into— theMutate::mint_intopath — atchain/pallets/balances/src/impl_fungible.rs:192-193.increase_balancedoes not go through it. This is exactly whymining-rewards, which does usemint_into, still records its proof explicitly.count_transfers(chain/runtime/src/transaction_extensions.rs:217-250) does not list either wormhole call, and its own comment states it "must stay in sync with the events matched byrecord_proofs_from_events_since" — i.e. the extension is not expected to record anything here.
The invariant this breaks — stated by the project itself
chain/pallets/wormhole/src/lib.rs:325-329, documenting WormholeAccountId:
"The
AsRef<[u8]>/From<[u8; 32]>bounds allowrecord_transferto canonicalize recipients (reduce each 8-byte limb mod the Goldilocks prime) before keyingTransferCountand inserting ZK-tree leaves."
record_transfer is the mechanism by which a wormhole-address balance becomes spendable. The invariant is therefore: any credit to a wormhole address must be accompanied by a ZK-tree leaf. The pallet upholds it on the exit path, mining-rewards upholds it for block rewards and transaction fees, and this one branch does not.
Proof of Concept
Runnable on unmodified production code. The test drives the real extrinsic (Wormhole::verify_private_batch) with the repository's own checked-in proof fixture — no internals are called by hand and no production line is touched.
The test carries its own non-vacuity oracle: in the same extrinsic, the exit account's TransferCount increases — proving the leaf mechanism is live, reachable and working in this very call — while the block author's stays at 0.
Steps to reproduce
Proof-of-concept source (complete, self-contained)
Output
The whole pallet suite stays green with the test added: 69 passed, 0 failed.
The test asserts, in order
Negative control
Injecting the minimal fix into the Ok(_) arm at pallets/wormhole/src/lib.rs:960 — pairing the credit with a leaf exactly as the exit path does — makes the PoC fail at precisely the leaf assertion, and nowhere else:
left: 1 is the leaf the fix creates. This confirms the assertion is load-bearing and that the missing record_transfer is the sole cause. Reverting the fix restores an empty git diff and the pass (69 passed, 0 failed). No production line is modified in the positive run.
Impact
At the shipped configuration — VolumeFeeRateBps = 4, VolumeFeesBurnRate = 50% (chain/runtime/src/configs/mod.rs:734-736) — the miner share is half of a 4 bps fee, so 0.02% of all wormhole exit volume is permanently destroyed, on every exit, without bound as volume accrues.
Additionally:
Miners never receive volume-fee income at all, removing the intended incentive to include exit settlements — while
MinerVolumeFeePaidreports otherwise, so the failure is silent to operators and to any accounting built on chain events.TotalIssuancepermanently includes tokens that are provably unspendable, so circulating-supply accounting drifts from reality with every exit, and the supply-driven block-reward calculation inmining-rewards::on_finalizeis computed against an inflatedtotal_issuance().
The identical omission applies to the aggregator rebate credit at chain/pallets/wormhole/src/lib.rs:919-933 whenever the aggregator supplies a wormhole-style address — that branch calls increase_balance with no record_transfer either.
Mitigations, and why none of them save it
The miner signs for the address
Impossible. The address is hash(hash(salt + secret)); colliding it with a Dilithium account address requires a Poseidon2 preimage break.
WormholeProofRecorderExtension records it
It matches only Transfer / Minted / TransferOnHold / ReserveRepatriated. increase_balance → write_balance emits no event at all.
Event::Minted fires anyway
Minted is emitted only from done_mint_into (pallets/balances/src/impl_fungible.rs:192-193), on the mint_into path. increase_balance does not go through it.
count_transfers covers wormhole calls
It does not list them, and its own comment says it is deliberately kept in sync with only the recorded events.
A later exit re-mints the value
Exits mint against a leaf. With no leaf there is nothing to prove, so no exit can ever reference this balance.
The Err branch burns it, so it is accounted
The Err branch burns and is accounted. The Ok branch is the defect: the mint succeeds, issuance rises, and the value is unreachable.
Governance can fix it later
A runtime upgrade can fix future payments. It cannot create the historical leaves, and every exit until then destroys value.
Novelty
Not one of the three published Immunefi Known Issues. The declared quantization item concerns mining rewards falling below the circuit's quantized minimum; this report is about a missing leaf record, at any fee size.
Distinct from the prior review finding on this same line. That finding concerned miner-fee address derivation — raw
decodeof the preimage, the missingPOW_ENGINE_IDfilter, and a silent burn on extraction failure. It was fixed: extraction is now correct, filtered, and fallible. The defect reported here is that a correctly derived author is credited without a leaf, and it survives that fix completely — the fix made the address right, not the accounting.Still present on the project's
main. Verified at upstreamQuantus-Network/chainHEAD (c3f7f3b8), ten commits past the frozen competition commit:pallets/wormhole/src/lib.rsis untouched and theOk(_)arm at lines 959-965 still goes straight fromincrease_balancetodeposit_eventwith norecord_transfer. No branch in the repository carries a fix.
Scope
pallets/wormhole is Quantus-authored — it has no upstream Substrate baseline — so the defect is unambiguously in in-scope first-party code. The single affected file is chain/pallets/wormhole/src/lib.rs, with the same shape at the aggregator-rebate branch in that file.
The bug requires no attacker at all and no privileged actor. It triggers on the normal, intended operation of the protocol: every wormhole exit that pays a non-zero miner fee destroys that fee. No special state, no race, no configuration change.
Recommended fix
Pair the credit with a leaf, exactly as the exit path and mining-rewards already do:
Apply the same treatment to the aggregator rebate at lib.rs:919-933.
record_transfer quantizes to the circuit's 2-decimal precision, so a fee below one quantum still cannot be represented as a leaf. Such residue should be added to the burn bucket rather than credited, so that issuance and spendability stay consistent — that is the same class of concern as the declared known issue on quantized mining rewards, and the two should be resolved together.
More generally, the safe invariant to enforce repository-wide is: every credit to a WormholeAccountId must be accompanied by a record_transfer. A single helper that performs both, used everywhere in place of a bare increase_balance/mint_into to a wormhole address, would close the class rather than this instance.
Was this helpful?