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

90488 sc high exact boundary withdrawal retroactively shrinks the period snapshot after

Submitted on Aug 25th 2026 at 09:17:54 UTC by @jesse03 for Audit Comp | Firelight

  • Report ID: #90488

  • 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

Root cause

The allocator and the vault both rely on the same historical lookup, totalAssetsAt(periodStart), but they use it at different times for different safety decisions:

// CoverOrderAllocator._computeAvailableCapacity()
$.vault.totalAssetsAt($.vault.currentPeriodStart())
// FirelightVault.payout()
uint256 assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart);

That snapshot is not immutable during the exact block whose timestamp equals currentPeriodStart().

FirelightVault.totalAssetsAt() is only:

and withdraw() / redeem() both end by writing a fresh total-assets checkpoint through _logTrace(..., true):

Therefore, if commitAllocation() executes early in the first block of a new period and a large withdrawal executes later in that same block, the exact same query totalAssetsAt(currentPeriodStart()) can resolve to:

1 - a higher value during commit, when capacity is validated;

2 - a much lower value later, when incident payouts for that period are capped.

The bug is not that settlement uses stale collateral in the abstract. The bug is that the protocol treats capturePeriodStart as a stable historical boundary, but that boundary remains mutable for the rest of the opening block.

Exploit

This issue is independent from the broader cross-period. It does not require:

  • a prior-period incident;

  • first-loss-buffer depletion;

  • reusing one collateral pool across two underwriting periods.

A single same-block boundary withdrawal is enough

Assume:

1 - The first block of period P is mined with timestamp T = currentPeriodStart()

2 - Early in that block, the allocator performs its normal commitAllocation() for period P

3 - _computeAvailableCapacity() reads vault.totalAssetsAt(T) before any boundary-block withdrawal checkpoint has been written, so the read still reflects the pre-withdrawal value of 10,000,000

4 - The commitment is stored with totalAvailableCapacity = 10,000,000

5 - Later in the same block, a large shareholder calls withdraw() or redeem() for 9,900,000

6 - The vault burns shares, moves those assets into pending-withdrawal accounting, and pushes a new _traceTotalAssets checkpoint at the same block timestamp T with only 100,000 active assets remaining

7 - From that point onward, totalAssetsAt(T) resolves to 100,000, not 10,000,000

8 - After the grace period, settleCoverOrder() still settles orders against the already stored commitment, because settlement checks only the committed Merkle tree and the stored commit.totalAvailableCapacity; it never revalidates the live or historical vault snapshot

9 - A legitimate incident is later created for period P, and approveCurrentAssessment() triggers payout

10 - FirelightVault.payout() now computes:

even though the same period's cover was matched using a committed capacity based on 10,000,000

The resulting mismatch is:

The payout path uses:

The critical point is that activePayableAmount can still be much larger than assetsAtCapturePeriod, because the withdrawal request remains slashable through withdrawAssets[capturePeriod + 1] during the payout window.

So the shortfall is not merely "the user withdrew first." The vault can still count those assets as payable capital, but the mutable historical lookup retroactively shrinks the cap that is supposed to represent the period's insured asset base.

Example:

IncidentManager.approveCurrentAssessment() still closes the incident after _executePayout(), even when the vault pays only a fraction of the approved loss.

No privileged actor has to behave incorrectly

The allocator's action is the protocol's intended opening-period workflow: commit once for the current period using the state visible at that moment.

The state corruption happens afterward because an ordinary user withdrawal in the same boundary block changes what future totalAssetsAt(currentPeriodStart()) lookups return. The protocol itself assumes that value is a stable period-start snapshot, but it is not.

Impact

The protocol can

1 - commit and settle current-period cover using a large start-of-period asset base

2 - later cap claims from that same period using a much smaller retroactively mutated asset base

3 - permanently underpay or even zero-pay valid covered losses from already matched orders

This is protocol insolvency caused by inconsistent accounting of the same period's collateral. The underwriting side and the payout side disagree about what capturePeriodStart means

Recommendation

Freeze one immutable asset snapshot per underwriting period and reuse that exact value everywhere that period-level solvency is enforced

Proof of Concept

Run it with:

Expected result:

Was this helpful?