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

90411 sc high same timestamp checkpoint corruption in withdraw causes theft through underpaid settled cover

Submitted on Aug 24th 2026 at 21:36:17 UTC by @Kalyantr for Audit Comp | Firelight

  • Report ID: #90411

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

FirelightVault.withdraw() is delayed: it queues assets for P+1, and claimWithdraw(P+1) is only possible in P+2. Pending withdrawals are meant to remain slashable during the payout window.

The issue is not an instant exit. The issue is that a withdrawal at the exact period‑start timestamp changes the historical cap used by payout() after the allocator has already committed capacity from the pre‑withdrawal value.

Vulnerability Details

Period‑P cover capacity is derived from the vault’s historical asset lookup at the period start T:

$.vault.totalAssetsAt($.vault.currentPeriodStart())

commitAllocation() validates capacity using that value, then stores only the derived capacity in the allocation commitment.

withdraw() is delayed: it queues assets for P+1 rather than transferring them immediately. However, it also records the post‑withdrawal active asset balance at Time.timestamp(). If commitAllocation() executes first and withdraw() is later in the same period‑boundary block, both calls use timestamp T.

The withdrawal writes a checkpoint at T that changes the later result of totalAssetsAt(T) from the pre‑withdrawal balance to the post‑withdrawal balance.

settleCoverOrder() uses the already stored commitment and does not re‑read vault assets, so cover can still be settled and its CoverNFT minted.

payout() later re‑reads totalAssetsAt(capturePeriodStart) as its exposure cap. Pending withdrawals remain in the payout source list, but paidAmount is already capped by the corrupted historical value. The result is an underpayment of settled cover while residual value remains with vault stakers.

Transactions included in the same EVM block share block.timestamp, the PoC asserts the commit transaction executes before the withdrawal transaction in that boundary block.

A normal withdrawal later in P leaves the period-start lookup unchanged and remains fully slashable through its pending-withdrawal bucket.

Impact Details

Selected impact: Critical — Direct theft of any user funds.

A valid settled cover claim can be underpaid by the amount removed from the period‑start exposure cap after capacity was committed.

In the PoC, A and B each deposit 100 assets. The allocator commits and settles 200 cover for P. At the start of P, commitAllocation() first observes totalAssetsAt(T) = 200. A then requests withdrawal of 100 in the same block, causing totalAssetsAt(T) to resolve to 100.

A later 200‑asset payout has 200 payable sources:

  • 100 active assets belonging to B

  • 100 pending withdrawal assets belonging to A

However, payout is capped at the corrupted 100 historical value and transfers only 100 to the protocol‑configured payout receiver. The 100 payment is drawn pro‑rata: 50 from B’s active assets and 50 from A’s pending withdrawal.

Result:

Without the same‑timestamp withdrawal, the cap remains 200 and the same claim pays 200, leaving both A’s pending withdrawal and B’s active assets at zero.

The unpaid 100 is a direct loss to the settled‑cover payout. It remains in staker‑side value rather than reaching the protocol’s configured payout recipient. payoutReceiver is not assumed to be the CoverNFT owner; it is the recipient configured by the protocol for valid vault payouts.

Proof of Concept

Proof of Concept

Add the below test case to test/FirelightVault

Run the command npx hardhat test test/FirelightVault/periodBoundaryPayout.poc.js --no-compile

The test uses the real FirelightVault, CoverOrderAllocator, and CoverNFT. It proves both:

  • whole-book limit: 100 committed/settled cover, same-block withdrawal changes the cap to zero, payout is zero

  • general case: A exits at T, B remains, 200 settled cover is capped at a 100 payout, and A later claims the 50 residual.

Recommendation

Use one strictly-before-timestamp exposure getter for both capacity calculation and payout:

Replace both reads:

This preserves generic totalAssetsAt() history while making every same-T state mutation irrelevant to period-P exposure.

Was this helpful?