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
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):
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
< 1quantum.
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—IdentifyAccountfor Dilithium (hash_bytes(pk))chain/pallets/wormhole/src/lib.rs—SCALE_DOWN_FACTOR, exit remint issummed_output_amount * 10^10qp-zk-circuits/wormhole/circuit/src/inputs.rs—PrivateCircuitInputs.input_amount: u32qp-zk-circuits/wormhole/circuit/src/zk_merkle_proof.rs— fee constraintoutput_1 + output_2 <= inputafter bpschain/runtime/src/lib.rs—UNIT,EXISTENTIAL_DEPOSIT,MAX_SUPPLYchain/runtime/src/configs/mod.rs—EmissionDivisor = 15_163_560Program known issue #3 (whole reward well below one quantum; planned upgrade) — not this bug
Program known issue #4 (volume fee
increase_balancewith norecord_transfer) — not this bugEiger
EQ-QNT-DOC-F-01leftover-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:
0. Step-by-step reproduction (triager)
Do not run this against a public testnet or mainnet. The PoC is an in-process runtime test.
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.
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)
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); control0xd9e6761e…3e6ce7=hash_bytes(Dilithium pk). They differ, so the miner cannot sign. After block 1leaf_countis 5 because genesis endowments and the treasury mint also insert native leaves; the miner-reward leaf is identified by matchingtoandamounttoMinerRewarded(index 3,transfer_count0).Full mint vs truncated hash:
MinerRewardedandfree_balanceare692_383_582_714.hash_leaf(full)=hash_leaf(truncated)=0x67adaeb8…8fc2e8.hash_leaf(70 quanta)=0x68408fce…4008(different).2_383_582_714planck are credited and stored but not in the Merkle commitment.No Dilithium spend: control transfer returns
Ok(Ok(())). The same key claiming miner origin returnsInvalidTransaction::BadProof. Miner balance unchanged at692_383_582_714.Loss:
0.002383582714 QUANfrozen on block 1 (> ED). Over 200 production-runtime blocks the remainders sum to475_807_892_516planck =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-01user-controlled partial withdrawal).
Was this helpful?