90180 bc high wormhole miner volume fee credits are uncollectible because no proof leaf is recorded
Submitted on Aug 23rd 2026 at 13:54:22 UTC by @y4rvin for Audit Comp | Quantus
Report ID: #90180
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Direct loss of funds
Description
Summary
process_exit_bundle credits the QPoW block author's Wormhole account with its share of every successful exit volume fee, but it does not record a Wormhole transfer or ZK-tree leaf for that credit. The account is keyless and cannot submit an ordinary signed transfer. Quantus's official wormhole collect-rewards flow discovers withdrawals only from transfer rows and requires the row's leaf index, transfer count, and Merkle proof. Consequently the credited miner fee has no collection path.
Frozen target
Competition repository:
https://github.com/immunefi-team/audit-comp-quantus-chain.gitRevision:
3b243b870a5442a6e5f443056f67bbba87d472f5Official CLI evidence:
https://github.com/Quantus-Network/quantus-cli.gitatd9e985d217f00fe8aaeb668e89136da58e75a305
Root cause
The successful exit-principal branch credits each recipient and immediately calls record_transfer, which advances the recipient transfer count and inserts a leaf (pallets/wormhole/src/lib.rs:818-859).
The miner-fee branch is instead at pallets/wormhole/src/lib.rs:944-983. It calls Unbalanced::increase_balance at :955-959, emits MinerVolumeFeePaid at :961-964, and never calls record_transfer. increase_balance calls only event-free write_balance (frame/support/src/traits/tokens/fungible/regular.rs:205-233), while Balances::Minted is emitted only by the separate Mutate::done_mint_into hook (pallets/balances/src/impl_fungible.rs:157-194). The runtime scanner therefore has no event from which it could create a leaf. This leaves the miner's free balance higher while its Wormhole transfer count and proof tree remain unchanged.
The beneficiary is not an ordinary signable account:
the miner account is
Poseidon(inner_digest)from the QPoW pre-runtime digest (primitives/wormhole/src/lib.rs:97-148);runtime signatures support only Dilithium87 and Dilithium65 (
primitives/dilithium-crypto/src/types.rs:71-98);signature verification requires
hash_bytes(embedded_public_key) == signed_origin(primitives/dilithium-crypto/src/traits.rs:152-199); andthe runtime describes the corresponding address as keyless and says it can never sign (
runtime/src/genesis_config_presets.rs:301-303).
Why the official collection path cannot recover the credit
The official CLI documents wormhole collect-rewards as the miner withdrawal path. Its current implementation:
queries indexed Wormhole transfer rows and returns zero withdrawals if the query is empty (
quantus-cli/src/collect_rewards_lib.rs:241-265);requires each transfer's
leaf_indexand fetches a ZK Merkle proof (:382-406);builds proof inputs from the leaf amount and transfer count, not the account's free balance (
:404-442); andobtains the leaf index and transfer count from the indexer's
transfertable (quantus-cli/src/subsquid/client.rs:102-134).
MinerVolumeFeePaid supplies none of these proof inputs. Indexing its amount into a free-balance view cannot manufacture a transfer row, leaf, or Merkle path.
Reproduction
Output:
Interpretation:
affectedexecutes the real unsigned private-batch entrypoint. The miner receives5,000,000,000planck in free balance, but its transfer count stays zero. The only new leaf is the exit-principal leaf.later_reward_controlmints and records a normal10,000,000,000-planck mining reward. Its leaf commits exactly that reward; it does not sweep the earlier missing fee despite the total balance increasing by15,000,000,000.negative_controlshows the necessary repair boundary: two minimum5,000,000,000-planck fee shares must accumulate to one10,000,000,000-planck Wormhole quantum before a useful leaf can be recorded.
The unified diff included in the form's PoC field changes only the test file. It uses a real fixture proof and the production verify_private_batch -> process_exit_bundle path. The block-author digest and soundness-pool state are normal test-fixture setup.
Impact
Runtime parameters set a four-basis-point volume fee with a one-quantum (0.01 QUAN) minimum and split it 50/50 between burning and the miner (runtime/src/configs/mod.rs:725-752). Therefore the minimum affected miner share is 0.005 QUAN (5,000,000,000 planck). Each successful author fee credit accumulates in a keyless free balance but is absent from the leaf-driven withdrawal ledger. The loss repeats on affected Wormhole exits.
Public prior art and why this is a fix bypass
This report deliberately discloses the closest public material:
Eiger F-03 concerned deriving the wrong miner author and burning/misdirecting its fee.
Eiger F-05 concerned signed transfer-call variants not being recorded by the old transaction extension.
PR #619 / commit
60ec991publicly observed that the miner fee usedincrease_balancewithoutrecord_transfer, but its stated impact was indexer-versus-RPC balance drift. It added onlyMinerVolumeFeePaidplus an indexer free-balance credit and tested the event and balance.
PR #619's explanation also assumed indexers saw a bare Balances::Minted. Frozen FRAME semantics and the release regression show that assumption is false: this increase_balance emits no Balances::Minted at all. Thus the public fix repaired only the explicitly added off-chain event consumer while leaving the on-chain proof recorder with no recognizable event and no explicit fee-leaf call.
The frozen code includes those remediations. The surviving defect is that PR #619's representation still cannot be consumed by the official collection path: it creates no transfer row, leaf index, transfer count, or Merkle proof. Neither the PR nor the cited audit finding states that the correctly-derived fee remains uncollectible through collect-rewards, adds the required leaf, or tests withdrawal. This is the competition's stated “bypass of a public fix” case, while the public overlap remains fully disclosed for adjudication.
Recommended fix
Do not expose an unbacked free-balance representation to a keyless miner. Accumulate sub-quantum miner fees per beneficiary. When the accumulated amount reaches one or more full SCALE_DOWN_FACTOR quanta, mint only the full-quantum portion and atomically call the Wormhole proof recorder for that exact amount; retain the remainder in accumulator storage. Emit separate accrual and payout events so indexers cannot confuse pending fees with collectible balance.
Add regressions that assert:
every successful miner-fee balance increment has a matching transfer row and non-zero leaf;
minimum fees accumulate rather than quantizing into zero-value leaves;
collect-rewardsdiscovers and proves the payout;a later ordinary mining reward cannot hide an unmatched fee balance; and
public- and private-batch exits use the same invariant.
Proof of Concept
This is a local regression against the exact frozen competition revision. It changes only the existing Wormhole test module and uses the real fixture proof plus the production verify_private_batch -> process_exit_bundle path. No production code, verifier bypass, live network, or post-call storage mutation is used.
Run the matched test
Expected decisive output:
The affected path increases the miner's free balance while leaving its transfer count unchanged; the one new leaf belongs only to the exit principal. The later-reward control proves a normal leaf records only its explicit reward amount and cannot sweep the earlier fee. The negative control shows that two minimum fee shares must accumulate to one full Wormhole quantum and pass through record_transfer before the miner has useful proof material.
Was this helpful?