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

89950 bc high process exit bundle never calls record transfer for miner fee every wormhole miner fee credit is permanently unspendable

Submitted on Aug 22nd 2026 at 02:54:52 UTC by @volpasec for Audit Comp | Quantus

  • Report ID: #89950

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

process_exit_bundle (pallets/wormhole/src/lib.rs) credits the block author's share of the wormhole exit volume fee (miner_fee) via Unbalanced::increase_balance, to an account produced by qp_wormhole::extract_author_from_digest — a Poseidon2 hash of the PreRuntime digest, structurally identical to the codebase's own WormholeAddress "unspendable account" type. Unlike the exit_account credit 90 lines earlier in the same function, this branch never calls Self::record_transfer, so no leaf is ever inserted into pallet-zk-tree's Merkle tree. The recipient has no ML-DSA keypair capable of signing for it and no Merkle leaf any wormhole exit proof can reference, so the credited value is permanently unreachable. This fires on every verify_private_batch/verify_public_batch call with a nonzero fee — at the real runtime's VolumeFeeRateBps = 4, every exit with nonzero volume — with no attacker and no privileged actor involved.

Vulnerability Details

The bug. process_exit_bundle's miner_fee branch: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L944-L983

			if !miner_fee.is_zero() {
				let digest = frame_system::Pallet::<T>::digest();
				if let Some(author) = qp_wormhole::extract_author_from_digest::<
					<T as frame_system::Config>::AccountId,
					_,
				>(digest.logs.iter())
				{
					match <T::Currency as Unbalanced<_>>::increase_balance(
						&author,
						miner_fee,
						frame_support::traits::tokens::Precision::Exact,
					) {
						Ok(_) => {
							Self::deposit_event(Event::MinerVolumeFeePaid {
								miner: author,
								amount: miner_fee,
							});
						},
						Err(e) => {
							burn_amount_u128 = burn_amount_u128.saturating_add(miner_fee_u128);
						},
					}
				} else {
					burn_amount_u128 = burn_amount_u128.saturating_add(miner_fee_u128);
				}
			}

No call to Self::record_transfer exists in this branch. author is always the output of derive_wormhole_address (Poseidon2 rehash) via extract_author_from_digest: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/wormhole/src/lib.rs#L97-L151

The correct pattern exists 90 lines earlier, in the same function. The exit_account credit uses the identical increase_balance call, immediately followed by Self::record_transfer: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L818-L859

record_transfer is the only function in this pallet that ever inserts a leaf: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L1280-L1330

Four call sites, none of them miner_fee or aggregator_fee.

Why the recipient has no signature path. WormholeAddress and DilithiumSigner are disjoint account derivations: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/dilithium-crypto/src/traits.rs#L77-L85

https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/dilithium-crypto/src/traits.rs#L191-L201

A signature-capable account's AccountId is hash_bytes(ml_dsa_public_key). A wormhole address's AccountId is the Poseidon2-rehash output itself — no public key anywhere in the derivation. Signing for a miner_fee credit requires an ML-DSA keypair whose hash_bytes(pubkey) equals a value the runtime already fixed: a second-preimage attack on a ~128-bit hash (per the March 2026 Equilibrium audit's own Poseidon2 security analysis, listed on this program's Audits page).

Production values, not mock artifacts. runtime/src/configs/mod.rs#L734-L736: VolumeFeeRateBps: u32 = 4, VolumeFeesBurnRate = Permill::from_percent(50). Identical in pallets/wormhole/src/mock.rs#L98-L106.

The same pallet family gets this right elsewhere. pallet-mining-rewards::mint_reward pays the same block author (via the same extract_author_from_digest) and calls record_transfer_proof on every credit: https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/mining-rewards/src/lib.rs#L220-L241

The base block reward gets a leaf. The volume-fee share does not.

Trigger path — no attacker required:

1

Submit a valid exit

Any account submits a valid verify_private_batch/verify_public_batch extrinsic with a nonzero exit amount (unsigned, permissionless — the only way to withdraw from the wormhole).

2

Compute and split the fee

process_exit_bundle computes the fee and splits it into burn_amount/miner_fee.

3

Derive the block author

The block author is derived via extract_author_from_digest — always a wormhole-style address.

4

Credit the miner fee

miner_fee is credited. record_transfer is not called.

5

Finalize the block

The block finalizes.

6

Funds become unreachable

The credit is now unreachable by signature and unreachable by proof.

Every exit with miner_fee > 0 reproduces this.

Why this is permanent, addressing the three most likely objections directly:

Is there another leaf-insertion path?

No — confirmed above by exhaustive grep; four call sites, all accounted for.

Could governance recover it?

pallet_balances::force_transfer (pallets/balances/src/lib.rs#L685) requires Root origin. A runtime upgrade could backfill a leaf. Both require the Technical Collective acting within its attributed privileges — out of scope under this program's rules.

Could an unrelated later credit to the same address rescue it?

WormholeProofRecorderExtension::record_proofs_from_events_since (runtime/src/transaction_extensions.rs#L309-L396) scans Balances::Transfer/Minted/TransferOnHold/ReserveRepatriated events — a fresh signed transfer into the miner's wormhole address would be caught and would insert a genuine new leaf. That leaf does not rescue the frozen credit: pallet-zk-tree's ZkLeaf (pallets/zk-tree/src/lib.rs#L139-L148) binds a fixed amount and a specific, monotonically assigned transfer_count at insertion time. A later top-up makes only that top-up's amount exitable. No operation in this codebase inserts a leaf at a past transfer_count position, and no leaf's amount derives from the account's current free_balance.

Is this EQ-QNT-WORMHOLE-F-03 (already fixed)?

No. F-03 (Equilibrium, March 20 2026) found the pallet decoding the PreRuntime preimage directly as an AccountId instead of Poseidon2-hashing it — misdirecting the fee to an account the miner didn't control at all. That's fixed in this commit (extract_author_from_digest now hashes correctly, matching pallet-mining-rewards). At the time of that audit, record_transfer wrote into a flat TransferProof StorageMap with no Merkle tree (per EQ-QNT-WORMHOLE-R-08's quoted code) — pallet-zk-tree postdates that audit. F-03 fixed which address the fee resolves to; it never evaluated whether that address's credit satisfies the Merkle-leaf requirement introduced afterward.

Is this a published known issue?

The program lists three (referendum vote removal, plaintext miner-node connection, zk-tree depth ceiling). None describes fee distribution or leaf coverage.

Impact Details

100% of miner_fee credited by process_exit_bundle is permanently frozen — the branch is unconditional on amount, so every exit producing a nonzero fee reproduces this identically, at the production VolumeFeeRateBps = 4. Not delayed, not recoverable after any number of blocks, not recoverable by the recipient or by any permissionless mechanism in this codebase.

aggregator_fee (pallets/wormhole/src/lib.rs#L892-935, same function) shares the identical pattern, but aggregator_address is a prover-supplied public input rather than a protocol-derived wormhole address — whether a given rebate is affected depends on the address the aggregator chooses to supply. Noted as a related pattern, not the basis of this report's severity claim.

References

  • Commit audited: https://github.com/immunefi-team/audit-comp-quantus-chain/tree/3b243b870a5442a6e5f443056f67bbba87d472f5

  • pallets/wormhole/src/lib.rs (miner_fee branch): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L944-L983

  • pallets/wormhole/src/lib.rs (exit_account, correct pattern): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L818-L859

  • pallets/wormhole/src/lib.rs (record_transfer): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L1280-L1330

  • primitives/wormhole/src/lib.rs (derive_wormhole_address, extract_author_from_digest): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/wormhole/src/lib.rs#L97-L151

  • primitives/dilithium-crypto/src/traits.rs (WormholeAddress vs DilithiumSigner): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/primitives/dilithium-crypto/src/traits.rs#L77-L201

  • runtime/src/configs/mod.rs (production fee rate): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/configs/mod.rs#L734-L736

  • pallets/mining-rewards/src/lib.rs (comparator, mint_reward): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/mining-rewards/src/lib.rs#L220-L299

  • runtime/src/transaction_extensions.rs (WormholeProofRecorderExtension): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/transaction_extensions.rs#L309-L396

  • pallets/balances/src/lib.rs (force_transfer, Root-only): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/balances/src/lib.rs#L685

  • EQ-QNT-WORMHOLE-F-03 / EQ-QNT-WORMHOLE-R-08 — Equilibrium, "Quantus Network: Security and Performance Review," 2026-03-20, listed on this program's Audits page.

Proof of Concept

This PoC runs inside pallets/wormhole's own mock.rs/new_test_ext harness, at VolumeFeeRateBps = 4 and VolumeFeesBurnRate = 50% (identical to the production runtime), against pallet-zk-tree's real Merkle-tree implementation:

https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/mock.rs#L108-L127

1

Add the test

In pallets/wormhole/src/tests.rs, inside mod private_batch_proof_tests (opens L568, closes L1168), paste the test below immediately after the line containing exactly author_balance_before + expected_miner_fee (L1090) and its closing );/});/} sequence, and before the #[test] that immediately precedes fn test_verify_private_batch_cannot_replay() { (L1096).

2

Run the test

3

Test

4

Output

Was this helpful?