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

89733 bc high wormhole miner volume fee is credited to a keyless account without a zk tree leaf permanently freezing it

Submitted on Aug 20th 2026 at 20:31:52 UTC by @santin0xyz for Audit Comp | Quantus

  • Report ID: #89733

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Permanent freezing of unclaimed yield

Description

Summary

Every wormhole exit settles a volume fee. Half is burned; the other half is credited to the QPoW block author (MinerVolumeFeePaid). The author's account is, by construction, a keyless Poseidon output, so its only spending path is a wormhole ZK exit — which spends a specific ZK-tree leaf. process_exit_bundle credits the fee with Unbalanced::increase_balance and never records that leaf, so the value lands in a balance that can never be moved again through any non-privileged path.

pallet-mining-rewards pays the same account in the same block and does record its leaf. The two pallets disagree on whether a credit to a keyless account needs one.

Why the author account cannot be signable

qp_wormhole::extract_author_from_digest (primitives/wormhole/src/lib.rs:122) takes the miner's 32-byte pre-runtime preimage and always returns derive_wormhole_address(preimage) = rehash_to_bytes(...), i.e. Poseidon over 4 felts. There is no code path that pays a miner-chosen raw address.

A signable account on this chain is hash_bytes(dilithium_pubkey) — Poseidon over the felt encoding of a 1952/2592-byte ML-DSA key (primitives/dilithium-crypto/src/scheme_macro.rs:239-244). Landing a Poseidon-of-4-felts output on a signable account would require a Poseidon preimage attack, so the payout address is keyless by construction.

For such an account the only spend is the ZK exit, and the circuit spends one concrete leaf (to, transfer_count, asset_id, amount) under (out_1 + out_2) · 10000 ≤ input_amount · (10000 − fee_bps), where input_amount is bound into the leaf hash (qp-wormhole-circuit, ZkLeafTargets::collect_for_hash) and the leaf must reproduce header.zk_tree_root. The account's free balance never enters the circuit. A credit with no leaf is not ZK-spendable — the runtime states this itself when documenting the known Root force_transfer gap in runtime/src/transaction_extensions.rs.

The asymmetry

pallets/mining-rewards/src/lib.rs:229 — correct:

pallets/wormhole/src/lib.rs:955 — the bug:

The comment at pallets/wormhole/src/lib.rs:822 justifies the event-free primitive for the user exit, which records its own leaf 26 lines later (:854). The same primitive was carried to the miner-fee credit, which has no leaf of its own.

No fallback covers it: this arm never calls record_transfer, and increase_balance emits no Minted/Transfer event, so even if a submission went through WormholeProofRecorderExtension the scanner would not pick the credit up. The intended admission path is a bare unsigned extrinsic (ValidateUnsigned), for which that extension's event scan does not run.

Proof of Concept

Three tests in a single self-contained file, poc.rs (attached). It borrows nothing from the existing test module, so installing it takes two steps and no patch:

1

Save the attached file

Save the attached file as pallets/wormhole/src/poc.rs.

2

Add the test module

Add two lines to pallets/wormhole/src/lib.rs, next to the existing mod tests; (line 164):

#[cfg(test)] must sit on mod poc itself — it does not inherit from mod tests. With that attribute the module compiles out of every non-test build, so the behaviour under test is the unmodified frozen commit. git diff after installing shows exactly 1 file changed, 2 insertions(+).

Full pallet suite with the module installed: 71 passed; 0 failed; 3 ignored.

The evidence in all three is TransferCount: record_transfer bumps it on every leaf insert and is its only writer, so a count that does not move proves no leaf was created for the credit.

What each test proves. poc1_whole_quantum_fee_is_credited_without_a_leaf is the one that isolates the cause. hash_leaf commits amount / AMOUNT_SCALE_DOWN_FACTOR, so a sub-quantum credit would be unrepresentable in a leaf even if one were recorded; this test picks an exit volume whose author share is exactly one whole quantum, which rules quantization out. The credit is representable, a leaf for it would commit amount 1, and it is still frozen.

poc2_real_proof_fee_survives_an_existing_reward_leaf runs the same on the checked-in proof, starting from the mainnet precondition that the author already holds a block-reward leaf, and shows that leaf does not cover the fee; it also carries the control that every exit the bundle minted did receive its own leaf.

poc3_odd_fee_splits_below_leaf_resolution is why fix step 1 exists.

Neither of the first two attempts an exit and observes it fail, because it is not needed: there is no circuit path of the form "I know the secret, therefore I may take this account's free balance". The absence of a leaf covering the credit is the whole proof.

Impact

Every wormhole exit permanently strands the block author's entire share of the volume fee — with the mainnet configuration (runtime/src/configs/mod.rs:734: VolumeFeeRateBps = 4, VolumeFeesBurnRate = 50%), 50% of every fee collected, on every settlement, forever. The settled fee is ceil(exit_quanta · bps / (10000 − bps)) quanta, so the effective rate is 10 000 bps on a 1-quantum exit, 400 bps at 25 quanta, and only approaches the nominal 4 bps at volume (4.01 bps at 10^6 quanta). Half of whatever is collected is stranded.

This is not depositor principal — the user already received output and the fee was deducted in-circuit. It is the author's compensation, i.e. unclaimed yield: credited, announced by an event, inert forever.

Second-order, not the primary claim: settlement extrinsics are Pays::No and impose a full plonky2 verification on the author in ValidateUnsigned::pre_dispatch. The volume fee is the only compensation, so making it unspendable removes the reason to include wormhole settlements at all. That does not stop the network; it can stop the wormhole.

Severity rationale

High — Permanent freezing of unclaimed yield. It is protocol yield, not user principal, and Balances::force_transfer is Root, so governance could recover stranded fees account-by-account: impracticable at scale, but it exists, so the Critical qualifier "fix requires hardfork" does not apply.

Fix

Two changes; the first alone is not sufficient.

1

Keep the author's share on a quantum boundary

At :890, floor the share and hold the remainder aside:

At :937, after the aggregator-rebate block at :894 has taken its share, fold the remainder into the burn:

Do not merge the blocks, and do not recompute burn_amount_u128 as total_fee_u128 - miner_fee_u128. The rebate is VolumeFeesAggregatorRate × burn_amount_u128 and subtracts itself from that bucket at :919, so folding the remainder in beforehand hands the aggregator a share of it (measured: 5e9 instead of 2.5e9 planck, breaking process_exit_bundle_fee_and_rebate_exclude_denied_segment and test_verify_public_batch_succeeds_and_pays_aggregator), and recomputing afterwards burns the rebate twice. The existing if !miner_fee.is_zero() guard covers the floor case, where the share rounds to zero and the whole fee is burned.

Small exits then burn the whole fee instead of paying something unspendable — better than the status quo, but a policy change. To keep the revenue, accrue the share in storage and settle it once it reaches a quantum.

2

Record the leaf

Keep increase_balance: the backing value already exists in the locked deposit, so mint_into would inflate TotalIssuance on top of value already counted.

mint_account is already in scope, and this arm sits inside if !miner_fee.is_zero(), so change 1's zero case never reaches it. Follow-ups: re-benchmark both settlement calls for the extra record_transfer (one TransferCount read/write plus a flat-priced leaf insert per pallet_zk_tree::INSERT_LEAF_DB_OPS), and pin the leaf in test_verify_private_batch_emits_miner_volume_fee_paid, which today asserts only the balance delta — precisely why this survived review.

Proof of Concept

Was this helpful?