For the complete documentation index, see llms.txt. This page is also available as Markdown.

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::TransferOnHold and Balances::ReserveRepatriated events (chain/runtime/src/transaction_extensions.rs:339-372).

  • Unbalanced::increase_balance resolves to write_balance, which emits no event. Event::Minted is emitted only from done_mint_into — the Mutate::mint_into path — at chain/pallets/balances/src/impl_fungible.rs:192-193. increase_balance does not go through it. This is exactly why mining-rewards, which does use mint_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 by record_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 allow record_transfer to canonicalize recipients (reduce each 8-byte limb mod the Goldilocks prime) before keying TransferCount and 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

1

Clone the repository at the frozen competition commit

2

Add the test

Open pallets/wormhole/src/tests.rs and paste the test from the next section inside mod private_batch_proof_tests { … } — for example immediately after that module's opening brace (line 568). This is the only file edited; no production file is modified.

3

Run the test

Build note. pallets/wormhole/build.rs generates the ZK circuit binaries at compile time and the first build takes roughly 12 minutes. Leave the circuit-size knobs at their defaults — do not set QP_NUM_LEAF_PROOFS / QP_NUM_PRIVATE_BATCH_PROOFS to smaller values to speed the build up, because the checked-in proof fixtures are generated at the defaults (7 leaves / 53 private batches) and will fail to deserialize against a smaller circuit. Toolchain: the repo's pinned rust-toolchain (1.93.0).

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

1

Derive and inspect the block author

The block author is derived exactly as production derives it — from a QPoW pre-runtime digest preimage through qp_wormhole::derive_wormhole_address — and starts with zero ZK-tree leaves.

2

Execute the real extrinsic

The real extrinsic Wormhole::verify_private_batch succeeds against the repository's own proof fixture. Nothing is stubbed.

3

Confirm the credit occurred

The credit really happened: the author's free balance strictly increases. Without this the rest would be vacuous.

4

Confirm the leaf mechanism is active

Control — the leaf mechanism is live in this very call: the exit account's TransferCount strictly increases in the same extrinsic. record_transfer is reachable, wired, and working here.

5

Confirm the missing author leaf

The bug: the author's TransferCount is still 0. Real value was credited to a key-less address with no leaf, so no nullifier path exists that could ever prove spending it.

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 MinerVolumeFeePaid reports otherwise, so the failure is silent to operators and to any accounting built on chain events.

  • TotalIssuance permanently includes tokens that are provably unspendable, so circulating-supply accounting drifts from reality with every exit, and the supply-driven block-reward calculation in mining-rewards::on_finalize is computed against an inflated total_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

Candidate mitigation
Why it fails

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 decode of the preimage, the missing POW_ENGINE_ID filter, 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 upstream Quantus-Network/chain HEAD (c3f7f3b8), ten commits past the frozen competition commit: pallets/wormhole/src/lib.rs is untouched and the Ok(_) arm at lines 959-965 still goes straight from increase_balance to deposit_event with no record_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.

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.

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?