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

88671 bc medium permissionless vesting claim forces payout fragmentation leading to loss of beneficiary funds

Submitted on Aug 14th 2026 at 12:57:06 UTC by @zealynx for Audit Comp | Quantus

  • Report ID: #88671

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

Brief/Intro

Vesting::claim is permissionless and pays the largest amount vested at call time. Every payout becomes a separate Wormhole leaf, and each leaf independently loses value to circuit rounding. An unrelated account can therefore call claim once per permitted interval to fragment a beneficiary's grant into many small leaves, multiplying the rounding loss. The beneficiary permanently receives less than their grant, the loss is irreversible, and the attacker needs nothing but transaction fees and no relationship to the schedule.

Vulnerability Details

Vesting::claim calls ensure_signed(origin) and deliberately permits any signer to claim any schedule. claim_plan then pays the largest amount vested at that moment.

Every payout becomes a separate Wormhole leaf quantized to 0.01 QUAN. A Wormhole leaf must satisfy:

(output * 10_000) <= (input * 9_996)

Rounding occurs independently for every leaf. Fragmenting one payout into many therefore multiplies the rounding loss rather than amortising it.

Aggregation cannot recover it. The inequality is enforced inside each leaf circuit before private-batch aggregation. The batch circuit may group identical exit accounts and sum their outputs, but it only ever sums outputs that already satisfied the per-leaf inequality — so batching fragmented leaves cannot restore what each leaf already rounded away.

Because the claim is permissionless, the choice of fragmentation belongs to the attacker, not the beneficiary. The one-day MinClaimInterval sets the maximum fragmentation rate but does nothing to prevent it: for a linear 365-day, 365-QUAN schedule an attacker calls once per day and forces 365 one-QUAN leaves, each able to output at most 0.99 QUAN. Claimed once at completion, the same grant is a single leaf able to output 364.85 QUAN.

Vulnerable scenario:

1

A beneficiary has a 365-day linear grant of 365 QUAN

2

An unrelated signed account calls Vesting::claim(schedule_id) after every 24-hour interval

3

The pallet records 365 leaves of 1 QUAN instead of one leaf of 365 QUAN

4

Independent per-leaf rounding reduces the combined maximum output from 364.85 to 361.35 QUAN

Attacker authority

Only an arbitrary funded account able to submit a signed extrinsic. The caller need not be the beneficiary, schedule creator, treasury, administrator, or a governance member. The RuntimeOrigin::root() calls in the PoC construct identical legitimate victim schedules as test fixtures only — every harmful daily claim is submitted by unrelated account(8).

Relationship to the other permissionless-claim finding

This shares only its precondition — Vesting::claim accepting any signer — with the separate report on blocked schedule termination. They are distinct vulnerabilities with different victims, mechanisms and fixes, and neither fix resolves the other:

This report
The termination report

Victim

the beneficiary

the treasury

Mechanism

repeated claims fragment the grant into many Wormhole leaves; per-leaf rounding compounds

a single claim leaves a sub-minimum vested remainder

Attacker calls

once per interval, repeatedly

exactly once, at a chosen vesting point

Loss

~3.50 QUAN of a 365-QUAN grant, destroyed

< 1 MinimumPayout, denied to the treasury

Fix

constrain who may claim, or amortise leaf rounding

correct the claim_plan non-final cap

Capping claim_plan (the other report's fix) does not prevent fragmentation; constraining the caller here does not repair the dust remainder that blocks termination.

Impact Details

Irreversible loss of a beneficiary's vested funds, inflicted by any third party.

  • Amount at risk: an additional 3.50 QUAN destroyed on a 365-QUAN grant — the maximum withdrawable falls from 364.85 QUAN to 361.35 QUAN. The loss scales with the number of forced fragments, i.e. with schedule duration.

  • Who bears the loss: the beneficiary. The funds are not transferred to the attacker; they become permanently unwithdrawable through the Wormhole exit path.

  • Reversibility: none. Each leaf's rounding is fixed at the moment the payout is recorded, and aggregation provably cannot recover it.

  • Attacker prerequisites: a funded account and transaction fees. No control over the schedule, the beneficiary, or any privileged origin.

  • Profit motive: none — this is griefing. The attacker pays fees to destroy another user's funds, which is why it is filed as Medium rather than higher.

References

Frozen revision: quantus-chain@3b243b870a5442a6e5f443056f67bbba87d472f5

  • pallets/vesting/src/lib.rs#L319-L350 — claim, ensure_signed with the result discarded

  • pallets/vesting/src/lib.rs#L530-L559 — claim_plan, pays the largest currently-vested amount

  • runtime/src/configs/mod.rs#L562-L571 — VestingMinClaimInterval, VestingPayoutQuantum, VestingMinimumPayout

  • Wormhole leaf fee inequality (output * 10_000) <= (input * 9_996), enforced per leaf in the withdrawal circuit before private-batch aggregation

Proof of Concept

Environment

Runs entirely locally. No test was performed against mainnet or any public testnet.

Requires a Rust toolchain matching the repository's rust-toolchain and SKIP_WASM_BUILD=1 (native-only test build). The test is added to the existing runtime/tests/governance/vesting.rs, which already supplies the account(), signers(), treasury_multisig() and new_test_ext() helpers it uses. No RPC endpoint, no fork, no external service.

Use frozen chain commit 3b243b870a5442a6e5f443056f67bbba87d472f5. Save and apply this patch from the repository root:

The test uses the production runtime configuration, records actual ZK-tree leaves, and compares identical schedules whose only difference is the unrelated caller's claim cadence. RuntimeOrigin::root() only establishes the victim's legitimate pre-existing vesting schedule; the attack itself is every Vesting::claim(RuntimeOrigin::signed(account(8)), 0) call, made by an unrelated ordinary account with no administrative or treasury authority.

Observed output:

Baseline:

After applying the recommendation below, the unchanged harm-mode PoC fails at the first daily claim with NothingToClaim. A positive sweep comparing periodic and final-only claims for seven schedule shapes produced identical maximum outputs in every case. The complete runtime Vesting suite passed 7/7 and cargo test -p pallet-vesting passed 58/58.

Recommendations

Quantize every non-final payout to the smallest multiple of PayoutQuantum whose Wormhole fee is an exact number of leaf quanta, while preserving an exact final payout for the remainder. At 4 bps and a 0.01-QUAN leaf quantum, that alignment is 2,500 leaf quanta, or 25 QUAN.

The core of the verified remediation is:

Was this helpful?