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

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_balance writes the balance without emitting a Balances event, so WormholeProofRecorderExtension cannot see it. The pallet's own test exit_credits_emit_no_scannable_transfer_events pins that this path emits nothing scannable.

  • The settlement is an unsigned bare extrinsic, where that extension does not run at all.

  • on_initialize records leaves only at block 1, so the genesis sweep is long past.

  • The pallet exposes only verify_private_batch and verify_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:

1

A miner runs with a stable rewards_inner_hash

Its author account already exists from earlier block rewards.

2

A user submits an exit

Any user submits a valid unsigned verify_private_batch settling a 1,000 QUAN exit.

3

The volume fee is calculated

volume_fee_for_exit computes ceil(100_000 * 4 / 9996) = 41 quanta, so the total fee is 410,000,000,000 planck.

4

The miner fee is calculated

VolumeFeesBurnRate is 50 percent, so 205,000,000,000 planck burns and miner_fee is 205,000,000,000 planck.

5

The author balance is credited

increase_balance(&author, 205_000_000_000, Exact) succeeds and MinerVolumeFeePaid is emitted.

6

No leaf is recorded

TransferCount for the author is unchanged and no NativeTransferred event fires, so no leaf exists for the credit.

7

The entitlement remains unchanged

The author's balance is 0.205 QUAN higher and its exitable entitlement is unchanged.

8

Later exits repeat the issue

Every later exit repeats this, so all wormhole volume-fee revenue accrues to block authors and none is deliverable.

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:

1

A volume fee is credited without a leaf

A 10,000 QUAN exit credits the author 200 quanta of volume fee and leaves its transfer count at zero, so no leaf commits it.

2

The withdrawal path is live

A recorded credit at the same address is proven, submitted through verify_private_batch, accepted, and pays out 990,000,000,000 planck of real balance.

3

Door 1

Proving the fee against the chain's own tree root is impossible. No leaf under that root pays it.

4

A tree differing by exactly the missing leaf

Calling the single missing record_transfer adds one leaf, committing exactly the credited fee to exactly the author. The tree goes from 3 leaves to 4 and the root changes.

5

Door 2

Under that root the exit is provable. Submitted against the live chain, whose header still commits the old root, it reverts with InvalidPublicInputs.

6

The only difference is the missing call

Give the chain the header carrying the fee leaf, resubmit the identical bytes, and it is accepted and pays 1,990,000,000,000 planck.

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?