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

89552 bc high qpow miners will permanently freeze the sub quantum remainder of every block reward because mint reward credits full planck while hash leaf only commits floor amount 10 1

Submitted on Aug 19th 2026 at 20:29:40 UTC by @n3310y for Audit Comp | Quantus

  • Report ID: #89552

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of unclaimed yield

Description

Brief/Intro

Minting the miner share in full Planck units and then hashing the leaf with integer division by 10^10 will cause permanent freezing of unclaimed yield for every QPoW miner as honest block production credits a Poseidon (non-ML-DSA) author with a remainder that is present in Balances and in leaf storage but absent from the Merkle commitment the wormhole circuit is bound to. On the production runtime the first block of the PoC mints 692_383_582_714 planck (0.692383582714 QUAN), commits only 69 quanta (0.69 QUAN), and leaves 2_383_582_714 planck (0.002383582714 QUAN) unexitable. That residue is above the existential deposit, so Substrate will not reap it. The miner cannot Dilithium-sign it out (BadProof) and cannot prove it in a wormhole exit. The freeze repeats on every subsequent block until the whole miner share itself falls below one quantum (the separately listed late-emission known issue). This is not the Eiger leftover-redemption finding: that issue is a user-chosen partial exit of an already-committed amount; this freeze is mint-side truncation before any exit.

Vulnerability Details

Root Cause

In chain/pallets/mining-rewards/src/lib.rs:195-196 on_finalize() calls mint_reward() with the undivided miner share. In lib.rs:230-237 that path mint_into()s reward and record_transfer_proof(..., reward). In chain/pallets/zk-tree/src/tree.rs:146-148 the leaf hash uses amount / AMOUNT_SCALE_DOWN_FACTOR (10^10). Miner identity is derive_wormhole_address(preimage) (chain/primitives/wormhole/src/lib.rs:147-148), which is Poseidon, not hash_bytes(dilithium_pk), so there is no ML-DSA spend of the residue.

Details

Quantus pays QPoW authors from remaining supply and then relies on the zk-wormhole to let those authors move value off a keyless account. Two independent type systems collide on that path.

Spendable Substrate accounts are ML-DSA-65 (or 87) public keys hashed with Poseidon:

QPoW authors are not that. The pre-runtime digest carries a 32-byte mining preimage. MiningRewards::on_finalize() extracts the author through extract_author_from_digest(), which re-hashes that preimage into a wormhole address:

That account can receive mint_into(), but it cannot produce a Signature::Dilithium65 that Verify will accept for that AccountId. The intended spend path is therefore a wormhole exit: insert a ZK-tree leaf at mint time, later prove membership of that leaf and remint the committed amount to a Dilithium account.

The mint itself is computed in full 12-decimal Planck units from production constants (MAX_SUPPLY = 21_000_000 * 10^12, EmissionDivisor = 15_163_560, treasury Permill 50%):

mint_reward() then credits that exact Planck amount and records a leaf with the same number:

The Merkle commitment does not use leaf.amount as stored. hash_leaf() divides by 10^10 (two decimal places of QUAN) before hashing, matching the circuit's u32 amount:

The wormhole circuit's private input_amount is a u32 quantized amount, not a Planck u128:

Settlement remints only that quantized public output times 10^10:

A valid proof of the block-1 leaf can therefore only ever mint at most 69 * 10^10 = 690_000_000_000 planck. Claiming 70 quanta would require a different leaf hash, which the tree does not contain. The circuit's fee constraint (qp-zk-circuits/wormhole/circuit/src/zk_merkle_proof.rs) is (output_1 + output_2) * 10000 <= input * (10000 - fee_bps), so outputs cannot exceed the already-truncated input_amount.

The residue 2_383_582_714 planck sits on the Poseidon miner in Balances. It is 2.38× the existential deposit (10^9), so it is not dust and will not be reaped. It cannot be moved with Balances::transfer_keep_alive: Executive::apply_extrinsic with a real Dilithium key claiming the miner origin returns InvalidTransaction::BadProof and the miner balance is unchanged. Remainders from later blocks cannot be added together to make one extra quantum, because each record_transfer_proof is a distinct (to, transfer_count) leaf.

This is not known issue #4. That path credits the miner volume fee with Unbalanced::increase_balance() and omits record_transfer(). This path does call record_transfer_proof; the leaf exists; only the committed amount is truncated.

This is not known issue #3. That text is the late-emission case where mining rewards drop well below the quantized minimum (the whole reward floors to 0, planned circuit upgrade). Block 1 of the production runtime commits 69 quanta, not 0. The freeze is live from genesis-scale rewards.

This is not Eiger EQ-QNT-DOC-F-01 (“Leftover Amount Redemption Issue in QIP-5”, chain/audits/EigerWormholeAudit.pdf). That prior finding is a user-controlled partial withdrawal: the prover may set output_amount_1 + output_amount_2 + fee < input_amount, the nullifier is consumed, and any leftover of the already-committed quantized amount cannot be redeemed again. The user chose to leave it. H-1 is an unavoidable mining-mint truncation. mint_reward() credits full Planck and hash_leaf() floors the commitment before any user action. A maximal exit of the block-1 leaf (input_amount = 69) still cannot recover 2_383_582_714 planck, because those units were never a felt in the leaf. They cannot appear as change on output_amount_2. Honest QPoW production is enough; no partial-exit choice is involved.

The treasury half of the same on_finalize mint is not this bug. Treasury is a pallet account; governance can spend it. Only the Poseidon miner remainder is frozen.

Attack Path

1

Mine a valid QPoW block

A miner finds a valid QPoW nonce. The 32-byte preimage is placed in the block header as DigestItem::PreRuntime(POW_ENGINE_ID, preimage).

2

Compute the miner reward

Executive::initialize_block + Executive::finalize_block run. MiningRewards::on_finalize() computes miner_reward = (MaxSupply - total_issuance) / EmissionDivisor after the 50% treasury split.

3

Derive a keyless miner account

extract_author_from_digest() sets the author to derive_wormhole_address(preimage) (Poseidon), not hash_bytes(dilithium_pk).

4

Mint and record the full Planck reward

mint_reward() mint_into()s the full Planck amount onto that Poseidon account and record_transfer_proof(..., reward) inserts a native leaf with leaf.amount = reward.

5

Commit only the quantized amount

hash_leaf() commits floor(reward / 10^10) only. Storage still holds the full amount; the Merkle root does not.

6

Prove only the quantized amount

The miner builds a wormhole proof of that leaf. The circuit input_amount can only be the quantized value (69 on block 1 of the PoC). The remainder never enters the proof and is not reminted on exit.

7

Fail to transfer the residue directly

Any attempt to Balances::transfer_keep_alive the remainder with an ML-DSA key claiming the miner origin fails Verify with InvalidTransaction::BadProof. Miner balance is unchanged.

8

Repeat on later blocks

The next block repeats the same mint against a new transfer_count. Remainders cannot be combined into one extra quantum.

Impact Details

In-scope impact: High — Permanent freezing of unclaimed yield.

The affected party is every QPoW miner. The protocol mints yield onto an account that cannot sign, then commits a truncated amount into the only exit path. No attacker needs to act. Honest block production is the attack path.

Measured on the production runtime (quantus-runtime, MAX_SUPPLY = 21_000_000 QUAN, EmissionDivisor = 15_163_560, 50/50 split, 12-second blocks):

Quantity
Planck
QUAN

Block-1 miner share (MinerRewarded)

692_383_582_714

0.692383582714

Committed by hash_leaf (69 quanta)

690_000_000_000

0.690000000000

Frozen remainder (block 1)

2_383_582_714

0.002383582714

Remainder / reward

—

0.3443%

Existential deposit

1_000_000_000

0.001

Frozen over 200 produced blocks (sum of remainders)

475_807_892_516

0.475807892516

Sum of those 200 miner block rewards

138_475_807_892_516

138.475807892516

Remainders / rewards (200 blocks)

—

0.3436%

Lead with what was measured, not with an annual projection. The 200-block figure is a direct runtime measurement, not a unit-test constant. Miner free balance after 200 blocks is 138.486803679516 QUAN; the extra ~0.011 QUAN versus the reward sum is the block-2 transaction-fee mint (out of scope for this report). The frozen sum above is only miner_reward % 10^10 from MinerRewarded events.

Immediate funds at risk, right now, are the measured block-1 remainder (2_383_582_714 planck = 0.002383582714 QUAN) and every following block of the same shape. The 6_264.055372392 QUAN number later in the PoC log is only an envelope: 7_200 blocks/day × 365 × the block-1 remainder, assuming that remainder stayed constant. It is not a guaranteed annual loss. The remainder is a sawtooth (miner_reward % 10^10) that decays only as remaining supply decays.

The freeze continues until the whole miner share drops below one quantum. That happens when 0.5 * remaining / 15_163_560 < 10^10, i.e. remaining supply < 303_271.2 QUAN. The PoC still had 20_997_723 QUAN remaining after 200 blocks. Known issue #3 only covers the tail after that threshold. Almost the entire emission schedule is this bug, not the listed exception.

The miner does not steal from users. They lose their own unclaimed yield. Treasury's matching remainder is spendable and is not counted in the loss.

Risk Breakdown

  • Severity: High, matching Immunefi impact “Permanent freezing of unclaimed yield”.

  • Likelihood: Certain. Every block whose miner share is not an exact multiple of 10^10 (the typical case) freezes the residue. No special transaction, no victim interaction, no privileged role.

  • Complexity: None. Happens in honest operation on the production runtime.

  • Privileges required: None beyond mining a valid QPoW block, which is the normal authoring path.

  • Impact window: Immediate, and repeating every 12-second block from genesis-scale rewards until the whole miner share itself is < 1 quantum.

References

  • chain/pallets/mining-rewards/src/lib.rs — on_finalize(), extract_miner_from_digest(), mint_reward()

  • chain/pallets/zk-tree/src/tree.rs — AMOUNT_SCALE_DOWN_FACTOR, hash_leaf()

  • chain/primitives/wormhole/src/lib.rs — derive_wormhole_address(), extract_author_from_digest()

  • chain/primitives/dilithium-crypto/src/traits.rs — IdentifyAccount for Dilithium (hash_bytes(pk))

  • chain/pallets/wormhole/src/lib.rs — SCALE_DOWN_FACTOR, exit remint is summed_output_amount * 10^10

  • qp-zk-circuits/wormhole/circuit/src/inputs.rs — PrivateCircuitInputs.input_amount: u32

  • qp-zk-circuits/wormhole/circuit/src/zk_merkle_proof.rs — fee constraint output_1 + output_2 <= input after bps

  • chain/runtime/src/lib.rs — UNIT, EXISTENTIAL_DEPOSIT, MAX_SUPPLY

  • chain/runtime/src/configs/mod.rs — EmissionDivisor = 15_163_560

  • Program known issue #3 (whole reward well below one quantum; planned upgrade) — not this bug

  • Program known issue #4 (volume fee increase_balance with no record_transfer) — not this bug

  • Eiger EQ-QNT-DOC-F-01 leftover-amount redemption in QIP-5 (chain/audits/EigerWormholeAudit.pdf) — not this bug (user-controlled partial exit of an already-committed amount)

Proof of Concept

This is not a hash_leaf unit test. It is a quantus-runtime integration test that drives the production runtime through Executive::initialize_block / finalize_block (the same hooks a real QPoW block runs) and Executive::apply_extrinsic (the same Dilithium signature + TxExtension pipeline a real transfer runs). It never talks to a public network.

The test builds a synthetic Header with PreRuntime(POW_ENGINE_ID, preimage) only so Executive will run those production hooks. It does not simulate a QPoW nonce search. The freeze does not depend on the nonce: any accepted block whose miner share is not a multiple of 10^10 truncates the same way.

What the PoC proves, in order:

1

Identify the QPoW author

The QPoW author is derive_wormhole_address(preimage), not hash_bytes(dilithium_pk).

2

Verify the block-1 reward

Block 1’s MinerRewarded amount equals the live production formula (MAX_SUPPLY - total_issuance) / 15_163_560 after the 50% treasury split: 692_383_582_714 planck.

3

Verify the full balance mint

Balances::free_balance(miner) equals that full mint.

4

Verify the leaf truncation

The ZK leaf stores the full mint, but hash_leaf(full) == hash_leaf(mint - remainder) != hash_leaf(next quantum).

5

Verify a Dilithium control spend

A Dilithium control account can spend through Executive::apply_extrinsic (Ok(Ok(()))).

6

Verify the miner account cannot spend

The same Dilithium key claiming the miner origin cannot (Err(Invalid(BadProof))); miner balance unchanged.

7

Verify repeating remainders

200 consecutive blocks produce 200 separate miner-reward leaves; remainders sum to 475_807_892_516 planck (0.475807892516 QUAN) and cannot merge.

8

Distinguish from known issues

Block 1 committed 69 quanta, so this is not known issue #3. A leaf exists, so this is not known issue #4. The remainder is missing from the leaf hash before any exit, so this is not Eiger leftover redemption (user-controlled partial withdrawal).

0. Step-by-step reproduction (triager)

Do not run this against a public testnet or mainnet. The PoC is an in-process runtime test.

1

Clone the repository

Clone the in-scope asset https://github.com/immunefi-team/audit-comp-quantus-chain and enter the chain/ directory. The repo pins Rust via chain/rust-toolchain (channel = "1.93.0", target wasm32v1-none). No extra environment variables are required.

2

Add the PoC test file

Add the PoC file at chain/runtime/tests/transactions/h1_mining_reward_remainder.rs (full source in section 2 below). Nothing else in the runtime is patched; the test only calls production Executive hooks. The synthetic header is a vehicle for those hooks, not a consensus exploit.

3

Register the module

Register the module in chain/runtime/tests/transactions/mod.rs by adding this line at the top of the file, next to the existing pub mod entries:

4

Run the test

From chain/, run the command in section 3. First compile of pallet-wormhole generates ZK circuit binaries (~40s) and then compiles quantus-runtime tests. The test itself finishes in ~1.4s. Use --nocapture so the step log prints. Expected result: test result: ok. 1 passed.

5

Compare the output

Compare stdout to the full log in section 4. The exact Planck figures are a function of total_issuance() before block 1 (the PoC endows 1000 UNIT + 1000 UNIT + ED). They must match the formula printed in STEP 2. Hashes in STEP 4 must show hash_leaf(full) == hash_leaf(truncated) and a different next-quantum hash. STEP 5 must show BadProof.

No Foundry/Hardhat fork is applicable; this is a Substrate L1. Executive::initialize_block / finalize_block / apply_extrinsic is the equivalent of a production-runtime fork test.

1. What was added (nothing in production logic is changed)

Path
Change

chain/runtime/tests/transactions/h1_mining_reward_remainder.rs

New file. Full E2E PoC.

chain/runtime/tests/transactions/mod.rs

One line: pub mod h1_mining_reward_remainder;

No pallet, runtime config, genesis preset, or circuit is modified. A passing test is the exploit demonstration: the production hooks already freeze the remainder.

2. Full PoC source

Save as chain/runtime/tests/transactions/h1_mining_reward_remainder.rs:

The copy above is the complete test. If markdown rendering ever disturbs whitespace, use the on-disk file chain/runtime/tests/transactions/h1_mining_reward_remainder.rs — that is what was compiled and run.

3. Run command

From the chain/ directory (Rust 1.93.0 via chain/rust-toolchain):

The integration-test binary is named mod because tests live at runtime/tests/mod.rs. No extra features, env vars, or network access. First compile of pallet-wormhole generates circuit binaries; the test itself is ~1.4s after that.

Expected: test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 42 filtered out.

4. Full log (actual stdout of the run above)

Compiler warnings from frame-benchmarking / pallet-wormhole circuit generation are omitted. This is the test body:

What this log proves

  • Identity: miner 0x813530fa…363cb6 = Poseidon(preimage); control 0xd9e6761e…3e6ce7 = hash_bytes(Dilithium pk). They differ, so the miner cannot sign. After block 1 leaf_count is 5 because genesis endowments and the treasury mint also insert native leaves; the miner-reward leaf is identified by matching to and amount to MinerRewarded (index 3, transfer_count 0).

  • Full mint vs truncated hash: MinerRewarded and free_balance are 692_383_582_714. hash_leaf(full) = hash_leaf(truncated) = 0x67adaeb8…8fc2e8. hash_leaf(70 quanta) = 0x68408fce…4008 (different). 2_383_582_714 planck are credited and stored but not in the Merkle commitment.

  • No Dilithium spend: control transfer returns Ok(Ok(())). The same key claiming miner origin returns InvalidTransaction::BadProof. Miner balance unchanged at 692_383_582_714.

  • Loss: 0.002383582714 QUAN frozen on block 1 (> ED). Over 200 production-runtime blocks the remainders sum to 475_807_892_516 planck = 0.475807892516 QUAN (~0.34% of miner block rewards). Genesis-rate year envelope at 12s blocks: 6_264.055372392 QUAN.

  • Not the listed known issues or the Eiger leftover finding: 69 committed quanta (not known issue #3). A leaf exists (not known issue #4). The remainder is missing from the leaf hash before any exit (not Eiger EQ-QNT-DOC-F-01 user-controlled partial withdrawal).

Was this helpful?