88223 bc high miner volume fee and aggregator rebate are credited to keyless wormhole addresses with no zk tree leaf permanently freezing them incomplete fix of eq qnt wormhole f 03
Submitted on Aug 12th 2026 at 09:55:57 UTC by @Valisthea for Audit Comp | Quantus
Report ID: #88223
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of funds (fix requires hardfork)
Description
Brief/Intro
On every wormhole exit, the block author's share of the volume fee is credited with Unbalanced::increase_balance to an address derived as derive_wormhole_address(preimage). No ML-DSA keypair maps to that address, so it is spendable only through a wormhole ZK exit, and a wormhole exit is bounded by a zk-tree leaf. Unlike every other credit path in the codebase, the miner-fee credit never calls record_transfer, so no leaf is ever created for it. The miner therefore receives a balance it can neither sign for nor prove, and the value is permanently frozen. The public-batch aggregator rebate has the identical omission. This is the uncovered half of EQ-QNT-WORMHOLE-F-03: that finding was fixed for address extraction only, while the leaf recording it also depended on was not carried over.
Vulnerability Details
The author address has no signing key
extract_author_from_digest takes the miner-supplied 32-byte pre-runtime preimage and returns the rehashed wormhole address:
// primitives/wormhole/src/lib.rs:146-148
let preimage: [u8; 32] = data.as_slice().try_into().ok()?;
let address_bytes = derive_wormhole_address(preimage).ok()?;
return AccountId::decode(&mut &address_bytes[..]).ok();documented at primitives/wormhole/src/lib.rs:100-102 as "The wormhole address is: address = hash(hash(salt + secret))".
A signable account, by contrast, is Poseidon over the ML-DSA public key:
No ML-DSA keypair produces a rehash_to_bytes image, so the author address can never be spent by signature. Its only spend path is a wormhole ZK exit.
A wormhole exit requires a zk-tree leaf
Only Pallet::record_transfer creates one, and it always bumps the recipient's transfer count:
The circuit binds the exitable amount to that leaf: R_leaf requires rootHash = computeRoot(leafHash(toAccount, transferCount, assetId, inputAmount), levels) and bounds the outputs by (out1 + out2) * 10000 <= inputAmount * (10000 - volumeFeeBps) (formal/WormholeSpec/Leaf.lean:89-90, 152-157 in qp-zk-circuits). No leaf means no provable inputAmount, so nothing can be exited.
The developers state the consequence themselves in record_transfer's own comment, warning that a leaf-keying mistake would leave a deposit "permanently unexitable" (pallets/wormhole/src/lib.rs:1286-1293).
The asymmetry
The exit loop credits and records:
pallet-mining-rewards credits the same class of address and also records:
The miner-fee credit does neither:
and neither does the public-batch aggregator rebate at pallets/wormhole/src/lib.rs:919-933.
An exhaustive grep of the pallet confirms there is no rescue path: record_transfer is called only at lib.rs:486, lib.rs:854 (the exit loop), and lib.rs:1367 / lib.rs:1386 (inside the record_transfer_proof trait impl, which only mining-rewards consumes). Nothing sweeps or backfills these credits.
Why the existing fix did not catch it
EQ-QNT-WORMHOLE-F-03 (Eiger, 2026-03-20, "Miner Rewards Silently Burned Due to Incorrect Preimage Handling") reported three problems: direct decode instead of hashing, no engine ID filtering, and silent failure. All three are fixed in the current code. That report's own "Correct Implementation" section points at pallet-mining-rewards as the model to follow. The fix adopted that pallet's extraction logic but not its record_transfer_proof call, so the failure mode moved from "the fee never reaches the miner" to "the fee reaches an address the miner cannot spend from".
The regression test added alongside the fix asserts only that the balance arrived:
It never checks that a leaf exists, which is why the remaining half went unnoticed.
Impact Details
Selected impact: Permanent freezing of unclaimed yield.
The block producer's share of the wormhole volume fee is permanently frozen on every single exit. It is credited as free balance to an address with no private key and no exitable zk-tree leaf, so it can never be moved by signature and can never be withdrawn by proof. Recovery requires a runtime upgrade that re-issues the credits.
Quantification. With VolumeFeeRateBps = 4 and VolumeFeesBurnRate = Permill::from_percent(50) (runtime/src/configs/mod.rs:734-738), the miner share is 2 bps of all wormhole exit volume. The loss is not a one-off: it accrues on every exit, forever, and grows with adoption of the wormhole. The aggregator rebate leg is frozen the same way, and PublicBatchPublicInputs::aggregator_address is documented as a hash-derived account (qp-zk-circuits/wormhole/inputs/src/lib.rs:269-270), so an aggregator following the intended privacy pattern loses its rebate identically.
No attacker is required. This is deterministic and unavoidable: it fires on every successful exit whose bundle produces a non-zero miner fee, and the circuit's ceiling rounding guarantees at least one full quantum of fee even on the smallest exit. The miner cannot opt out by choosing a key-holding account, because the same digest field is what routes their mining rewards to a wormhole address.
Honest bounding, stated rather than omitted: no user principal is touched, and the per-exit amount is 2 bps, so a conservative grader could argue Medium. What resists that is permanence and unbounded accrual: there is no in-protocol path to recover any of it, and the total grows without limit as wormhole volume grows.
References
Miner fee credited without a leaf:
pallets/wormhole/src/lib.rs:955-965Aggregator rebate, same omission:
pallets/wormhole/src/lib.rs:919-933Exit loop, the correct pattern:
pallets/wormhole/src/lib.rs:828-859record_transfer, the only leaf creator:pallets/wormhole/src/lib.rs:1280-1310Developers' own "permanently unexitable" warning:
pallets/wormhole/src/lib.rs:1286-1293mining-rewards, the model the auditors cited:pallets/mining-rewards/src/lib.rs:230-237Author address derivation:
primitives/wormhole/src/lib.rs:109-111, 122-150Signable account derivation, for contrast:
primitives/dilithium-crypto/src/scheme_macro.rs:241-245Fee parameters:
runtime/src/configs/mod.rs:734-738The blind regression test:
pallets/wormhole/src/tests.rs:1086-1090Prior finding this incompletely fixes:
EQ-QNT-WORMHOLE-F-03, Eiger review 2026-03-20,chain/audits/
Link to Proof of Concept
https://gist.github.com/Valisthea/0b093e3e3a7754bc64181d7add5cdee7
Proof of Concept
One runnable test, passing against the in-scope asset at commit 3b243b87. The full existing wormhole suite still passes alongside it (69 passed, 0 failed, 3 pre-existing ignored), so nothing here is achieved by altering the project's own tests.
Setup
Step by step
Assert no leaf exists for it
TransferCount[author] is still 0. Because record_transfer unconditionally increments the recipient's transfer count (lib.rs:1295-1301), a count of zero is a direct on-chain witness that record_transfer was never called for the author. The credited fee can never be exited, and the address has no signing key, so the value is frozen.
Recommended fix
One call at each site, mirroring the exit loop at lib.rs:851-859:
and the analogous call for aggregator_fee at lib.rs:919-933.
A regression test for either site should assert the leaf, not just the balance: TransferCount[recipient] must increase, and ZkTree::leaf_count() must grow by one more than the number of exits. A storage migration is also needed to re-issue fees already credited without leaves, since those balances are unreachable by any in-protocol path.
Was this helpful?