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

88571 bc high missing zk tree leaf on the wormhole exit miner fee credit permanently freezes all miner exit fee revenue keyless account no exit path

Submitted on Aug 13th 2026 at 21:31:17 UTC by @OxPrince for Audit Comp | Quantus

  • Report ID: #88571

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of unclaimed yield

Description

Brief/Intro

On Quantus, block authors (miners) are paid at a wormhole address — a keyless address derived as hash(hash(salt + secret)). Such an address has no private key, so it can never sign a transaction; the only way to spend value held there is a wormhole exit, which requires a ZK-tree leaf committing the amount. Every legitimate credit to a keyless address is therefore paired with a leaf (block rewards, ordinary transfers, and deposits all do this).

When a wormhole exit is processed, it charges a volume fee and pays a share to the block author. That payment is minted to the author's keyless address with an event-free increase_balance and no leaf-recording call, so no leaf is ever created for it. The miner thus accrues exit-fee revenue that can never be exited and never spent — it is permanently frozen. Under the production configuration this occurs on every wormhole exit, for the life of the chain. On mainnet this silently strands the miners' entire exit-fee income stream: the value is minted into existence at an address from which it can never be moved.

Relationship to EQ-QNT-WORMHOLE-F-03 (prior audit)

The Eiger audit's medium finding EQ-QNT-WORMHOLE-F-03 already identified that miners were not receiving their volume fees: author extraction decoded the preimage directly (wrong), so extraction failed and the fee was burned. The remediation corrected the derivation (extract_author_from_digest → derive_wormhole_address), so the fee now reaches the miner's address. This report shows that remediation is incomplete: the address it now reaches is keyless, and no leaf is created, so the fee is frozen rather than burned — the audit's root complaint ("miners don't get their fees") is still not resolved. Notably, the audit cited mining-rewards as the "correct implementation" for the derivation; that same pallet also records a leaf, which the wormhole fix did not carry over. This report should be submitted as an incomplete fix of F-03, not as a novel discovery, and its acceptance turns on whether the program treats an incomplete remediation of a prior finding as in scope.

Vulnerability Details

Background: why a keyless credit needs a leaf

The wormhole implements privacy-preserving transfers using "unspendable" (keyless) addresses. A miner's reward address is derived from a secret preimage carried in the QPoW pre-runtime digest:

primitives/wormhole/src/lib.rs:100-151:

Because the address is a hash, no key can sign for it. Funds there are realized only via a wormhole exit, which proves knowledge of secret, nullifies a leaf, and mints the amount to a fresh address. If no leaf exists for a credit, no exit proof can ever be constructed for it, so the funds are unreachable.

The correct pattern (positive control): block rewards record a leaf

The mining-rewards pallet mints the block reward to the same keyless author and records a leaf, which is exactly what makes rewards spendable:

pallets/mining-rewards/src/lib.rs:230-237:

The exit path likewise records a leaf for each exit-account credit, immediately after an event-free mint:

pallets/wormhole/src/lib.rs:828-859:

The bug: the miner-fee credit mints without recording a leaf

A few lines later in the same function, the miner-fee credit mints the same way but stops there — no record_transfer:

pallets/wormhole/src/lib.rs:944-965:

The event-free mint is intentional — emitting Balances::Minted would make the runtime's proof-recorder scan double-count the credit (see the note at lib.rs:822-827). That is correct for the scan, but it makes the explicit record_transfer the only way a leaf can be created for this credit — and it is missing. There is no other leaf source: leaves are created only inside record_transfer, and once at genesis in on_initialize for block 1 (lib.rs:468-496). A credit that misses its leaf at credit time is unrecoverable.

The aggregator-rebate credit (lib.rs:919-934) has the identical omission and freezes the same way whenever the aggregator address is a wormhole address.

It fires on every exit under production config

runtime/src/configs/mod.rs:734-738: VolumeFeeRateBps = 4 (0.04 %), VolumeFeesBurnRate = 50 %. The miner therefore receives 50 % × 0.04 % = 0.02 % of every exit's volume, minted to a keyless address with no leaf. Every wormhole exit permanently freezes that miner fee.

The existing test checks balance and event, but never a leaf

The pallet's own test asserts the fee lands in the author's raw balance and emits MinerVolumeFeePaid, but never checks whether a leaf was created — so the gap is unpinned:

pallets/wormhole/src/tests.rs:1036-1092:

Impact Details

On every wormhole exit, the miner's share of the volume fee (0.02 % of exit volume under production config) is credited to the miner's keyless address without the leaf that is the sole means of ever spending it. The value is minted into existence but stranded: no user action can free it, because the address cannot sign and no exit proof can be built without a leaf. Over the chain's life this is a continuous, unbounded, irrecoverable loss of the miners' legitimately-earned exit-processing revenue. Recovery requires a runtime change (e.g. a migration minting leaves for the stranded balances); it cannot be fixed by any user.

Magnitude and likelihood. No attacker, no cost, no privileged access, no special preconditions — it is a standing protocol defect that strands value on every exit. Per-exit the amount is small (0.02 % of that exit's volume); in aggregate it is the entire miner-fee stream from the wormhole. Impact scales with wormhole exit volume, which on a privacy-first chain is the primary usage.

In process_exit_bundle, after a successful miner-fee mint, record the leaf exactly as the exit-account credit does at lib.rs:854:

Apply the same to the aggregator-rebate credit (lib.rs:919-934). Add regression tests asserting a leaf (and a TransferCount advance) for both credits, mirroring the exit-account leaf assertions; the PoC in this report inverts trivially into that regression test. Keep the fee output quantum-aligned (multiple of SCALE_DOWN_FACTOR) so no sub-quantum remainder is stranded.

References

Vulnerable / relevant code

  • pallets/wormhole/src/lib.rs — miner-fee credit with no leaf (944-965), aggregator rebate (919-934), exit-account credit + leaf (828-859), event-free-mint rationale (822-827), genesis-only leaf snapshot (468-496)

  • pallets/mining-rewards/src/lib.rs:230-237 — block reward records a leaf (positive control)

  • primitives/wormhole/src/lib.rs:100-151 — keyless wormhole-address derivation + author extraction

  • runtime/src/configs/mod.rs:734-738 — volume-fee config (miner fee non-zero on every exit)

Proof of Concept

1

Add the test

This is a unit test that must live inside the wormhole pallet's test module, because it uses the pallet's mock (crate::mock::*), the real proof fixture (deserialize_test_proof / get_test_proof_bytes), and internal storage (crate::TransferCount).

Paste the #[test] fn poc_miner_volume_fee_has_no_leaf_and_is_permanently_frozen function below into pallets/wormhole/src/tests.rs, inside the existing mod private_batch_proof_tests { ... } block (e.g. right after test_verify_private_batch_emits_miner_volume_fee_paid). All imports it needs are already in scope there.

Alternatively, apply the companion patch:

2

Run the test

The first build generates the ZK circuit binaries, ~8-9 min, then they are cached and reruns take ~20 s:

3

Verify the expected output

After the fix (add Self::record_transfer(..) for the miner-fee credit), assertion (2) flips: the tree grows by num_exit_accounts + 1 and TransferCount(author) becomes 1 — i.e. this test then FAILS, which is the regression guard the fix should ship with (invert the two assertions).

Was this helpful?