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

89889 bc medium permissionless vesting claim strands a schedule in an un terminable state blocking the treasury s clawback of the final tranche

Submitted on Aug 21st 2026 at 17:22:45 UTC by @zealynx for Audit Comp | Quantus

  • Report ID: #89889

  • 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: any funded account can trigger a payout on any schedule. Its non-final cap guarantees the schedule retains a minimum-sized total remainder, but not that the already-vested and unpaid portion is either zero or at least MinimumPayout — and end_schedule rejects exactly that middle case. An unrelated account can therefore call claim at the right moment on a schedule the administrator is about to terminate, permanently denying the treasury its clawback of the final unvested tranche and blocking termination until natural maturity. The denied amount vests to the original beneficiary instead of returning to the treasury.

Vulnerability Details

Vesting::claim calls ensure_signed(origin) and accepts any signer for any schedule id.

For a non-final claim, claim_plan caps the payout at remaining - MinimumPayout, which ensures the schedule retains one minimum-sized total tranche. It does not ensure that the amount which is already vested but unpaid is either zero or at least MinimumPayout.

When the uncapped candidate exceeds that cap by less than one minimum payout, the claim succeeds and leaves the difference vested and unpaid. end_schedule independently rejects every non-zero unpaid vested amount below MinimumPayout:

ensure!(vested_paid.is_zero() || vested_paid >= T::MinimumPayout::get(), Error::<T>::PayoutBelowMinimum);

A schedule that was terminable immediately before the claim therefore becomes impossible to end.

The cap only binds when the candidate exceeds remaining - MinimumPayout, which requires total - vested < MinimumPayout. The unvested portion at that instant — the amount the treasury would have reclaimed — is consequently always less than one MinimumPayout, at any schedule size.

The condition is one-shot per schedule: after the capped claim, remaining == MinimumPayout, so any subsequent claim hits max_non_final = 0 < minimum and returns WouldLeaveDust. MinClaimInterval does not gate the attack either, because last_claim_at is None on a schedule that has never been claimed.

Vulnerable scenario

1

Create the schedule

A legitimate one-year schedule holds 100 QUAN for a beneficiary.

2

Trigger a permissionless claim

When 99.01 QUAN is vested, an unrelated account calls the permissionless claim before the administrator's termination call.

3

Strand the schedule

claim_plan pays the capped 99 QUAN, leaving a 1-QUAN total remainder but only 0.01 QUAN currently vested and unpaid.

4

Block termination

end_schedule rejects the 0.01-QUAN payout with PayoutBelowMinimum until natural maturity.

The attacker needs only an arbitrary funded account able to submit a signed extrinsic — no privileged origin, no relationship to the schedule, no knowledge of the beneficiary. (In the PoC, the only Root calls create the legitimate pre-existing schedule and model the administrator's failed termination — Root appears on the victim side only; the harmful claim is submitted by an unrelated account.)

Recommended fix. When the non-final cap would leave a non-zero, sub-minimum vested remainder, reduce the claim by enough to preserve at least MinimumPayout of the amount already vested; if that would make the claim itself smaller than MinimumPayout, reject it with ClaimWouldLeaveDust:

This can defer a near-maturity claim on a small schedule until the final claim, but preserves immediate administrative termination and avoids the inconsistent vested remainder.

Impact Details

Any signed account can deny the administrator's immediate clawback of the final unvested tranche and block schedule termination for a bounded window — a permissionless griefing of the treasury's clawback path, with no profit to the attacker.

  • Amount denied to the treasury: bounded by MinimumPayout - PayoutQuantum. With production values (MinimumPayout = 1 QUAN, PayoutQuantum = 0.01 QUAN) that is 0.99 QUAN, and it is bounded below one MinimumPayout by construction regardless of schedule size. Without mitigation it vests to the original beneficiary at maturity — it is never returned to the treasury.

  • Duration termination is blocked: duration × (MinimumPayout − PayoutQuantum) / total. The demonstrated 100-QUAN one-year schedule blocks termination for ~3.6 days; the window scales inversely with grant size, so larger grants are blocked for proportionally less time (minutes for large grants).

  • Partial mitigation available to the admin: retargeting the schedule to the treasury prevents the beneficiary capturing the remainder, but still cannot recover the tranche or end the schedule before maturity.

  • Attacker prerequisites: one signed extrinsic from any funded account.

Selected in-scope impact: Griefing — an unprivileged actor damages the protocol/treasury (a permanently-denied sub-MinimumPayout clawback plus a bounded termination block) with no profit motive.

References

Frozen revision: quantus-chain @ 3b243b870a5442a6e5f443056f67bbba87d472f5

  • https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/vesting/src/lib.rs#L319-L350 — claim, ensure_signed result discarded

  • https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/vesting/src/lib.rs#L401-L442 — end_schedule, the PayoutBelowMinimum guard

  • https://github.com/immunefi-team/audit-comp-quantus-chain/blob/3b243b870a5442a6e5f443056f67bbba87d472f5/pallets/vesting/src/lib.rs#L528-L560 — claim_plan, the non-final cap

Proof of Concept

Runs entirely locally. No test was performed against mainnet or any public testnet. The test is added to the existing runtime/tests/governance/vesting.rs (which supplies the account(), new_test_ext(), set_time() helpers) and uses production runtime constants.

1

Control

Create a 100-QUAN one-year schedule, advance to 99.01% vested, and end_schedule — the treasury correctly gets 0.99 QUAN back.

2

Attack

Use the identical schedule, but have an unrelated account(8) call claim first. It pays the capped 99 QUAN, leaving 0.01 QUAN vested-unpaid. end_schedule now fails with PayoutBelowMinimum — at 99.01%, one millisecond before maturity, and only succeeds at maturity, by which point the remainder has vested to the beneficiary. The treasury never recovers it.

3

Retarget mitigation

Even after retargeting the schedule to the treasury, end_schedule still fails until maturity.

Apply this patch and run:

Observed output

With the recommended fix applied, the unchanged test fails at the first expected PayoutBelowMinimum (because end_schedule now succeeds), pinning the harm to the claim_plan cap. A sweep over grants of 2–8 minimum payouts and every quantized vesting point confirms every successful claim then leaves end_schedule immediately usable, and the existing Vesting suite stays green.

Was this helpful?