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

88872 bc high missing record transfer on the miner volume fee credit permanently freezes unclaimed miner yield on a non ml dsa signable qpow wormhole author

Submitted on Aug 15th 2026 at 10:56:36 UTC by @n3310y for Audit Comp | Quantus

  • Report ID: #88872

  • 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

Omitting record_transfer() after crediting the miner volume fee with event-free Unbalanced::increase_balance() will cause permanent freezing of unclaimed yield for QPoW miners as every wormhole exit credits that fee to the Poseidon-derived, non-ML-DSA-signable block author, which has no matching ZK-tree leaf.

This is a vulnerability path in honest settlement, not a theft attack. No privileged origin is required. It fires on every successful private-batch (and public-batch) exit that pays a non-zero miner share.

Vulnerability Details

Quantus spends native value through a ZK Merkle tree, not through Dilithium signatures on the recipient. A credit is spendable via wormhole if and only if pallet-zk-tree stores a leaf (to, transfer_count, asset_id, amount) and TransferCount is advanced. record_transfer() is the function that inserts that leaf and emits NativeTransferred.

process_exit_bundle() is the single settlement function for both verify_private_batch() and verify_public_batch(). For each successful user exit it does:

The comment immediately above that increase_balance() explains why the mint must be event-free: the runtime WormholeProofRecorderExtension records a leaf for every Balances::Minted / Transfer event it scans. Using mint_into() here would double-record the exit. So the pallet takes on the duty of calling record_transfer() itself. User exits honour that duty.

After the exits, the pallet computes volume_fee_for_exit() at the configured 4 bps (quantized ceiling, matching the circuit), burns VolumeFeesBurnRate (50% on the live runtime), and credits the remainder to the block author:

increase_balance() with Precision::Exact writes author.free_balance += miner_fee. It does not emit Minted/Transfer, so the scanner cannot repair the miss. It does not bump total_issuance, so this is not an inflation bug. The only event is MinerVolumeFeePaid, which is not a leaf.

The author is not an address the miner controls with a Dilithium key. QPoW puts a 32-byte preimage in the pre-runtime digest; author extraction is:

derive_wormhole_address() is rehash_to_bytes(&inner_digest) — the same unspendable-account derivation used for wormhole deposits. Production Balances::transfer requires a Dilithium signature whose public-key hash equals the account id. No such key exists for a Poseidon(preimage) account. The pallet mock does not check Dilithium signatures, so a RawOrigin::Signed(miner) transfer in a unit test would falsely succeed and must not be used as a freeze proof.

The remaining spend path is a wormhole exit. The circuit commits to the leaf amount, not to AccountData.free_balance. After a normal block, mining-rewards has already inserted a leaf for the block reward only:

A later volume-fee credit raises free_balance but does not insert a second leaf and does not change the reward leaf's amount. A prover can exit the reward. They cannot exit reward + fee or fee alone, because those amounts are not in the tree. Transferring to the wormhole author creates a new leaf for the deposit, not a leaf that covers the already-credited fee.

The existing pallet test test_verify_private_batch_emits_miner_volume_fee_paid only asserts MinerVolumeFeePaid and the balance delta. It never checks TransferCount or the ZK tree, which is why this gap survived.

Live runtime parameters (mirrored in the pallet mock and in the committed private_batch.hex fixture):

  • VolumeFeeRateBps = 4

  • VolumeFeesBurnRate = 50% → miner share is half the volume fee (about 2 bps of exited value, with the circuit's one-quantum floor)

  • EXISTENTIAL_DEPOSIT = MILLI_UNIT (0.001 QUAN). A failed below-ED mint burns the fee instead of freezing it. After any mining-reward credit (the normal case), and for any fee at or above the 0.005 QUAN miner floor, increase_balance succeeds and the freeze applies.

Root Cause

In pallets/wormhole/src/lib.rs process_exit_bundle(), user exits are credited with increase_balance() and then record_transfer(). The miner volume-fee branch uses the same increase_balance() (so WormholeProofRecorderExtension will not see a Balances::Minted event) but never calls record_transfer() / record_transfer_proof().

That is a mistake because the QPoW author is not a Dilithium account. extract_author_from_digest() always sets the author to derive_wormhole_address(preimage) — a Poseidon wormhole address with no ML-DSA key. The only spend path for that account is a wormhole exit of a ZK leaf (recipient, transfer_count, asset, amount). Without a leaf, the credited fee cannot be exited, transferred, or otherwise claimed.

The mining-rewards pallet already does this correctly: mint_into(miner, reward) plus record_transfer_proof(None, mint_account, miner, reward). The volume-fee path is the inconsistent sibling.

This finding is only the miner volume-fee path. The public-batch aggregator rebate also uses increase_balance() without a leaf, but aggregator_address is chosen by the aggregator and can be a spendable Dilithium account. Do not fold that into this report.

Relationship to Previous Audits

The relevant prior report is the Eiger audit.

This finding is distinct from its EQ-QNT-WORMHOLE-F-03 and EQ-QNT-WORMHOLE-F-05 findings.

F-03 covered incorrect author extraction: the pallet decoded the QPoW preimage directly, so the miner fee went to the wrong address or was effectively burned. Its fix derives the correct Poseidon wormhole author.

This report starts after that fix succeeds. The new failure occurs after the correct author receives an event-free balance credit: the branch omits the explicit record_transfer() needed to create a spendable leaf.

F-05 covered ordinary transfer call variants missed by the old call-pattern recorder. Its fix scans emitted Balances::Transfer and Balances::Minted events and records leaves from those events.

The miner-fee credit is not one of those transfer variants. Unbalanced::increase_balance() emits neither scanned event, so it bypasses the event-scanner fix and requires an explicit recorder call.

The affected code path and root cause are therefore different. Under the competition's Code Update Assurance, this is a new bypass/omission, not the unpatched vulnerability disclosed in F-03 or F-05.

Vulnerability Path

1

A QPoW miner wins a block

The header digest contains PreRuntime(POW_ENGINE_ID, preimage). The runtime author is derive_wormhole_address(preimage).

2

Mining rewards create a spendable reward leaf

pallet-mining-rewards on finalize mints the block reward with mint_into and record_transfer_proof. The miner now has a spendable ZK leaf for the reward amount and TransferCount = 1.

3

A valid batch exit is submitted

A user (or aggregator) submits verify_private_batch / verify_public_batch with a valid proof. Pool admission (validate_unsigned) runs cheap checks; pre_dispatch runs full ZK verification; dispatch runs process_exit_bundle().

4

User exits are recorded

User exits are minted and each is recorded as a ZK leaf (NativeTransferred).

5

The miner fee is credited without a leaf

process_exit_bundle() computes the volume fee, burns 50%, and increase_balances the other 50% onto the wormhole author. It emits MinerVolumeFeePaid and returns. No leaf is inserted. Miner TransferCount is unchanged.

6

The balance and leaf diverge

The miner’s free_balance is now reward + fee. The only miner-owned leaf still commits amount = reward.

7

The fee cannot be claimed

The fee cannot be moved with a Dilithium balances.transfer (no key). It cannot be wormhole-exited (no leaf for that amount). Subsequent mining-reward leaves commit later rewards only. The fee remains on the account forever, until a runtime upgrade inserts the missing leaves or moves the balances.

Impact Details

The affected party is every QPoW miner (the protocol’s block authors). They cannot claim the wormhole volume-fee share the runtime already credited to them.

Selected in-scope impact: High — Permanent freezing of unclaimed yield.

Why this impact, and not a Critical one:

  • The frozen funds are miner protocol fees (unclaimed yield), not user funds at rest or in motion. Critical “direct theft of any user funds, other than unclaimed yield” does not apply, and nothing is stolen.

  • A runtime upgrade can insert the missing leaves or move the stranded balances. Critical “permanent freezing of funds (fix requires hardfork)” does not apply.

  • increase_balance() does not increase total_issuance. This is not an inflation / unauthorized-minting bug.

Loss size:

  • Per successful exit bundle: miner_fee = volume_fee - floor(volume_fee * burn_rate), with volume_fee = ceil(exit_quanta * 4 / 9996) * 10^10 planck.

  • On the committed fixture (19.98 QUAN exited) the PoC freezes 5_000_000_000 planck = 0.005 QUAN (half of the one-quantum minimum fee). Larger exits pay ~2 bps of exited value to the miner, all of which freeze the same way.

  • The loss is continuous for the life of the chain: every private-batch and public-batch exit that pays a miner share adds another frozen credit. There is no user action that recovers it.

  • USD value is not computed here: the competition snapshot does not pin a QUAN market price, and inventing one would misrepresent funds at risk. The frozen amount in native units is exact in the PoC log.

This is not the aggregator rebate, and it is not the mining-reward path.

References

Proof of Concept

This is Substrate / FRAME, not an EVM contract. There is no mainnet fork in Hardhat/Foundry. The program’s own build instructions are cargo test --release in the chain repo, and the pallet already ships a committed real private-batch proof (pallets/wormhole/test-data/private_batch.hex, generated with quantus wormhole multi round). The PoC is a full settlement-path test named poc_miner_volume_fee_credited_without_zk_leaf that:

  • uses that real ZK proof (not a mocked verifier);

  • seeds the QPoW pre-runtime digest the same way live authorship does;

  • runs validate_unsigned → pre_dispatch (full ZK verify) → verify_private_batch;

  • credits a mining-rewards-style leaf first as a positive control;

  • then shows the volume fee raising free_balance with no new miner leaf;

  • prints the frozen amount;

  • as a negative control, calls the missing record_transfer_proof() and shows the fee leaf appear.

It does not call Balances::transfer from RawOrigin::Signed(miner). That origin is not Dilithium-checked in the pallet mock and would be a false spend.

Nothing in this PoC talks to mainnet or a public testnet.

Reproduce

From the audit-comp-quantus-chain repo root (this workspace: codebases/chain), on commit 3b243b870a5442a6e5f443056f67bbba87d472f5 plus the PoC test added below:

Requirements: Rust toolchain able to build this workspace (see the chain README), no extra env vars, no network. The first --release build of pallet-wormhole regenerates ZK verifier artifacts via build.rs (~20s) and then compiles. Subsequent runs reuse the artifact.

What was added

File: pallets/wormhole/src/tests.rs, module private_batch_proof_tests.

Import changes at the top of that module (helpers get_test_proof_bytes(), deserialize_test_proof(), setup_valid_block_state_for_test_proof(), ceil_volume_fee(), set_miner_preimage_digest() already exist):

Then add the test function below (immediately after test_verify_private_batch_cannot_replay, before regenerate_test_fixture). No other production code is modified.

Full PoC source

Run command

Full log

What this log proves, in order:

1

The author is a wormhole address

The author bytes equal derive_wormhole_address([7u8; 32]) — a wormhole address, not hash(dilithium_pk).

2

The mining reward has a matching leaf

After a mining-reward credit, the miner has 1 QUAN and leaf [0] = (miner, 1 QUAN).

3

The batch is verified and user exits are recorded

The real private-batch proof is ZK-verified and settled. The user exit of 19.98 QUAN gets NativeTransferred and leaf 1.

4

The miner fee is credited

Miner free_balance rises by 5_000_000_000 planck and MinerVolumeFeePaid fires.

5

No leaf exists for the miner fee

Miner TransferCount stays 1. The only miner-owned leaf is still the reward. There is no NativeTransferred for the fee.

6

The missing call resolves the issue

Calling the missing record_transfer_proof(None, minting, miner, 5_000_000_000) inserts leaf 2 for that exact amount. That is the whole fix.

Was this helpful?