89348 bc high mining reward s zk tree leaf floors the amount freezing the remainder
#89348 [BC-High] Mining reward's zk-tree leaf floors the amount, freezing the remainder
Submitted on Aug 18th 2026 at 18:19:05 UTC by @AlexSR for Audit Comp | Quantus
Report ID: #89348
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of unclaimed yield
Description
Mining rewards are minted at full precision but their zk-tree leaf commits only the floored amount, so the remainder is permanently unclaimable
Summary
pallet-mining-rewards mints the miner's reward at full u128 precision and records that same exact amount as a wormhole transfer proof (pallets/mining-rewards/src/lib.rs:220-237). The proof's authoritative commitment is the zk-tree leaf hash, and that hash commits floor(amount / 10^10), not amount (pallets/zk-tree/src/tree.rs:146-149). Exits reconstruct value the other way, as summed_output_amount * 10^10 (pallets/wormhole/src/lib.rs:792-793).
So for a reward amount = q*S + r with S = 10^10 and 0 < r < S, the miner is credited q*S + r at an address whose only claim instrument is a withdrawal proof, and the largest proof that address can ever produce for that credit is worth at most q*S (the circuit's volume-fee relation puts the practical ceiling below that, pallets/wormhole/src/lib.rs:1066-1076). The r planck is earned yield the miner can never claim by any permissionless action.
The recipient is keyless by construction. on_finalize derives the author address from the 32-byte QPoW pre-runtime preimage as poseidon(preimage) (mining-rewards/src/lib.rs:205-207, primitives/wormhole/src/lib.rs:97-110,122,146-147); that preimage is itself the inner digest poseidon(salt || secret). A signable account id is a different derivation over a different input, poseidon(dilithium_public_key) (primitives/dilithium-crypto/src/scheme_macro.rs:238-245). A miner cannot mine to an account it can sign for, so the withdrawal proof is the only exit.
The project already defends the same hazard on its other keyless-recipient payout path, and says why in its own words at three separate sites. pallet-vesting rounds every payout down to the leaf quantum because "a payout below one quantum would create a zero-value leaf: funds moved to a keyless beneficiary would be irrecoverable" (pallets/vesting/src/lib.rs:147-152). The same reasoning routes early-termination dust to the treasury rather than the beneficiary (:401-407, :422-433) and backs a compile-time assert that no payout may exceed the leaf's u32 ceiling (runtime/src/configs/mod.rs:584-591).
Mining quantizes nothing, and its recipient is keyless.
Verified on the frozen in-scope tree (commit 3b243b87); both defect files are fork-authored Quantus code.
Root cause
mint_reward credits first and records second, with no alignment step between the two. The call arguments are folded onto one line here for width; the source spreads them across :232-237:
(mining-rewards/src/lib.rs:230-237.)
Nothing downstream aligns it. Wormhole::record_transfer_proof rejects only an exact zero and passes every other native amount straight through (pallets/wormhole/src/lib.rs:1354-1368), and record_transfer stores the raw planck value in the leaf struct before hashing (pallets/wormhole/src/lib.rs:1296-1310, pallets/zk-tree/src/lib.rs:259,283-288).
The loss happens in the hash, which divides by AMOUNT_SCALE_DOWN_FACTOR = 10^10 and commits the u32 quotient (pallets/zk-tree/src/tree.rs:146-149; the factor is at :34). The project's own doc states the split: "The amount stored on-chain is the raw planck value. When hashing for the ZK circuit, amounts are quantized by dividing by 10^10 [...]" (docs/zk-trie-architecture.md:422; the sentence continues "to fit in a single field element with 2 decimal places of precision").
The 2-decimal wire format is not the bug. The bug is that an origination path mints an amount the wire format cannot represent, to a recipient whose only spend instrument is that format.
The reward is not aligned by any explicit step. total_reward = remaining_supply / EmissionDivisor with EmissionDivisor = 15_163_560 (mining-rewards/src/lib.rs:155-157, runtime/src/configs/mod.rs:144), then a Permill::mul_floor split (mining-rewards/src/lib.rs:161-162). Neither step produces a multiple of 10^10 except by coincidence.
Why the remainder is permanently frozen
The leaf cannot represent it. hash_leaf(amount) and hash_leaf(floor(amount/S)*S) are the same 32 bytes, so the commitment is blind to r. A withdrawal proof is checked against that commitment and the exit converts the proven output back with saturating_mul(SCALE_DOWN_FACTOR) (pallets/wormhole/src/lib.rs:792-793), so no proof against that leaf can pay out more than q*S. The attached leaf_commitment_remainder_poc.rs shows this directly.
The remainders never combine. Each credit gets its own leaf, keyed (recipient, transfer_count) (pallets/wormhole/src/lib.rs:1296-1310), and each is floored independently. on_finalize makes up to two separate miner credits per block, one for collected fees and one for the block reward (mining-rewards/src/lib.rs:193, :196; the fee call is skipped when the block collected none, since mint_reward returns on a zero amount at :221-223), so a block can strand two remainders. Thousands of them never sum to a claimable quantum: no leaf holds the sum.
The balance is not a fallback. The address is keyless, so no signed Balances transfer exists, and an exit does not spend it either: the exit issues the claim to the exit account with increase_balance and never debits the address the leaf was recorded against (pallets/wormhole/src/lib.rs:828-833). The leaf is the spend instrument; the balance is its locked counterpart. pallet-sudo was removed from this runtime (runtime/src/lib.rs:232), so recovery needs a privileged pallet-balances call through Root governance, such as force_transfer or force_set_balance, or a set_code migration. None of those is a permissionless action available to the miner, and the runtime keeps no ledger separating residue from the counterpart of unexited leaves.
Attack path
No attacker and no extrinsic. It is the ordinary reward path: any block whose miner credit is not an exact multiple of 10^10 strands the difference, and nothing in the emission arithmetic aims for that multiple.
Impact
Permanent freezing of a slice of the miner's unclaimed yield, on every credit that is not quantum-aligned.
Per credit the frozen amount is bounded by one quantum, r < 10^10 planck = 0.01 QUAN (zk-tree/src/tree.rs:34, runtime/src/lib.rs:97). The rate is what matters. At TARGET_BLOCK_TIME_MS = 12_000 (runtime/src/lib.rs:87) the chain produces 2,628,000 blocks a year and each block strands up to two remainders, so the block-reward arm alone loses on the order of 10^4 QUAN a year, ceiling 26,280 QUAN.
The emission budget is charged for it: total_reward is derived from T::Currency::total_issuance() (mining-rewards/src/lib.rs:143), which counts the stranded planck.
As a fraction of the miner's own yield it is a few tenths of a percent per credit at launch-era supply. The real on_finalize, run in the pallet mock at the shipped existential deposit with the block's collected fees added back, credits a 692,449,528,904 planck reward and strands 2,449,528,904 of it, 0.354%. That fraction grows over the life of the chain: the reward decays with remaining supply while r stays bounded by a fixed 10^10. Individual blocks vary, since r = miner_reward % 10^10 takes any value in that range, zero included. This report is scoped to the above-minimum stretch of the emission schedule, where the reward sits well above the quantized minimum and only its tail is lost. The chain operates in that stretch today, and will for a long time to come.
The direction is a freeze, not theft or inflation. No user principal is affected and no state is corrupted.
Severity self-assessment
The governing rung, quoted verbatim from the Blockchain/DLT ladder:
Permanent freezing of unclaimed yield
Any miner credit that is not an exact multiple of 10^10 strands its remainder at an address with no key and no second instrument to claim it with, and nothing on the mining path aims for that multiple. The quantity is unclaimed mining yield and the loss is permanent, so the finding lands on High.
Why known issue 3 does not reach this
The clause mentions:
At some point the volume of transactions will cause the zk-tree depth to exceed the current maximum, both on chain and in circuit. When that moment approaches we will do a runtime upgrade to update both the circuit and the chain. A similar reasoning works for when / if mining rewards ever drop well below the quantized minimum for the circuit.
Its subject is the reward and its predicate is that the reward drops well below the quantized minimum. That is false of every credit in this report. The block reward at launch-era supply commits 69 whole quanta, and the finding is that a reward far above the minimum still loses its tail.
The two cases also need different changes, and that is what separates them. The clause promises "a runtime upgrade to update both the circuit and the chain". An increase in precision is sufficient for the disclosed case: once the quantum sits below the reward, floor(reward / S) stops being zero and the credit becomes representable again. It is not sufficient here. This loss is reward % S, and no smaller S guarantees that is zero, because nothing on the mining path aligns the credit to S. Only S = 1 makes it identically zero, and that is not a retune of the constant: the leaf commits the quantized amount as a u32 (pallets/zk-tree/src/tree.rs:138-148) under a compile-time assert that MAX_SUPPLY < u32::MAX * AMOUNT_SCALE_DOWN_FACTOR (runtime/src/configs/mod.rs:588-591), so dropping quantization altogether means widening the circuit's amount field. Aligning the credit before it is minted removes the loss at any S and touches no circuit at all.
Proof of Concept
Five drop-in unit tests across the two target pallets, each extending that pallet's own test suite and, where it touches runtime state, driving that pallet's own mock. mining_reward_remainder_poc.rs appends to pallets/mining-rewards/src/tests.rs, extending miner_reward_works (tests.rs:8) and miner_reward_with_transaction_fees_works (tests.rs:40) and reusing their mock and helpers. leaf_commitment_remainder_poc.rs appends to pallets/zk-tree/src/tests.rs and drives the pallet's own insert_leaf, the same function the wormhole's record_transfer calls (zk-tree/src/lib.rs:352-359) and the same one that suite's insert_first_leaf_works (tests.rs:182) drives. Every symbol used already exists in those two test builds; nothing is authored that either suite already provides.
The mining-rewards tests raise that mock's existential deposit to the shipped MILLI_UNIT = 1e9 (runtime/src/lib.rs:98,102) rather than its default of 1, so the issuance the reward is computed from is the shipped magnitude. The zk-tree mock has no balances pallet and derives the reward arithmetically from the same shipped constants. The two constants the finding turns on need no adjustment: the mock's MaxSupply = 21_000_000 * UNIT and EmissionDivisor = 15_163_560 (mining-rewards/src/mock.rs:38-39) are identical to the runtime's (runtime/src/lib.rs:105, configs/mod.rs:143-144). Full parity table in RUN.md.
poc_block_reward_credit_exceeds_what_its_leaf_can_ever_claimruns the realon_finalize(lib.rs:129) and asserts the claim across the whole collection of the miner's recorded proofs: every credited planck was recorded, yetsum(floor(a/S) * S)is strictly less than the total credited, and the entire shortfall equalsblock_reward % 10^10. An aligned fee credit of exactly four quanta is the control: it rides along in the same call and loses nothing.poc_fee_credit_can_strand_one_planck_short_of_a_full_quantumpins the per-credit maximum:3*10^10 + 9_999_999_999planck commits 3 quanta and strands 9,999,999,999.poc_tree_root_cannot_distinguish_the_sub_quantum_remainderworks at the level a withdrawal is checked. Recording the full credit and recording only its floored part leave the real tree with a byte-identical root. The control is the next assertion: one more whole quantum does move it.poc_stored_leaf_keeps_the_remainder_the_commitment_dropspins the divergence inside one state:Leavesholds the full amount while the hash over that same leaf equals the floored amount's.poc_remainders_never_combine_into_a_claimable_quantumshows two credits stranding just under a quantum each commit 1 + 1 quanta, not the 3 the same total would commit as one credit.
A disclosed stub: the mining-rewards mock wires type ProofRecorder = MockProofRecorder (mock.rs:129,172), a recording stub rather than the live wormhole recorder. The first two tests therefore establish what the miner is credited and what amount reaches the recorder; the zk-tree tests run the real tree and establish what a recorded amount commits to. Neither file claims the other's half.
Suggested fix
Round the miner's credit down to the leaf quantum before minting and carry the remainder forward, the shape pallet-vesting ships for its keyless beneficiaries (quantize_down, vesting/src/lib.rs:562-567). At the top of mint_reward, take remainder = reward % T::LeafQuantum::get() only when the recipient is a miner and the quantum is non-zero, subtract it from the credit before mint_into, and add it to CollectedFees. The treasury payout is left exact, following the project's own reasoning that dust is safe on a signature-controlled account and only stranded on a keyless one (vesting/src/lib.rs:404-406). CollectedFees is the right carry bucket: the pallet already treats its contents as "already-scheduled supply" that the remaining-supply calculation adds back (mining-rewards/src/lib.rs:134-141), so the emission schedule is unchanged.
quantize_mining_reward_before_mint.patch (attached) is this change plus a runtime binding of LeafQuantum straight to pallet_zk_tree::tree::AMOUNT_SCALE_DOWN_FACTOR, following the pattern VestingPayoutQuantum sets, though vesting anchors on pallet_wormhole::SCALE_DOWN_FACTOR (:563-567) which the runtime asserts equal to the zk-tree factor (:574-581). It is forkless, and already-stranded residue is reconstructible from the MinerRewarded { miner, reward } history (mining-rewards/src/lib.rs:238-241).
A guard at the recorder instead would be worse than nothing: the mint has already happened by then, so rejecting the credit would suppress the whole leaf and strand the entire reward, not just its tail.
mining_reward_remainder.rs
leaf_commitment_remainder_poc.rs
Was this helpful?