88943 bc high residue truncation and two leaf split in pallet mining rewards lead to permanent freezing of unclaimed miner yield
#88943 [BC-High] Residue truncation and two-leaf split in pallet-mining-rewards lead to permanent freezing of unclaimed miner yield
Submitted on Aug 15th 2026 at 20:15:43 UTC by @v4rvl for Audit Comp | Quantus
Report ID: #88943
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of unclaimed yield
Description
Summary
pallet-mining-rewards::on_finalize pays the block author at a keyless wormhole address. That account has no private key by construction, so its only spend path is a ZK wormhole exit against a zk-tree leaf. The leaf, however, does not commit the credited amount: it commits amount / 10^10 truncated to u32 (pallets/zk-tree/src/tree.rs:146-148). Every planck below the 10^10 quantum is therefore minted into an account that can never spend it.
The pallet's only guard on a payout is if reward.is_zero() { return; } (pallets/mining-rewards/src/lib.rs:220-223). There is no quantum alignment and no minimum anywhere in the pallet. It then makes the loss worse by splitting one block's income into two independently-quantized credits (lib.rs:193 fees, lib.rs:196 emission), so the block pays the truncation twice.
The sibling pallet in the same repository already carries exactly this guard, with a comment naming exactly this failure mode.
The lead claim is the quantization residue. leg mod 1e10 planck sits below the leaf quantum, so it is absent from every commitment: no leaf holds it, no exit can mint it, no fee collects it and no burn accounts for it. It is paid to nobody at all. This is the half that no documented design decision covers, and it is the half the report leads on. Measured at production constants on the runtime harness: 3,246,474,598 planck per block (994,787,000 on the fee leg plus 2,251,687,598 on the reward leg), which is 0.4616% of that block's entire miner income of 703,246,474,598 planck.
The two-leaf split is the second claimed mechanism, and it is probabilistic rather than per-block. on_finalize issues one block's income as two independently quantized credits (lib.rs:193 fees, lib.rs:196 emission), so the block pays the truncation twice. A further whole quantum is destroyed only on the blocks where the two residues sum past the quantum. The harness swept real production blocks carrying 1 to 8 signed ML-DSA-65 transfers each and measured the split strictly worse on 1 of the 8 sampled blocks, with the split never better than one combined credit on any of them. It is stated as that measured ratio, not as an unconditional per-block cost.
Amplification, reported as amplification: the dead one-quantum fee leg. At production constants one signed ML-DSA-65 transfer costs 10,994,787,000 planck, that is 1.099 quanta, so the fee leg does not commit a zero-value leaf: it commits one quantum. The impact survives by a different mechanism. The circuit's fee relation (output_1 + output_2) * 10000 <= input * (10000 - 4) drives a one-quantum input to output 0, and process_exit_bundle then skips a zero-output slot outright (pallets/wormhole/src/lib.rs:787-789), so the fee leg's single committed quantum, 10,000,000,000 planck, can never be exited either. Total permanently frozen on the measured block: 13,246,474,598 planck, 1.8836% of miner income. The legitimate 4 bps volume fee taken off the exitable reward leg (also 10,000,000,000 planck on this block) is reported separately and is not counted as stranding anywhere in this report.
The distinct future case where the emission reward itself falls below the quantum is conceded to Official Known Issue 3 (SCOPE.md:78) and is not claimed here.
Root Cause
In-scope trigger, reached without any elevated role. The root cause is reached by on_finalize (pallets/mining-rewards/src/lib.rs:129), a FRAME hook with no origin, no dispatchable and no caller. There is no pre-existing administrator anywhere on this path, and nothing on it waits on an administrative step - it is a non-privileged path end to end, and it is not a self-inflicted one either, since the account that loses the value is the honest block author's. This finding does not rely on, and is independent of, every one of the following, each ruled out on its own terms - it needs no administrator, no contract owner, no governance vote, no multisig, no protocol team intervention, no operator, no sequencer, no guardian, no off-chain infrastructure, no leaked key, no leaked credential, and no test double standing in for a verifier. The loss fires under fully honest operation of the shipped runtime with every secret intact. The only actor is the node that authors the block, and the only configuration required is the one documented deployment mode (node/src/service.rs:488).
Three in-scope facts compose:
The block author's account is keyless
on_finalize resolves the miner via Self::extract_miner_from_digest() (pallets/mining-rewards/src/lib.rs:165), which is qp_wormhole::extract_author_from_digest (pallets/mining-rewards/src/lib.rs:205-208). That function takes the miner-supplied 32-byte pre-runtime preimage and returns derive_wormhole_address(preimage) (primitives/wormhole/src/lib.rs:147-149), which is rehash_to_bytes(&inner_digest) - i.e. address = Poseidon(Poseidon(salt || secret)) (primitives/wormhole/src/lib.rs:97-111). No ML-DSA keypair maps to that AccountId32. The node treats the operator-supplied value as the preimage, not as an address (node/src/service.rs:488: let rewards_preimage: [u8; 32] = rewards_address.into();), so a miner cannot opt out and be paid at a signable account.
The only spend path commits a truncated amount
Each mint_reward calls T::ProofRecorder::record_transfer_proof (pallets/mining-rewards/src/lib.rs:232-237); ProofRecorder = Wormhole (runtime/src/configs/mod.rs:141). That lands in Wormhole::record_transfer (pallets/wormhole/src/lib.rs:1280-1310) which calls T::ZkTree::record_transfer(...), and the leaf preimage commits
with AMOUNT_SCALE_DOWN_FACTOR = 10_000_000_000 (pallets/zk-tree/src/tree.rs:34).
A leaf under two quanta yields nothing on exit
The circuit's fee/balance relation is
and the chain pins bundle.volume_fee_bps == T::VolumeFeeRateBps::get() (pallets/wormhole/src/lib.rs:1100) with VolumeFeeRateBps: u32 = 4 (runtime/src/configs/mod.rs:734). For input = 1 quantum: out * 10000 <= 9996, so out = 0; and process_exit_bundle then drops the slot outright:
The minimum spendable leaf is therefore 2 quanta = 2e10 planck = 0.02 UNIT (UNIT = 1_000_000_000_000, runtime/src/lib.rs:97).
The omission. mint_reward guards nothing but zero:
The amplifier. One block's author income is issued as two independent leaves:
Since floor(a/q) + floor(b/q) <= floor((a+b)/q), the split destroys an additional quantum whenever frac(a) + frac(b) >= q. That is a probabilistic condition, not a per-block one, and it is reported as measured rather than as modelled: sweeping real production blocks carrying 1 to 8 signed ML-DSA-65 transfers each, the split was never better than a single combined credit and was strictly worse on 1 of the 8 sampled blocks (the 8-transfer block, where the fee-leg residue 7,958,296,000 and the reward-leg residue 2,251,687,598 sum to 10,209,983,598 planck, past the 1e10 quantum). The pallet already owns the helper that would fix it: retain_unminted rolls a residue back into CollectedFees for the next finalize (pallets/mining-rewards/src/lib.rs:301-306).
Attack Path
There is no attacker. This is a spontaneous, unconditional protocol defect that fires on every block with a block author. The sequence is:
Sub-quantum planck becomes unreachable
Any planck below the quantum on either leg is now in an account with no key and no representable leaf. It is unreachable forever. This is the claimed residue: at production constants, 994,787,000 planck on the fee leg plus 2,251,687,598 on the reward leg, 3,246,474,598 planck for the block.
One-quantum legs are also unreachable
Amplification on top of step 6, and reported as amplification: whenever the truncated leaf value of a whole leg is 0 or 1, that entire leg is unreachable too, because the circuit forces output = 0 and process_exit_bundle skips the slot. At production constants the fee leg lands exactly here: one signed ML-DSA-65 transfer costs 10,994,787,000 planck, so the leg commits one quantum, not zero, and that one committed quantum is driven to zero output by the exit fee constraint. A further 10,000,000,000 planck is therefore dead on this block. The distinct future case where the emission reward itself falls below the quantum is conceded to Official Known Issue 3.
Impact
Permanent loss of unclaimed miner yield, measured at production constants on a real runtime block. The report leads with the mechanism that no documented design decision covers, and presents the rest as amplification.
(i) LEAD CLAIM - the quantization residue, value paid to nobody at all. leg mod 1e10 planck is below the leaf quantum, so it is absent from every commitment. There is no leaf holding it, no exit that can mint it, no fee that collects it and no burn that accounts for it. It simply has no recipient. Measured on the executed production block: 3,246,474,598 planck, decomposing as 994,787,000 on the fee leg plus 2,251,687,598 on the reward leg. That is 0.4616% of that block's miner income of 703,246,474,598 planck. There is no magnitude at which this stops: the emission credit today is 69 quanta, 69x above the leaf quantum, and its residue is still destroyed; a reward of 1,000 quanta strands its residue exactly the same way. At TARGET_BLOCK_TIME_MS = 12_000 (runtime/src/lib.rs:87) that is 7,200 blocks/day, so ~23.37 UNIT/day, ~8,532 UNIT/year, ~0.0406% of the 21,000,000 UNIT cap per year on this block profile. The arithmetic behind every one of those figures is shown in Funds At Risk And Reward Bound.
(ii) The two-leaf split, which pays the truncation twice - probabilistic, measured, not per-block. on_finalize issues one block's author income as two independently quantized credits (pallets/mining-rewards/src/lib.rs:193, :196). Since floor(a/q) + floor(b/q) <= floor((a+b)/q), a whole extra quantum (1e10 planck) is destroyed whenever the two legs' fractions sum to at least one quantum. The harness swept real production blocks carrying 1 to 8 signed ML-DSA-65 transfers each and measured: the split is never better than one combined credit, and it is strictly worse on 1 of the 8 sampled blocks. The report claims that measured ratio and nothing stronger. The 8-transfer block is the strictly-worse one: two leaves commit 77 quanta where one combined credit commits 78. The mutation control flips exactly that assertion, proving it is load-bearing on :193/:196 and on nothing else. This is not a quantization necessity; it is an avoidable implementation choice, and the pallet already owns the helper that fixes it (retain_unminted, lib.rs:301-306).
(iii) AMPLIFICATION, stated as amplification - the dead one-quantum fee leg. At production constants one signed ML-DSA-65 transfer costs 10,994,787,000 planck, so the fee leg is 1.099 quanta and commits one quantum, not zero. That single committed quantum is nevertheless unexitable: the circuit's fee relation (output_1 + output_2) * 10000 <= input * (10000 - 4) admits no output above zero for a one-quantum input, and process_exit_bundle skips a zero-output slot at pallets/wormhole/src/lib.rs:787-789. A further 10,000,000,000 planck is therefore dead on the measured block. This is presented as amplification rather than as the lead because runtime/src/configs/mod.rs:729-733 documents the flat one-quantum minimum exit fee as intended, so a triager can reasonably argue the dead fee leaf is by design. No such comment covers the residue in (i), which is why (i) leads.
Total on the measured block: 13,246,474,598 planck permanently frozen, 1.8836% of miner income, that is ~95.37 UNIT/day, ~34,812 UNIT/year, ~0.1658% of the 21,000,000 UNIT cap per year. The legitimate 4 bps volume fee taken off the exitable reward leg (10,000,000,000 planck on this block) is a correctly-charged protocol fee with a recipient and is reported separately; it is not counted as stranding anywhere in this report.
No claimed loss is recoverable in-protocol (see Victim Recovery Enumeration).
Funds At Risk And Reward Bound
Asset identity: the native Quantus token, minted by
T::Currency::mint_intoinpallets/mining-rewards/src/lib.rs:230withtype Currency = Balances(runtime/src/configs/mod.rs:138-141). Denomination fixed atpub const UNIT: Balance = 1_000_000_000_000;(runtime/src/lib.rs:97). Total capMAX_SUPPLY = 21_000_000 * UNIT(runtime/src/lib.rs:105).Unit -> USD: 1 UNIT = 1e12 planck (
runtime/src/lib.rs:97). No USD figure is claimed. The chain is pre-launch at this pin: the program publishes no token price and there is no TVL figure to convert against, so asserting a dollar number here would be fabricated. The loss is quantified in native token and as a percentage of author income instead, and the honest statement is that the USD figure scales linearly with whatever the launch price is.Market-size scenario: 7,200 blocks/day (
TARGET_BLOCK_TIME_MS = 12_000,runtime/src/lib.rs:87); block emissionremaining_supply / EmissionDivisorwithEmissionDivisor = 15_163_560(runtime/src/configs/mod.rs:144), treasury share 50% (pallets/treasury/src/lib.rs:60, v1 migration). On the executed production block the miner reward leg is 692,251,687,598 planck and the fee leg is 10,994,787,000 planck, so gross miner income is 703,246,474,598 planck. Traffic scenario: 1 signed transfer/block, which is the realistic launch regime for a newly launched base-layer chain and is the regime the program's own 12-second block target implies.The production transaction fee, decomposed:
Per-block loss, with the arithmetic shown. Quantum
q = 1e10planck.Percentage of author income, from the production numbers:
Per-day and per-year (7,200 blocks/day, 365 days, 1 UNIT = 1e12 planck):
Which of these numbers are stable and which are not - stated rather than left to be found.
Issuance-INDEPENDENT (fixed by the fee constants and the quantum, not by supply): the transaction fee 10,994,787,000 planck, its residue 994,787,000 planck, and the fee leg's 1 dead committed quantum 10,000,000,000 planck. Their sum, 10,994,787,000 planck per block, is the stable floor of the per-block loss at 1 transfer/block, because the entire fee leg is unexitable at that traffic level.
Issuance-DEPENDENT: the reward-leg residue 2,251,687,598 planck. The reward leg is
(MAX_SUPPLY - total_issuance) / EmissionDivisornet of the treasury share, so its residue is a function oftotal_issuance. The runtime-test genesis seeds roughly 6,000 UNIT, far below any realistic mainnet genesis, so this component is not a stable figure: it varies block to block across the whole range0 .. 1e10planck with an expected value of about 5e9 planck. Gross miner income, and therefore every percentage above, moves with it for the same reason.The honest consequence: the shape of the finding, the residue having no recipient and a one-quantum leg being unexitable, is issuance-independent; the exact reward-leg planck figure and the exact percentages are one measured block and are labelled as such throughout.
Absolute figure vs floor: the loss is unbounded in time (it never stops) and monotonically accumulating. **Per-residue magnitude is strictly under 1e10 planck (0.01 UNIT), and the severity claim does not rest on per-credit magnitude.
Attacker cost, capital lock, profit and net loss - stated in the expected terms, and stated honestly as zero where they are zero. This finding has no attacker, so several standard economic fields have no value to report. They are recorded as not applicable rather than filled in with an invented figure:
Attacker cost: not applicable, and the honest value is zero. There is no attack transaction, so there is no attacker cost to price: no gas, no fee, no bond, no stake and no bribe is spent by anybody to cause this loss.
on_finalize(pallets/mining-rewards/src/lib.rs:129) runs by itself on every block.Capital lock: none, and the honest value is zero. No capital is locked, posted, borrowed, flash-loaned, bonded or staked at any point on this path. The required capital for the finding to fire is zero because there is nothing to fund.
Attacker economics: there is no attacker, nobody gains anything from this finding, and no gain is claimed. Nobody receives the frozen planck. It is minted into a keyless account (
pallets/mining-rewards/src/lib.rs:230) and simultaneously made unrepresentable in the only instrument that can ever claim it (pallets/zk-tree/src/tree.rs:146-148), so the value is destroyed, not transferred. Because the gain side is structurally empty, no profitability test, no cost-versus-gain comparison and no return-on-capital figure applies here, and this report asserts none. The finding stands on destruction of the miner's income, not on anyone's extraction of it.Net loss: the net loss is the protocol's and the block author's, with no offsetting counterparty gain anywhere: 13,246,474,598 planck per block on the executed production-constant block, accruing every block with an author, forever.
Affected amount and amount basis: the affected amount is the frozen 13,246,474,598 planck per block, decomposed above into 3,246,474,598 planck of quantization residue that has no recipient at all and the fee leg's single committed quantum of 10,000,000,000 planck that the exit relation drives to zero output. The amount basis is that block's gross miner income of 703,246,474,598 planck, which puts the affected amount at 1.8836% of author income on the measured block. Every figure here is read off the executed transcripts and the production constants cited above; none is modelled, extrapolated to a token price, or assumed.
Why an economics section exists at all on a no-attacker finding: the fields are answered rather than skipped so that a triager does not have to infer whether they were overlooked or are genuinely empty. They are genuinely empty, and saying so is the accurate answer.
Proof of Concept
Proof Of Concept
Harness A (primary) - production-constant runtime integration harness. It is not a mock. It runs inside the quantus-runtime crate's own integration-test directory using the crate's existing TestCommons::new_test_ext() harness, with real frame_system and pallet_treasury genesis. The transaction fee is produced by a real ML-DSA-65 signed extrinsic driven through Executive::apply_extrinsic, so nothing about the fee is assumed. Because the runtime wires ProofRecorder = Wormhole (runtime/src/configs/mod.rs:141), a miner credit walks the real pallet_wormhole::record_transfer into pallet_zk_tree::insert_leaf; the leaves are then read back out of live storage, and the quantization is proven by ZkTree::verify_proof accepting the quantized leaf against the same live on-chain root as the real one. Every constant is read off the runtime at run time, never hard coded.
The pinned source tree was not modified by either harness beyond the test file itself; each run was made against the pinned tree, which was restored afterwards.
Harness A - production-constant runtime integration harness (PRIMARY EVIDENCE)
Run command:
Harness A source (inline, complete)
Transcript A1 - BASELINE, unmodified pinned source, production constants
Verbatim from production_baseline_transcript.txt.
Suggested Fix
Carry the vesting guard across. In pallets/mining-rewards/src/lib.rs:
The production fix mutant (production_fix_mutant.patch) is a working, executed implementation of steps 2 and 3: under it the fix-verifying test passes, that is the 3,246,474,598 planck of quantization residue goes to zero, and the two-leaf split disappears with it.
The treasury leg (lib.rs:199) needs no change: T::Treasury::account_id() is a pallet account spendable through the treasury pallet, not a keyless wormhole beneficiary.
Was this helpful?