89448 bc high block authors are paid wormhole volume fees at a keyless address that no exit proof can unlock
Submitted on Aug 19th 2026 at 12:02:53 UTC by @Pyro for Audit Comp | Quantus
Report ID: #89448
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of funds
Description
A wormhole address holds no spendable balance. process_exit_bundle never debits the source address when it settles an exit. What makes value at such an address usable is a ZK-tree leaf, and record_transfer is the pallet's only leaf writer.
The exit-credit path pairs the credit with the recorder:
match <T::Currency as Unbalanced<_>>::increase_balance(
exit_account,
*exit_balance,
frame_support::traits::tokens::Precision::Exact,
) { Self::record_transfer(
T::AssetId::default(),
&from_account,
&to_account,
*exit_balance,
);The miner volume fee uses the same event-free primitive and stops at the event:
The author is qp_wormhole::extract_author_from_digest, which returns derive_wormhole_address(preimage):
The payee is a Poseidon image, so no signing key exists for it. A ZK exit is the only way to move value sitting there, and an exit needs a leaf. The miner holds the secret and can satisfy the circuit, but this credit leaves it nothing to prove against.
Nothing repairs the omission later:
increase_balancewrites the balance without emitting aBalancesevent, soWormholeProofRecorderExtensioncannot see it. The pallet's own testexit_credits_emit_no_scannable_transfer_eventspins that this path emits nothing scannable.The settlement is an unsigned bare extrinsic, where that extension does not run at all.
on_initializerecords leaves only at block 1, so the genesis sweep is long past.The pallet exposes only
verify_private_batchandverify_public_batch, so no admin path can insert the leaf.Root cannot insert one either. Root dispatched calls bypass the recorder extension by design (audit item EQ-QNT-WORMHOLE-F-05), so a rescue needs a separately funded signed transfer, per miner, per settlement.
Leaves are per
(recipient, transfer_count)and each commits one fixed amount, so the mining-rewards leaf does not cover the fee.
Both failure arms of the same branch add the unpaid fee back to burn_amount. The branch's contract is pay or burn. The success path does neither.
Example:
Recommendation
Record the leaf inside the same success arm, exactly as the sibling exit-credit branch does.
Better, factor a helper that does increase_balance then record_transfer, and route every event-free credit in process_exit_bundle through it so no future branch can credit without recording.
Proof of Concept
Verified against commit 3b243b8. The test builds a cryptographically valid exit proof for the credited fee, submits it through the real unsigned extrinsic, and the chain reverts. The same construction for a recorded credit is accepted and pays out.
Setup
pallets/wormhole already carries qp-wormhole-prover, qp-wormhole-circuit and qp-wormhole-aggregator as dev-dependencies, so real proofs can be built in-tree. Two more are needed to drive the real emission path. In pallets/wormhole/Cargo.toml, [dev-dependencies]:
In pallets/wormhole/src/mock.rs, add MiningRewards: pallet_mining_rewards to construct_runtime! and:
Shared prover plumbing, appended to pallets/wormhole/src/tests.rs:
The test:
Actual output:
Read in order:
Step 4 is what makes the rejection specific. The proof is not rejected because it is malformed or aimed at the wrong block. It is rejected because the chain's tree is missing one leaf, and step 4 builds the tree that differs from the live one by exactly that leaf.
Was this helpful?