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

89854 sc high same block withdrawal rewrites committed period start assets and makes valid cover payouts zero

Submitted on Aug 21st 2026 at 14:23:06 UTC by @Shutaru for Audit Comp | Firelight

  • Report ID: #89854

  • 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:

    • Protocol insolvency

Description

Severity and impact

Suggested severity: Critical — protocol insolvency.

A vault depositor can place a normal withdrawal after commitAllocation in the first block of a cover period. When both transactions have the exact period-start timestamp, the withdrawal replaces the totalAssets checkpoint that the allocator already used to commit cover capacity. A later valid incident then pays zero, even though the withdrawal remains pending and the depositor can claim all of it after the cooldown.

The depositor does not need a privileged role. The allocator, curator, and assessment approver follow their intended paths. The race requires the allocation commitment and withdrawal to land in that order in the exact period-boundary block.

Root cause

FirelightVault records totalAssets with block.timestamp as the checkpoint key:

Checkpoints.Trace256.push does not append a second checkpoint when the key matches the latest key. It replaces the stored value:

The allocator calculates period capacity from the checkpoint at currentPeriodStart():

Payout later reads the same historical timestamp and treats it as an exposure cap:

The value is therefore consumed as immutable history during allocation but remains mutable for the rest of that block.

Affected locations:

  • contracts/core/lib/Checkpoints.sol:198-214

  • contracts/core/FirelightVault.sol:755-775

  • contracts/core/FirelightVault.sol:911-930

  • contracts/core/FirelightVault.sol:1044-1055

  • contracts/core/CoverOrderAllocator.sol:696-719

End-to-end exploit

The PoC uses the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Only the underlying token and price feed are local test dependencies.

  1. The depositor deposits 100 units into the vault.

  2. Automining is disabled and the next block timestamp is set to the next period start.

  3. The allocator submits a valid commitment for 100 units of cover capacity.

  4. The unprivileged depositor submits a withdrawal for all 100 units with lower transaction priority. Both transactions are mined in the same block; the PoC asserts that the commitment precedes the withdrawal.

  5. The commitment stores totalAvailableCapacity = 100 and totalDeclaredAllocated = 100.

  6. The later withdrawal replaces the checkpoint at the period-start timestamp. totalAssetsAt(periodStart) changes to zero, while withdrawAssets[nextPeriod + 1] contains the full 100 units.

  7. A cover order is settled and a real CoverNFT is issued.

  8. The curator creates and confirms an incident, adds the covered loss, and submits the assessment.

  9. The assessment approver follows the normal approval path. The vault emits PayoutExecuted(receiver, 100, 0, periodStart), the incident records vaultPaidAmount = 0, and its status becomes CLOSED. A second approval attempt reverts with InvalidIncidentStatus, so the unpaid amount cannot be retried through the assessment flow.

  10. After the cooldown, the depositor claims the full 100 units.

The negative control moves the withdrawal out of the boundary timestamp. In that case totalAssetsAt(periodStart) remains 100 and the vault pays the full 100-unit claim. This isolates equal-timestamp checkpoint replacement as the cause.

Practical feasibility

The period boundary is deterministic and public. A depositor can watch for the allocator's commitment and backrun it in the same boundary block. This is not a Hardhat-only timestamp condition: a read-only check of the deployed Phase 1 vault and recent Flare block history found blocks whose timestamps exactly matched 28 of the previous 30 scheduled daily period starts. Many of those boundary blocks contained multiple transactions.

The observation was made without changing chain state. The scoped proxy is:

Do not use a timestamp checkpoint as mutable history after allocation has consumed it. Capture the vault asset value once for the period or commitment, store it immutably, and bind both allocation capacity and payout to that same value.

If the intended value is the state before any transaction in the new period, derive it from the last checkpoint strictly before the boundary and store it once. If deposits at the boundary should count, have the allocator create a one-time snapshot when it commits. In either design, a later transaction with the same timestamp must not change a snapshot already used to underwrite cover.

Add a regression test that commits allocation and then processes a withdrawal in the same boundary block. The payout cap must remain equal to the value used by the commitment.

Scope and evidence notes

  • No privileged role is assumed malicious.

  • No mainnet transaction was sent.

  • The PoC does not claim that the depositor steals the payout. It proves that committed slashable backing can escape while a valid covered claim receives zero.

  • The exact timing precondition is stated rather than hidden: the commitment and withdrawal must execute in order in a block timestamped at the period boundary.

Proof of Concept

Proof of Concept

This runnable Hardhat test uses the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Only the underlying ERC-20 and price-feed dependencies are local test doubles.

Run from the frozen competition repository:

Expected result:

The full suite passes with this file included: 450 passing.

The positive case asserts all of the following:

  1. A 100-unit allocation commitment executes before the withdrawal in the same period-boundary block.

  2. The commitment records 100 units of capacity and declared allocation.

  3. The later unprivileged withdrawal changes totalAssetsAt(periodStart) from 100 to zero while the same 100 units remain queued in withdrawAssets.

  4. A real cover order settles and mints a CoverNFT.

  5. A confirmed incident and approved assessment request a 100-unit payout.

  6. The vault emits PayoutExecuted(receiver, 100, 0, periodStart).

  7. The incident becomes CLOSED with vaultPaidAmount = 0; another approval attempt reverts with InvalidIncidentStatus.

  8. The payout receiver receives zero, while the withdrawing depositor later claims all 100 units.

The negative control moves only the withdrawal timestamp past the period boundary. The historical snapshot remains 100 and the same payout transfers all 100 units.

Test file

Was this helpful?