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

89090 sc high a period c payout executed during c 1 is invisible to coverorderallocator s period start capacity snapshot letting a full book of c 1 cover be sold and settled against a vault the

Submitted on Aug 17th 2026 at 04:43:36 UTC by @zhouchungong for Audit Comp | Firelight

  • Report ID: #89090

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Protocol insolvency

Description

Brief/Intro

_computeAvailableCapacity sizes a period's cover ceiling from vault.totalAssetsAt(vault.currentPeriodStart()). A claim captured in period C stays payable through C+1, and payout() checkpoints when it executes, so C's payout — made during C+1 — lands outside the snapshot C+1 reads. The payout empties the vault first; the ceiling never sees it, and a full book of C+1 cover is then sold and settled against nothing.

Vulnerability Details

The ceiling reads a period-start snapshot:

// CoverOrderAllocator.sol#L710-711
uint256 totalAssetsCanonical = Decimals.convert(
    $.vault.totalAssetsAt($.vault.currentPeriodStart()),

and _validateMatchingCapacity is the only on-chain bound that ties a period's cover to vault assets:

totalAssetsAt(t) returns the last checkpoint with key at or before t (FirelightVault.sol#L456-458, core/lib/Checkpoints.sol#L218-221), while payout() checkpoints at the current time:

For any payout strictly after a period's start, that checkpoint lies outside the snapshot the ceiling reads, and stays outside for the rest of the period.

Paying a claim a period after its capture is specified, not incidental: _isPeriodInPayoutWindow is current == period || current == period + 1 (FirelightVault.sol#L1130-1132), and IncidentManager._isPeriodExpired is its negation (IncidentManager.sol#L839-841). The code prioritises it:

…Unresolved incidents from the previous period take priority over the current period; within a period the earliest capture timestamp wins. (IncidentManager.sol#L869-870)

Underwriting never reads incident state: grep -c -i incident contracts/core/CoverOrderAllocator.sol returns 1, a comment on line 26.

_computeAvailableCapacity reads the first-loss buffer live (CoverOrderAllocator.sol#L705), so buffer outflows correct the ceiling at once. Only the staked leg is frozen.

The protocol knows about this exposure and guards it — but only where money comes in:

…The previous period is included because a claim captured then is still payable this period, so deposits stay blocked until that payout window closes; otherwise fresh deposits would back unresolved exposure. (FirelightVault.sol#L1112-1114)

deposit and mint enforce that at #L660 and #L696 — but only while an incident is open: the flag clears with the incident, not with the window. Underwriting has no counterpart at all.

Impact Details

Real FirelightVault, CoverOrderAllocator, IncidentManager; minCAR = effectiveLeverage = 12000 (multiplier exactly 1.0x, so there is no leverage to attribute this to), tolerance 0, empty buffer.

vault assets unless marked
approval inside C (control)
approval deferred to C+1

C's payout, executed by IncidentManager

20,000

20,000

totalAssets() after it

0

0

totalAssetsAt(startOf(C+1))

0

20,000

ceiling for C+1

0

20,000

commit of the full ceiling

reverts MatchingCapacityOverflow

accepted

cover sold and settled for C+1

—

20,000 canonical

The only variable moved is when the assessment is approved, and the contract never treats that timing as a control: underwriting reads neither incident state nor live assets. Settlement still pulls the buyer's premium — 19.178083 USDC here — and mints the CoverNFT (CoverOrderAllocator.sol#L535, #L537). That premium is all that changes hands at once; the damage is 20,000 canonical of cover with nothing behind it. Obligations exceed assets from settlement onward: that is the insolvency.

Exposure is not limited to that buyer. payout bounds a C+1 claim by the lesser of live payable assets and totalAssetsAt(startOf(C+1)) (FirelightVault.sol#L911, #L927) — the same spent snapshot, so it stops binding — and the payout closed the incident, reopening the deposit gate (IncidentManager.sol#L436, #L829). In the run a newcomer deposits 20,000 and all of it is payable against cover sized on money already gone. The pattern recurs whenever a payout is deferred.

Why this is in scope. The run bypasses an intended protocol constraint rather than requiring any role to exceed its authority: _validateMatchingCapacity is that constraint, and the control shows the same action rejected when the approval lands one period earlier. No role acts incorrectly either — approving in C+1 is inside the window the contract defines. Prices are constant throughout, so this is not the disclosed collateral-drawdown case; and the known issue concerns aggregate claims outrunning cover sold, where here one legitimate claim is paid and cover outruns assets.

Limitations. The first-loss buffer absorbs the loss first (IncidentManager.sol#L645-670), so this needs an assessed loss larger than the deployed buffer, whose size I have not measured. An off-chain matcher could decline on live assets, though not on incident state: the payout closed the incident before this sale. The program casts that matcher as computing and submitting allocation commitments, not as a collateral check, and nothing on chain requires it.

Suggested fix. The two obvious guards are complementary, not alternatives; I implemented and ran both. Math.min(totalAssetsAt(currentPeriodStart()), totalAssets()) rejects this sale without blocking ordinary underwriting, but not a commit placed before the payout, because _settleCoverOrder reuses the stored commit.totalAvailableCapacity (CoverOrderAllocator.sol#L507). Mirroring hasActiveIncident fails the other way round: it catches that earlier commit, but by this sale the flag is already false. Re-checking capacity at settlement would cover both; I did not implement that one.

Proof of Concept

No fork, no RPC. Both cases share one setup(); the only variable moved is where approve(s) runs.

Test
Asserts
Observed

control — approval inside C

the claim is paid in full, the C+1 snapshot is 0, and the full-ceiling commit reverts

20,000 paid, snapshot 0, contract-computed ceiling 0, reverted

finding — approval deferred to C+1

the payout empties the vault, snapshot and ceiling unchanged, the full ceiling settles, premium taken, later deposit fully payable

20,000 paid, totalAssets() 0, snapshot 20,000, ceiling 20,000, 20,000 canonical settled, 19.178083 USDC collected, newcomer's 20,000 fully payable

ceilingOf() reads the ceiling out of the contract: it staticCalls commitAllocation with MaxUint256 and takes maxCapacity from the resulting MatchingCapacityOverflow, rejecting any other error. In the control's withArgs the second argument is that ceiling; the first is ours, echoed back.

Was this helpful?