88566 bc high pr 656 incomplete remediation bundle level rounding under settles per leaf wormhole fees
Submitted on Aug 13th 2026 at 20:49:10 UTC by @CarlosMB for Audit Comp | Quantus
Report ID: #88566
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
PR #656 changed Wormhole settlement to use quantized-ceiling semantics:
fee_quanta = ceil(exit_quanta * bps / (10000 - bps))The remediation computes that ceiling once over the aggregate output of the exit bundle and states that this reproduces the minimum fee locked by the proofs.
That assumption is incorrect.
The Wormhole leaf circuit enforces the fee independently for every leaf:
(output_1 + output_2) * 10000
<= input_amount * (10000 - volume_fee_bps)At the production 4 bps rate, a leaf with 2q input can output at most 1q, where 1q = 0.01 QUAN:
Therefore every such leaf is forced by the circuit to leave a full 1q gap.
However, process_exit_bundle() first aggregates the successfully minted outputs and then calls volume_fee_for_exit() only once:
The proofs therefore enforce the equivalent of:
while the runtime settles:
These are not equivalent because:
can hold strictly.
As a result, valid proofs can permanently consume value as a protocol-enforced fee gap that is never included in burn, miner, or aggregator settlement.
This is an incomplete remediation of PR #656, not a claim that bundle-level rounding itself is undocumented.
Root cause
The leaf circuit applies its balance/fee inequality separately to each consumed input.
The runtime instead derives one fee from the total successfully minted output:
volume_fee_for_exit() performs only one ceiling:
PR #656 introduced this calculation to fix a previous bug where proof-locked fee value silently disappeared from burn/miner/aggregator allocation.
The remediation relies on:
but that only proves that bundle settlement does not exceed the amount locked across individual proofs. It does not prove that the complete proof-side minimum is settled.
The E2E below demonstrates a strict inequality.
Impact
Consider seven valid Wormhole leaves:
For every leaf, the ZK circuit prevents the user from outputting the complete 2q; 1q is the maximum valid output.
For one 7-leaf private batch:
This is not voluntary user underpayment or arbitrary change left behind: the circuit itself makes a 2q -> 2q exit invalid.
After successful settlement, all seven nullifiers are consumed. The 6q difference is:
not returned through an exit output;
not included in the runtime fee;
not burned;
not allocated to the miner;
not allocated to the public-batch aggregator; and
not recoverable by spending the original leaves again.
The audited production build uses:
and the audited runtime exposes verify_public_batch(), which ultimately uses the same process_exit_bundle() settlement path.
A full public batch can therefore contain 53 * 7 = 371 underlying leaves. For the same valid minimum-size pattern:
The public-batch figure is the scaled consequence of the same root cause demonstrated directly by the private-batch E2E below.
Why this is an incomplete remediation of PR #656
The existing bundle-level ceiling is publicly documented; this report does not rely on it being unexpected.
The issue is that PR #656 states that the new calculation reproduces the proof-side minimum while using the relation:
The E2E proves that the inequality can be strict:
Therefore the previous shortfall class remains reachable for multiple small leaves: value that the ZK constraints force to remain outside the outputs is still omitted from fee settlement.
This also conflicts with the documented 4-bps fee policy for small exits, where the circuit floor is 0.01 QUAN per nonzero small exit / pre-split chunk. Aggregating several such leaves should not make the individually enforced fee gaps disappear from settlement.
Proof of Concept
Runnable E2E PoC
The following test creates 7 real Wormhole leaves, generates 7 real Plonky2 leaf proofs, aggregates them into a private batch, passes both validate_unsigned() and pre_dispatch(), settles the batch through verify_private_batch(), and confirms that:
Add the test to:
Run:
Observed:
This E2E proves the root cause on a real private batch. The public-batch impact follows from the same settlement function and the production 53 × 7 aggregation configuration:
Recommended fix
The runtime and circuit must use the same rounding boundary.
One option is to authenticate and aggregate the fee contribution enforced for each real leaf and expose that total to settlement, so the runtime settles:
instead of:
Alternatively, change the ZK construction so that the fee constraint itself is enforced once over exactly the same aggregate value used by the runtime.
The critical requirement is that proof enforcement and runtime settlement use identical aggregation/rounding semantics.
Was this helpful?