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

89449 bc high mining rewards and fee credits mint more than their zk leaf commits stranding the difference at a keyless miner address

Submitted on Aug 19th 2026 at 12:04:39 UTC by @Pyro for Audit Comp | Quantus

  • Report ID: #89449

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

mint_reward mints the raw reward and hands the same raw amount to the proof recorder:

					match T::Currency::mint_into(miner, reward) {
						Ok(_) => {
							T::ProofRecorder::record_transfer_proof(
								None, // Native token
								mint_account,
								miner.clone(),
								reward,
							);

hash_leaf commits only whole quanta:

AMOUNT_SCALE_DOWN_FACTOR is 10,000,000,000 planck. The sub-quantum remainder never enters the Poseidon preimage, so no circuit can witness it, and process_exit_bundle reconstructs summed_output_amount * SCALE_DOWN_FACTOR. Entitlement stops at whole quanta while the balance is the raw amount.

on_finalize resolves the payee through qp_wormhole::extract_author_from_digest, which hashes the PoW preimage:

No signing key exists for a Poseidon image, so a ZK exit is the only way to spend a block reward, and an exit can claim only what the leaf commits.

There is no accumulator. on_finalize makes three independent mint_reward calls and each opens its own leaf at the next transfer_count, with its own floor. The nullifier binds to (secret, transfer_count), so a leaf spends once and a later leaf cannot absorb an earlier remainder. Nothing repairs it afterwards either: record_transfer is the only leaf writer, on_initialize records only at block 1, and a leaf's committed amount is fixed at insert.

The fee leg fares worse than the reward leg. Transaction fees get their own mint_reward call, so they get their own leaf and their own floor. The quantum is 0.01 UNIT, and the runtime prices a typical 500-byte transfer at about 0.0005 UNIT of length fee. Any block whose fees total under one quantum commits a zero-value leaf, so the miner can withdraw none of them, while the runtime documents that "100% of transaction fees go to the block miner". The dead leaf still consumes a transfer_count index.

The runtime already names this hazard and enforces the invariant elsewhere. VestingPayoutQuantum is defined as pallet_wormhole::SCALE_DOWN_FACTOR, with the comment that "a sub-quantum transfer would be committed as a zero-value leaf, stranding funds paid to keyless beneficiaries". Pallet-vesting honours it:

Vesting pays whole quanta and keeps the remainder in the pot for the next claim. Mining-rewards mints the remainder into an address that can never claim it.

The same chokepoint accepts unaligned credits from the event scanner, so a third-party transfer sized for a wormhole recipient loses its remainder the same way. record_transfer_proof rejects only a zero amount. There is no alignment check and no usable minimum.

The shortfall is permanent and recurs on every block. At the 12,000 ms target that is 7,200 block rewards a day, each stranding its own remainder at an address that can never claim it, while total issuance rises by the full raw amount and the unclaimable part consumes the 21,000,000 QUAN supply budget. Emission decays, so the shortfall grows as a share of the reward until the per-block reward itself falls under one quantum and strands in full.

Example

1

The chain starts from the Planck preset

Genesis issuance is 100,009,001,000,000,000 planck.

2

The first block carries a PoW pre-runtime digest

It collects no transaction fees.

3

Calculate the total reward

on_finalize computes:

4

Split the treasury portion

The 50 percent treasury portion floors to 689,151,854,808 planck, so miner_reward is 689,151,854,809 planck.

5

Mint the full raw reward

mint_into credits the full 689,151,854,809 planck and total issuance rises by the same amount.

6

Commit only whole quanta

hash_leaf commits:

This is worth 680,000,000,000 planck.

7

Identify the stranded entitlement

9,151,854,809 planck of that block's reward is entitlement that was never created, 1.33 percent of the reward.

8

Repeat the shortfall

The next block opens a new leaf at transfer_count + 1 with no remainder carried in, and the shortfall repeats for every block author.

Recommendation

Apply the rule pallet-vesting already applies. Pay the miner one credit, quantized down to whole leaf quanta, and roll the remainder into the next block's bucket.

LeafQuantum is a new pallet constant, wired in the runtime to pallet_wormhole::SCALE_DOWN_FACTOR, exactly as VestingPayoutQuantum already is.

Add the same guard at the shared chokepoint so no caller can bypass it. In record_transfer_proof, reject a native credit to a wormhole-form recipient that is not a multiple of AMOUNT_SCALE_DOWN_FACTOR, or that is below the two-quantum minimum the circuit fee relation needs for a nonzero output.

Proof of Concept

Verified against commit 3b243b8. The leaves come from the real emission path, the recipient is the address the PoW digest actually pays, and the failing withdrawal is a valid proof reverted on chain.

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

Confirm the payee

The payee is asserted against the same extract_author_from_digest the emission path calls, so it is the keyless miner address.

2

Produce real emission leaves

Twenty blocks of real on_finalize produce twenty separate leaves. Nothing merges them, and 48,981,930,013 planck is stranded across them.

3

Door 1

Claiming one quantum more against the chain's own root is unprovable. The leaf commits 69 quanta and no more.

4

The ceiling

A legitimate exit withdraws 680,000,000,000 planck out of 692,449,484,656 planck credited.

5

A tree differing by exactly the missing leaf

Recording the accumulated remainder, which is what the recommended carry does, adds one leaf committing exactly those 48,981,930,013 planck. The tree goes from 41 leaves to 42.

6

Door 2

Under that root the remainder is withdrawable. Submitted against the live chain it reverts with InvalidPublicInputs.

7

Withdraw with the carry leaf present

With the carry leaf present the same proof pays 30,000,000,000 planck.

Step 5 is what makes the rejection specific. The remainders are not lost to an accounting quirk. They are real value that becomes withdrawable the moment a leaf commits them, and the fix that creates that leaf is the one in the recommendation above.

Was this helpful?