90235 bc high wormhole fee credits skip record transfer miner and aggregator volume fees are minted to keyless addresses with no zk tree leaf permanently freezing them
Submitted on Aug 23rd 2026 at 22:30:18 UTC by @emp_mrpl for Audit Comp | Quantus
Report ID: #90235
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)
Permanent freezing of funds
Description
Summary
On every wormhole exit, settle_batch credits three parties. The exit recipient's credit is paired with record_transfer, which inserts a leaf into the zk-tree. The miner's volume-fee share and the aggregator's rebate are not — they are minted with increase_balance alone.
The withdrawal circuit can only ever spend value that is committed as a leaf under ZkTree::Root. A balance with no leaf therefore has zero exit capacity. Because both fee recipients are keyless addresses (derived by Poseidon2 re-hash, no signing key exists), the credited value can be moved neither by signature nor by exit. It is permanently frozen.
Affected code (pinned commit 3b243b870a5442a6e5f443056f67bbba87d472f5)
Credit
Site
record_transfer?
Leaf?
Spendable?
Exit recipient
pallets/wormhole/src/lib.rs:854 → :880
yes
yes
yes
Miner volume fee
pallets/wormhole/src/lib.rs:981
no
no
never
Aggregator rebate
pallets/wormhole/src/lib.rs:945
no
no
never
The miner address comes from qp_wormhole::derive_wormhole_address (primitives/wormhole/src/lib.rs:109), a Poseidon2 re-hash of the PoW pre-runtime digest. No private key produces it.
Impact
Fires on 100% of exits carrying a non-zero fee, deterministically, on every honest node. No attacker, no crafted input, no privileged actor.
Private exit: 5,000,000,000 planck frozen. Public exit: 7,500,000,000 planck (miner 5e9 + aggregator 2.5e9).
The victims are the block author and the aggregator, who never receive a spendable form of the fee they earned.
No recovery path. A forward fix cannot retroactively insert leaves for fees already credited in historical blocks; those credits remain uncommitted under every historical
ZkTree::Root.
Why this is "Permanent freezing of funds" and not "unclaimed yield"
Unclaimed yield is income not yet distributed. Here the distribution demonstrably ran: the runtime executes increase_balance(&author, miner_fee, Exact) at :981, emits Event::MinerVolumeFeePaid at :987, and the author's free balance rises from 0 to 5,000,000,000 (measured, both in a source-exact test and in live committed state at block 22). The value is already funds on a balance. A balance that can never be spent maps to "Permanent freezing of funds", and since the fix requires a runtime upgrade that does not restore historical credits, also to "Permanent freezing of funds (fix requires hardfork)".
Not a duplicate of prior audit findings
Eiger EQ-QNT-WORMHOLE-F-03 ("Miner Rewards Silently Burned Due to Incorrect Preimage Handling") is the direct ancestor. Pre-fix, the fee was minted with no record_transfer and the preimage was decoded directly, so a decode failure burned the fee. Eiger's remediation — deriving the address via Poseidon2 — made the fee land at a real keyless address, still with no leaf. The F-03 fix converted a silent burn into a silent freeze. This is a fix-introduced defect, not a duplicate: F-03 was wrong-address-to-burn; this is correct-address-but-no-leaf-to-freeze.
EQ-QNT-WORMHOLE-R-08 shows that at audit time (2026-03-20) record_transfer did not insert a zk-tree leaf at all. The leaf mechanism and the exit's dependence on it are a post-audit change that never wired the fee credit sites.
Not covered by the program's known issues
PROGRAM known issue #3 waives mining-reward emission dropping below the quantized minimum. This finding is on a different pallet and call path (settle_batch volume-fee distribution, not pallet-mining-rewards emission), and its stated mitigation (a tree-depth runtime upgrade) does not touch :945/:981.
The same keyless address demonstrates the contrast directly: its mining rewards DO get committing leaves (29 of them, verified by direct oracle query), while its volume fee gets none.
Investigated and explicitly NOT claimed
Per-exit, Σ(free balances) rises while TotalIssuance stays flat, which looked like a candidate unauthorized-mint. It is not, and I am not claiming it:
The deposit locks the principal on a keyless vault (measured live:
0 → 2e13), so the exit mint is a replacement of value already removed from circulation — textbook EIP-7503 lock-and-mint.The exit circuit binds the exited leaf's recipient to
H(H("wormhole" ‖ secret))(unspendable_account.rsCircuitFragment::circuit;circuit.rsconnect_shared_targets). A leaf whose recipient is a signature-spendable account is not exitable without a Poseidon2 preimage break.
Both are documented in full in sections 10 and 11 of 01_full_report.md in the linked gist.
Environment
All testing on a local --dev QPoW node only. No mainnet or public testnet was contacted, per program rules.
Link to Proof of Concept
https://gist.github.com/IbrahimMrpl/a6a0ee14d3e9923ec14bba1dceb0a412
Proof of Concept
The exploit path is entirely shipped code — no shim, no rebuild, no feature flag.
Part A — live accepted exit on a real permissionless deposit
Node: local quantus-node --dev, port 9945, specVersion 143, pinned commit 3b243b870a5442a6e5f443056f67bbba87d472f5.
Deposit (permissionless, shipped path)
Pick a secret s, derive U = UnspendableAccount::from_secret(s).
Submit a plain signed Balances::transfer_allow_death(U, 20_000_000_000_000) from a funded Dilithium account.
Deposit tx:
0xa400cc6c…d762f95The shipped runtime tx-extension
WormholeProofRecorderExtension(runtime/src/transaction_extensions.rs:339Transfer match →:389→pallet-wormhole record_transfer_proof :1367→record_transfer :1393) records committed leaf #35,transfer_count = 0,leaf_hash = 0x2c845015…5bb1.The transfer itself locks the principal:
free(U)goes0 → 20,000,000,000,000.
There is no dedicated deposit extrinsic; a plain signed transfer to an unspendable address IS the deposit mechanism.
Generate a proof bound to live chain state
Read live header N = 16 (parentHash, stateRoot, extrinsicsRoot, zkTreeRoot, digest) and the real Merkle siblings/positions of leaf #35 (depth 3).
Run qproof --mode gen-private with QP_LIVE=1.
The circuit's computed
block_hash = 0xd3184e30…8933chain_getBlockHash(16)returns the identical value → the proof is provably bound to committed chain state, not a synthetic block.
Read committed state (blocks 21 → 22)
Decoded wormhole events of block 22:
NativeTransferred
exit recipient 0x0404…0404
19,980,000,000,000
yes
ProofVerified
—
exit_amount 19,980,000,000,000
—
MinerVolumeFeePaid
keyless author 0x8b3c…272c
5,000,000,000
NO
NativeTransferred
author 0x8b3c…272c (mining reward)
677,940,125,224
yes (separate pallet)
Committed storage (hashed into the state root):
ZkTree::LeafCount: 49 → 52 (+3) = 1 exit recipient (record_transfer) + 2 mining-reward leaves. The keyless miner-fee credit added 0 leaves.ZkTree::Root:0x5423be46…fef1→0xa10fe444…acf87Author
0x8b3c…272cfree balance: 14,249,714,740,921 → 14,932,654,866,145 (+682,940,125,224), which decomposes exactly as5,000,000,000(keyless volume fee, no leaf)+ 677,940,125,224(mining reward, which does get its own leaf).
Part B — the spendability oracle (direct query, not inference)
Oracle = ZkTreeApi::get_merkle_proof (runtime/src/apis.rs:184), folded through the vendor Poseidon2 pallet_zk_tree::tree::hash_node. This is Quantus's own code, not written for this PoC.
Scan the live tree for the keyless author 0x8b3c…272c:
29 leaves commit to that address — all mining rewards (amounts ~677,9xx,xxx,xxx, each distinct).
0 leaves commit the
5,000,000,000volume-fee value.Root spot-check:
proof.root == committed ZkTree::Root, PASS.Negative control: 1-bit flip of a true leaf hash → recomputed root differs → REJECTED.
Same address, same block. One credit type gets a committing leaf, the other does not. No get_merkle_proof can ever return a path to the fee value, so no spend proof for it can be constructed.
Part C — source-exact differential (one-command reproduction)
Miner half — driving the committed fixture bundle through the real verify_private_batch:
Aggregator half — through the real verify_public_batch → settle_batch:
In both arms the leaf delta equals the exit-recipient count only. The single difference between the exit credit and the fee credits is the record_transfer call.
Negative control on the same machinery: the settlement path's storage_tail prices and records the same insert correctly, so the divergence is a property of the two flagged sites, not of the measurement.
Fix
Pair record_transfer with the fee credits at pallets/wormhole/src/lib.rs:945 (aggregator rebate) and :981 (miner volume fee), as is already done for the exit credit at :854 → :880.
Note that this does not recover fees already credited in historical blocks — those remain uncommitted under every historical ZkTree::Root.
Was this helpful?