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:
Why this is permanent, addressing the three most likely objections directly:
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-L983pallets/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-L859pallets/wormhole/src/lib.rs(record_transfer): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/wormhole/src/lib.rs#L1280-L1330primitives/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-L151primitives/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-L201runtime/src/configs/mod.rs(production fee rate): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/configs/mod.rs#L734-L736pallets/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-L299runtime/src/transaction_extensions.rs(WormholeProofRecorderExtension): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/runtime/src/transaction_extensions.rs#L309-L396pallets/balances/src/lib.rs(force_transfer, Root-only): https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/balances/src/lib.rs#L685EQ-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
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).
Was this helpful?