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

88951 bc high batch level fee rounding permanently strands fees locked by wormhole leaf proofs

Submitted on Aug 15th 2026 at 21:39:05 UTC by @Porcupine17126 for Audit Comp | Quantus

  • Report ID: #88951

  • 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

Each Wormhole leaf proof independently constrains its quantized input and outputs:

(output_1 + output_2) * 10000 <= input * (10000 - fee_bps)

At the production fee rate of 4 bps, a one-quantum exit therefore requires a two-quantum input. The leaf consumes one full fee quantum (0.01 QUAN).

The private-batch circuit verifies and aggregates up to seven leaf proofs, but its public inputs do not retain each leaf's private input_amount or fee gap. After verifying the batch, process_exit_bundle() sums all successful outputs and calculates the fee only once:

ceil(sum(outputs) * fee_bps / (10000 - fee_bps))

This is not generally equal to the fee that the independently constrained leaves had to lock:

ceil(sum(outputs) * rate) < sum(ceil(each_output * rate))

The omitted amount is not minted to an exit account, burned, paid to the miner, or rebated to an aggregator. Because the valid leaf nullifiers are permanently consumed, that value remains in the spent Wormhole deposits and cannot be recovered.

Root cause

The leaf circuit in qp-wormhole-circuit enforces the fee relation separately for every leaf. For any leaf output o, the minimum input is ceil(o * 10000 / (10000 - bps)), so the minimum fee gap is:

The scoped runtime correctly mints each aggregated output, but then reduces the bundle to one minted_exit_amount and calls volume_fee_for_exit() once:

volume_fee_for_exit() performs a single div_ceil() on the summed output. The current comment says this is “exactly the minimum fee gap the proofs lock,” but that is true only for a single leaf. A sum of ceilings is generally larger than a ceiling of the sum.

The production private-batch size is seven leaves (QP_NUM_LEAF_PROOFS=7). Each valid leaf has already been constrained independently before aggregation, so padding or proof validity does not close the gap.

Minimal reachable example

1

Leaf A

Leaf A proves input 2 quanta and output 1 quantum. It necessarily locks 1 fee quantum.

2

Leaf B

Leaf B independently proves input 2 quanta and output 1 quantum. It necessarily locks 1 fee quantum.

3

Aggregate proofs

The production private-batch prover aggregates the two valid leaf proofs.

4

Mint exits

The runtime mints both one-quantum exits.

5

Settle fee

Runtime fee settlement calculates ceil(2 * 4 / 9996) = 1 quantum.

6

Consume nullifiers

Both real nullifiers are consumed.

Two fee quanta were unavoidably left behind by the leaf constraints, but only one is distributed. The other 0.01 QUAN is permanently omitted.

For a full seven-leaf private batch of minimum exits, the leaf constraints lock seven fee quanta while the runtime settles one, omitting six quanta (0.06 QUAN). The loss is repeatable and cumulative across ordinary fragmented exit batches; it does not require malformed proofs, invalid public inputs, a privileged actor, or network probing.

Impact

The omitted value was locked by valid users' spent deposits specifically because of the protocol's per-leaf fee constraint. After the nullifiers are accepted, nobody can create another exit for those deposits. Yet the runtime allocates only the smaller bundle-level amount to the fee recipients.

The strict rounding difference is permanently inaccessible after the underlying nullifiers are consumed. The portion allocated by protocol policy to the miner or aggregator is permanently frozen unclaimed yield; the remaining portion is fee value that should have been burned but instead remains stranded in the consumed Wormhole inputs. The closest listed impact is High, “Permanent freezing of unclaimed yield.”

This report does not claim attacker profit, direct theft, protocol insolvency, or a consensus failure.

Relationship to the public fee-rounding fix

PR #656 / commit 7d7c4bd9 publicly fixed a different rounding error: base-unit truncation caused even a single minimum exit to settle less than one quantum. The change introduced the current quantized div_ceil() calculation.

This report is the unhandled aggregation case in that remediation. The PR correctly states ceil(sum) <= sum(ceil), but treats that inequality only as proof that the bundle calculation cannot overcharge. It does not account for the strict-inequality case, where the difference is value that every underlying leaf was forced to leave behind but the runtime never distributes. The current source still calls the bundle result “exactly the minimum fee gap the proofs lock,” which the PoC disproves for two valid leaves.

The live competition rules state that a bypass of a public fix is a new valid bug. The listed known issues and bundled audit reports do not disclose cross-leaf fee loss after aggregation.

1

Expose proof-locked fees

Expose each leaf's constrained fee gap, or an authenticated sum of those gaps, in the leaf proof's public inputs.

2

Aggregate fees recursively

Recursively sum that value in private- and public-batch circuits.

3

Settle the authenticated aggregate

Have process_exit_bundle() burn/distribute the authenticated aggregate fee sum for the valid processed segments.

Alternatively, redesign the circuit so the fee is constrained once at the same aggregation level at which the runtime settles it. Recomputing a single ceiling from final aggregate outputs cannot recover per-leaf rounding already imposed by the leaf constraints.

Proof of Concept

Apply the following patch to scoped commit 3b243b870a5442a6e5f443056f67bbba87d472f5 and run:

The PoC inserts canonical leaves directly only to avoid unrelated deposit setup. The leaf structure, hash, Merkle path, circuit verification, aggregation, runtime verification, settlement, and nullifier consumption are all production implementations; replacing insertion with the ordinary recorder produces the same leaf consumed by the proof.

poc.patch

Focused cryptographic output

Clean pallet-suite output

Was this helpful?