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

88615 sc high stale period start capacity snapshot lets the allocator over sell cover after a mid period payout causing protocol insolvency

Submitted on Aug 14th 2026 at 06:44:34 UTC by @hunter67 for Audit Comp | Firelight

  • Report ID: #88615

  • 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

Summary

CoverOrderAllocator._computeAvailableCapacity values the vault's staked assets at vault.totalAssetsAt(vault.currentPeriodStart()), the asset total checkpointed at the start of the current period, instead of the live vault.totalAssets(). The documented off-chain matcher computes capacity from the live vault.totalAssets(), and the on-chain error and parameter docs describe the commit ceiling as the "live capacity". So the on-chain check in commitAllocation is the live-capacity enforcement bound that is supposed to stop a commitment from exceeding what current collateral can back.

Payouts reduce collateral mid-period. FirelightVault.payout pushes the lowered asset total at the block timestamp of the payout, which is after the period start, so totalAssetsAt(currentPeriodStart()) keeps returning the pre-payout value for the rest of the period. When a legitimate incident payout is included before that period's commitAllocation, the enforcement bound is computed from the stale, higher snapshot and accepts a commitment the live value would reject. Cover is then committed and settled against collateral that no longer exists, and a later covered claim on that cover is underpaid.

Every actor in the reproduction behaves honestly and within its role. No attacker needs to hold or abuse a privileged role.

Target

  • Program: Immunefi "Audit Comp | Firelight".

  • Repo (frozen contest source): immunefi-team/audit-comp-firelight at 42f9ea5 (tree 6c38527).

  • In-scope contracts involved: contracts/core/CoverOrderAllocator.sol, contracts/core/FirelightVault.sol, contracts/core/IncidentManager.sol, contracts/core/interfaces/ICoverOrderAllocator.sol.

Severity

Critical, protocol insolvency. The protocol underwrites and settles more cover than its collateral can back at the configured minCAR / effectiveLeverage, so covered claims cannot be fully paid and cover buyers take a direct, permanent loss. The insolvency impact is in-scope Critical per the program rules.

The severity is isolated from the protocol's intended leverage by a 1x-leverage control (see Impact, and the supplied sccap-case4d-1x-leverage-control.test.js): with effectiveLeverage == minCAR, a correct live-capacity guard would pay the claim in full and lose nothing, while the buggy guard leaves 2000 of a valid claim permanently unpaid. That unpaid amount is caused only by this bug, not by fractional-reserve leverage.

Root cause (exact lines)

  1. CoverOrderAllocator.sol:711, inside _computeAvailableCapacity (:696-725):

  2. _computeAvailableCapacity is the sole capacity ceiling consumed by the commit guard _validateMatchingCapacity (:394-403), reached from commitAllocation (:348) and recommitAllocation (:378). Within that computation the vault-backed component is read from the period-start snapshot above rather than live totalAssets() (the first-loss-buffer balance is added separately, so the defect is specifically the vault-backed term):

  3. The interface documents this bound as the live capacity. The off-chain matcher computes capacity from vault.totalAssets() (ICoverOrderAllocator.sol:40-41), and the error / parameter docs call the on-chain ceiling the "live capacity" (ICoverOrderAllocator.sol:417-421 and :528-529). The implementation reads the period-start snapshot instead, so implementation and specification disagree.

  4. The snapshot goes stale within the period because FirelightVault.payout pushes the reduced total at the current block timestamp, not at the period start (FirelightVault.sol:952):

    totalAssetsAt(t) is upperLookupRecent(t) (FirelightVault.sol:456) and currentPeriodStart() floors to the period boundary (FirelightVault.sol:342-345), so a payout at now > periodStart never moves the period-start lookup.

  5. Settlement does not re-check live collateral. _settleCoverOrder (CoverOrderAllocator.sol:457-540) enforces the per-market cap against the stored commit.totalAvailableCapacity (:507) and the merkle proof; it never re-reads the vault, so the stale-inflated capacity flows straight into settled cover.

Preconditions

  • Honest roles only: CURATOR_ROLE, ALLOCATOR_ROLE, ASSESSMENT_APPROVER_ROLE, and IncidentManager holding the vault PAYOUT_ROLE / INCIDENT_ROLE. No role acts outside its documented privileges.

  • A legitimate incident whose payout is included during the period but before that period's commitAllocation. The clean, fully-reachable form is a previous-period incident paid in the current period: the vault's payout window is the capture period or the one after it (FirelightVault.sol:1130-1131), so a period P-1 incident can be approved and paid during period P, before the P commit. A same-period-P payout before the first P commit is not reachable, because an incident of P must reference a settled cover of P, which requires the P commit to already exist, after which commitAllocation(P) reverts PeriodAlreadyCommitted.

  • The off-chain matcher snapshotted capacity from the pre-payout collateral, which is normal: the payout can be included between the matcher's snapshot block and the commit-inclusion block.

Timing (why an honest allocator submits the pre-payout capacity)

This ordering is exactly what the on-chain recompute and the per-period divergenceToleranceBps exist for. The interface states the on-chain recompute is "live" and is used to bound the matcher input, but the implementation reads the period-start checkpoint, so the bound does not do its job.

Steps to reproduce

The supplied PoC sccap-case4c-production-path.test.js drives the whole chain through the real FirelightVault, CoverOrderAllocator and IncidentManager (no mock vault). Numbers below are from the run.

1

Period P0

A user deposits 10,000 fXRP. A cover buyer's order for 6,000 USD of Morpho cover is created, targeting period P1.

2

Period P1: settle old cover

ALLOCATOR_ROLE runs commitAllocation(P1) and settleCoverOrder, minting the 6,000 "old" CoverNFT. Live collateral at commit time is 10,000, so this commit is correct.

3

Period P1: create incident and victim order

CURATOR_ROLE creates and assesses (but does not yet approve) an incident on the old CoverNFT. A second cover buyer's order for 12,000 USD of Morpho cover is created, targeting period P2.

4

Advance to period P2

The off-chain matcher reads live collateral, still 10,000, and derives a 20,000 P2 capacity from it.

5

Period P2: approve prior-period incident

ASSESSMENT_APPROVER_ROLE approves the period-P1 incident. IncidentManager._executePayout calls vault.payout, paying the 6,000 assessed loss and draining live collateral from 10,000 to 4,000.

totalAssetsAt(currentPeriodStart(P2)) still returns 10,000.

6

Period P2: commit stale capacity

ALLOCATOR_ROLE commits the matcher's pre-payout 20,000. The guard recomputes its ceiling from the stale 10,000 snapshot, gets 20,000, and accepts. The boundary is exact: committing 20,000 + 1 reverts MatchingCapacityOverflow(20001e18, 20000e18), proving the accepted ceiling equals capacity(10,000), not capacity(4,000).

7

Period P2: settle victim cover

ALLOCATOR_ROLE settles the 12,000 victim CoverNFT against the stale-inflated commitment. A correct live commit of 8,000 would cap Morpho cover at 60% * 8,000 = 4,800, so 7,200 USD of this cover should never have been sellable.

8

Period P2: pay victim claim

A covered incident on the victim CoverNFT is driven through the full IncidentManager.

_executePayout requests 12,000 fXRP from the vault; vault.payout pays only the remaining 4,000 and the incident is closed terminally with no debt-retry path. The cover buyer is short 8,000 fXRP.

Expected vs actual

  • Expected: commitAllocation(P2) binds matchingCapacity to the live capacity, so after the drain it rejects any commitment above 8,000 USD, and no more than 4,800 USD of Morpho cover can be settled.

  • Actual: it binds to the period-start snapshot, accepts 20,000 USD, and lets 12,000 USD of Morpho cover settle. The excess is not backed by live collateral, and a covered claim on it is underpaid.

Impact

The bug-attributable harm is the cover the guard allows beyond the live-collateral limit:

  • Committed capacity: 20,000 USD accepted vs 8,000 USD that live collateral supports, so 12,000 USD excess capacity.

  • Morpho cover: 12,000 USD settled vs a live-correct per-market cap of 4,800 USD, so 7,200 USD of excess cover that a correct guard would have refused.

The same 7,200 falls out of the claim shortfall as a counterfactual:

So the observed 8,000 fXRP shortfall is not all attributable to the bug; the by-design 2x leverage accounts for 800, and the remaining 7,200 is caused by the stale-capacity over-issuance.

The 1x-leverage control (sccap-case4d-1x-leverage-control.test.js) removes the leverage entirely. With effectiveLeverage == minCAR, a correct guard would allow at most 2,400 Morpho cover on 4,000 collateral, which pays a full claim 100% and loses nothing; the buggy guard allows 6,000, so a full claim pays only 4,000 and leaves 2,000 permanently unpaid. Under a correct guard the by-design shortfall is zero, so the entire 2,000 is caused by the bug. This shows the finding is genuine insolvency, not merely a higher expected leverage.

Scope note: these figures are the impact demonstrated in the PoC's harness config, not a claim that a specific amount has already been lost on a live deployment. The finding is a source-level mechanism that over-issues cover and leaves approved liability underfunded; on any deployment the magnitude scales with the drained collateral and the configured effectiveLeverage / minCAR.

On the trusted-role responsibility note

FirelightVault.payout's NatSpec states that keeping aggregate payouts within underwritten exposure is the responsibility of the curator and assessor roles. This finding does not contradict that and does not require either role to over-assess or act maliciously. The reproduction has honest roles: the matcher snapshots live collateral correctly, a valid prior-period claim is approved, and the allocator commits the value the matcher produced. The independent allocator invariant still explicitly requires the submitted matching capacity to be bounded by live collateral at commit time (ICoverOrderAllocator.sol:40-41, :417-421, :528-529); that invariant is what fails, because the implementation reads the period-start checkpoint instead of live assets.

Development intent (commit I-8)

The on-chain bound and its "live capacity" wording were introduced in the same commit. Upstream commit 411e7ca3a6ba705434a170aa39ccd3db0ac54ce6 ("feat: [I-8] add matchingCapacity to allocation commitments", 2026-07-21) added the matchingCapacity parameter and the _validateMatchingCapacity check to commitAllocation. The same commit documented that check as bounding the caller against the live capacity: the MatchingCapacityOverflow NatSpec it added reads "Thrown when the caller's matching capacity exceeds the live capacity", and the commitAllocation parameter doc it added says matchingCapacity "Must not exceed the live capacity widened by the period's divergence tolerance". But _validateMatchingCapacity reuses the pre-existing _computeAvailableCapacity, which reads totalAssetsAt(currentPeriodStart()) rather than live totalAssets(); that capacity function is not modified by the commit. So the check that was added and documented as a live-capacity bound is wired to a period-start read. A claim that the period-start snapshot is intentional has to explain why the check introduced specifically to enforce live capacity does not read live collateral.

Value staked assets from live collateral, matching the documented behaviour. In _computeAvailableCapacity (CoverOrderAllocator.sol:711) read $.vault.totalAssets() instead of $.vault.totalAssetsAt($.vault.currentPeriodStart()). The off-chain matcher already uses totalAssets() (ICoverOrderAllocator.sol:40-41), so this aligns the on-chain bound with the value the matcher committed against, and the period's divergenceToleranceBps continues to absorb the small snapshot-to-inclusion drift it was designed for.

Defence in depth: re-check the committed capacity against live collateral at settlement time in _settleCoverOrder, so a payout included after a correct commit but before settlement cannot leave the sold cover under-collateralised either.

References and how this differs

  • The protocol's own divergence tolerance (CoverOrderAllocator.sol:75-81) is documented to absorb only the small drift between the off-chain snapshot and commit inclusion, and is capped at 10% (MAX_DIVERGENCE_TOLERANCE_BPS). A payout can erode collateral far beyond that band, so this is not the tolerated drift the design accounts for.

  • The listed known issues for the contest (permissioned MVP, off-chain matching, external FLB wallet, USD peg) do not cover the period-start-vs-live capacity read. This is a concrete implementation-violates-interface defect in an in-scope contract, not the off-chain-matching known issue: the on-chain bound that is supposed to make over-commit impossible is the thing computing on stale data.

Limitations

  • Source-lane only: the PoC runs against the frozen contest source in a local Hardhat EVM; I did not test against a live Flare deployment. Every claim here is a mechanism claim reproducible in the harness.

  • The trigger needs the payout to be included before the period's commit (the previous-period-incident path in the PoC). I did not measure how frequently that interleaving occurs in production operations; it is reachable under normal operation, and the reproduction shows the full reachable path.

  • Impact is quantified for the harness config. The general magnitude scales with the drained collateral times effectiveLeverage / minCAR; the 1x control isolates the bug's own contribution from the intended leverage.

Proof of Concept

Production source under review: upstream immunefi-team/audit-comp-firelight at 42f9ea5. The PoC is a Hardhat test supplied with this report and applied on top of that frozen source; it adds no contract and changes no production code. It uses only contracts and helpers already present in the frozen source (test/setup/fixtures.js, contracts/test/MockERC20.sol, contracts/test/MockAggregatorV3.sol, the in-scope CoverNFT, IncidentManager, CoverOrderAllocator, FirelightVault) plus the @openzeppelin/merkle-tree dev dependency.

1

Clone the source

Clone immunefi-team/audit-comp-firelight and check out 42f9ea5.

2

Install dependencies

3

Save the PoC

Save the file below as test/audit/sccap-case4c-production-path.test.js.

4

Run the test

Environment used: Windows 11, Node v24.16.0, Hardhat 2.22.19, solc 0.8.28, in-process Hardhat EVM (london), no fork. Two supplementary tests are supplied the same way: sccap-case1-capacity-staleness.test.js (mechanism plus a no-payout control) and sccap-case4d-1x-leverage-control.test.js (the 1x-leverage severity control).

Full primary PoC file (sccap-case4c-production-path.test.js):

Raw output of the primary PoC:

Raw output of the 1x-leverage control (sccap-case4d-1x-leverage-control.test.js):

Was this helpful?