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 = 4VolumeFeesBurnRate = 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_balancesucceeds 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
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 increasetotal_issuance. This is not an inflation / unauthorized-minting bug.
Loss size:
Per successful exit bundle:
miner_fee = volume_fee - floor(volume_fee * burn_rate), withvolume_fee = ceil(exit_quanta * 4 / 9996) * 10^10planck.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
Miner fee mint without a leaf:
pallets/wormhole/src/lib.rs(process_exit_bundle miner branch)User exit mint with a leaf (the pattern the miner branch omitted): same file,
record_transferafterincrease_balanceQPoW author = Poseidon wormhole address:
primitives/wormhole/src/lib.rsextract_author_from_digestMining rewards do record a leaf:
pallets/mining-rewards/src/lib.rsmint_rewardScanner never sees
increase_balance:runtime/src/transaction_extensions.rsWormholeProofRecorderExtensionLive fee parameters:
runtime/src/configs/mod.rs(VolumeFeeRateBps = 4,VolumeFeesBurnRate = 50%)Incomplete existing coverage:
pallets/wormhole/src/tests.rstest_verify_private_batch_emits_miner_volume_fee_paid(balance + event only)
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_balancewith 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:
Was this helpful?