88337 sc medium cover capacity is read from a period start snapshot so cover committed after an incident payout is priced against collateral the payout already spent
Submitted on Aug 12th 2026 at 19:35:59 UTC by @johanw for Audit Comp | Firelight
Report ID: #88337
Report Type: Smart Contract
Report severity: Medium
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 staked collateral as vault.totalAssetsAt(vault.currentPeriodStart()), a checkpoint keyed to the start of the period. FirelightVault.payout writes its post-payout checkpoint at Time.timestamp(), which is always strictly later, so that lookup cannot observe the payout for the remainder of the period.
A cover allocation committed after an incident payout is therefore validated against collateral that has already left the vault. In the PoC a claim is approved and paid through the real IncidentManager, taking the vault from 1,000,000 USD to 100,000 USD. The allocator then makes a fresh commitment, with no prior commitment in that period, and the on-chain capacity check authorises 5,000,000 USD of cover. Live collateral supports 500,000 USD. The check is overstating by a factor of ten and it is enforcing to the wei while doing so. That cover is then sold and premium is collected on all 5,000,000 USD of it at settlement.
There is no attacker. Every role acts correctly and the protocol still reaches a state its own on-chain control was written to prevent.
Vulnerability Details
Contracts:
contracts/core/CoverOrderAllocator.sol-_computeAvailableCapacity()(L696-725, the pinned read at L711, the live first-loss-buffer read at L705),_validateMatchingCapacity()(L394-403), the per-protocol concentration cap (L500-512),_replaceableCommitment()(L425-435),MAX_LEVERAGE_FACTORand its real-backing comment (L79-82)contracts/core/FirelightVault.sol-payout()(L892-966, the post-payout checkpoint at L952),totalAssetsAt()(L456-458)contracts/core/IncidentManager.sol-_executePayout()(L621-706, the vault call at L684)
All three are named in the competition's Assets in Scope table. Nothing in contracts/legacy/ or contracts/oracle/ is involved.
Bug Class: Stale historical checkpoint used as a live solvency input. Capacity is computed from a period-start snapshot that a mid-period payout can never move, so underwriting capacity is overstated and cover is sold beyond the configured capital adequacy floor.
Severity: Medium. CVSS 4.0 vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:N/VC:N/VI:H/VA:N/SC:N/SI:H/SA:N = 6.9, computed with the cvss library rather than estimated. AT:P because the state needs a payout to execute inside a period before that period's allocation is committed, a normal protocol event but a condition of the deployment. PR:H because reaching the state requires the assessment approver to approve a claim and the allocator to commit; both act correctly and neither is an attacker, but CVSS measures the privileges of the party performing the action. VI:H is the driver: the on-chain solvency control is violated through the exact check written to enforce it, at ten times the configured limit. SI:H for the subsequent impact on the cover buyer, a separate party. Selected impact is Protocol insolvency; severity is calibrated to Medium rather than that impact's usual band because this protocol is fractional by design at 5x, and the defect degrades the ratio to 50x for the remainder of one period rather than creating insolvency from a solvent state.
Root Cause
The two legs of the same capacity sum are read differently. The first-loss buffer is live and re-floors on its own; the vault balance is a checkpoint lookup keyed to the period start:
totalAssetsAt resolves through upperLookupRecent, which returns the most recent checkpoint at or before the key. The payout writes its checkpoint here:
A payout during period Q necessarily has Time.timestamp() > currentPeriodStart() for Q, so upperLookupRecent(currentPeriodStart()) cannot return it. This is not a race and there is no window to win: every call into _computeAvailableCapacity for the rest of the period returns the pre-payout figure.
The result feeds the bound that is supposed to enforce solvency:
A second control inherits the same figure once it is frozen into the commitment:
This is what the code itself claims to guarantee:
A floor asserted in code and not enforced by it is the defect, independent of who calls the function.
Sequence, all of it ordinary operation:
Stakers hold 1,000,000 USD; the first-loss buffer holds 200,000 USD. Period 1 cover commits and settles honestly against the 6,000,000 USD of capacity that supports.
An incident captures period 1. The curator confirms it, records an assessed loss of 1,100,000 USD against the settled cover, and submits the assessment.
Period 2 opens. The approver approves.
_executePayoutdraws 200,000 USD from the buffer and callsvault.payoutfor the remaining 900,000 USD. The vault holds 100,000 USD; the buffer is empty.Still in period 2, the allocator makes a first-time commitment for that period.
_validateMatchingCapacitycomputes capacity from the period-2-start snapshot, which never saw the payout, and authorises 5,000,000 USD. Live collateral supports 500,000 USD.The order settles. 5,000,000 USD of cover is live against 100,000 USD of collateral, and premium is charged on all of it.
Step 3 is not exceptional. The payout window is the capture period or the one after it, and one allocation commitment exists per period, so a payout preceding a period's commitment is an expected operating state for a cover protocol.
Proof of Concept
See the Proof of Concept field below for the full runnable Foundry test and output.
Impact
The capital adequacy bound is the product. minCAR and effectiveLeverage fix a minimum ratio of real collateral to sold cover, and _validateMatchingCapacity is the on-chain control that enforces it. After a payout, that control authorises allocations it should reject, for the rest of the period.
Measured in the PoC:
The protocol sold, and collected premium on, 5,000,000 USD of cover at a moment when it held 100,000 USD of collateral. Ten times the configured limit of underwriting exists on the book.
Stakers carried 50x leverage on their capital where the configuration permits 5x.
Because
_hasActiveIncident()gatesdepositandmint(FirelightVault.sol:660,:696), stakers cannot top the vault back up while the incident is open, so the gap cannot be closed by new deposits during the exposure window.
On the causal boundary, stated so it is not overread: the PoC also validates a 3,000,000 USD claim against that cover and shows it paying 100,000 USD. That 100,000 USD figure is what the vault holds and would be the same had capacity been floored correctly, because addAssessmentLosses caps an assessed loss at the order's allocatedCoverAmount and payout caps at live assets either way. The shortfall on that single claim is therefore not attributable to this defect and is not claimed as such. What this defect causes is that the cover existed to be sold at all: premium taken for underwriting capacity that provably did not exist, and a book carrying ten times the liability the configured floor permits, so that aggregate claims across all buyers in the period exceed what the collateral can serve.
Firelight's staker documentation at https://docs.firelight.finance/for-stakers states that "once coverage is live on mainnet, all staked capital in the vault backs coverage and is slashable". The test shows cover sold at ten times the collateral the configuration permits to back it.
Limitations, stated up front
The stale window is bounded to the remainder of the period in which the payout lands. At the next boundary the checkpoint is visible and capacity re-floors without intervention. Proven in
test_scope_staleWindowClosesAtTheNextPeriodBoundary.The claim that the per-protocol concentration cap at
CoverOrderAllocator.sol:507inherits the same stale figure is from reading the code, not from the PoC. The test setsmaxProtocolConcentrationBpsto 10,000, which disables that control.The bound is an upper bound, so an operator may always commit a smaller capacity voluntarily. What is proven is narrower and still the point: the contract is the only on-chain source of the figure, and it reports the stale one to whoever asks.
Distinctness From Disclosed Issues
Known issue 3, "aggregate claims not capped on-chain", concerns claims exceeding cover sold, in IncidentManager, and the vault says so itself at FirelightVault.sol:885: "The vault applies only these per-call limits; ensuring aggregate payouts stay within underwritten exposure is the responsibility of the curator and assessor roles." This report is the opposite direction, cover exceeding collateral, in CoverOrderAllocator. That direction is not delegated to roles; the protocol built an on-chain cap for it and the evidence shows the cap live and enforcing to the wei while computing from an input a payout can never move.
The first-loss-buffer accepted risk, that a buffer funded at commitment and then drawn down leaves sold cover underbacked, is the nearest relative and this is strictly worse. For the buffer the read is live, so only already-committed cover goes stale. For the vault the read is pinned, so a brand-new commitment made after the drawdown is mispriced too. The PoC shows both legs side by side.
Macro M-2 and Firelight's L-2 response discuss a stale-pin pattern, but that is the off-chain matching engine's pin block, not this on-chain storage read.
Macro M-1 was unbounded effectiveLeverage, fixed at CoverOrderAllocator.sol:665 with effectiveLeverage <= MAX_LEVERAGE_FACTOR * minCAR. That bounds the multiplier; this is about the collateral input the multiplier applies to. The PoC runs with the M-1 fix in force at maximum legal leverage and the bound is still breached.
I reviewed the full Macro finding list at https://library-pr-440.onrender.com/library/audits/firelight-1 (2 Medium, 4 Low, 1 Gas, 11 Informational, published 2026-07-29). None addresses period-start snapshotting or capacity-input staleness. The strings totalAssetsAt and currentPeriodStart do not appear in it.
Recommended Fix
Keep the period-start snapshot, since the off-chain matcher needs a deterministic capacity figure and that is why matchingCapacity is a commit parameter. Add a live floor so the pinned value can never exceed present reality:
min preserves determinism in the normal case, where the two agree, and floors capacity the moment a payout removes collateral. This is sufficient for the sequence proven here, where the commitment is made after the payout.
Two further changes close the case where the commitment already exists when the payout lands:
Bound
commit.totalAvailableCapacityby live capacity inside_settleCoverOrderrather than trusting the value frozen at commit time, and use the same bounded value for the per-protocol concentration cap at L507.Give payouts a way to invalidate a live commitment.
_replaceableCommitmentrejects bothrecommitAllocationandcancelCommitAllocationwithSettlementsAlreadyStartedonce any cover has settled, so a partially settled period cannot be corrected at all. Either haveIncidentManagerre-floor the current period's commitment whenvault.payoutmoves collateral, or permit a downward-only recommit after settlement has begun.
Supporting Materials
poc-test/CapacityStaleAfterPayout.t.sol- full runnable Foundry PoC, 5 tests: the defect, the realized-loss measurement, a negative control, and two scope tests that state the limits of the finding. Source inlined in the Proof of Concept field below.foundry.toml- the build configuration used, inlined below. The repository ships nofoundry.tomlat this commit.
Proof of Concept
Proof of Concept
Full runnable test at poc-test/CapacityStaleAfterPayout.t.sol, source inlined at the end of this field. It is self-contained: the vault, allocator, cover NFT, IncidentManager and the two mocks are all deployed inside the test, so it runs offline with no fork, no RPC endpoint and no API key, and it touches no deployed contract and no real funds.
Nothing is mocked in the primary test. The payout is driven through the real IncidentManager lifecycle, createIncident then confirmIncident then addAssessmentLosses then submitCurrentAssessment then approveCurrentAssessment, which reaches _executePayout and calls vault.payout exactly as deployed. The two mocks used are the repository's own contracts/test/MockERC20.sol and contracts/test/MockAggregatorV3.sol, standing in for the collateral token and the price feed only.
The first-loss buffer is funded with 200,000 and drawn by the real payout, deliberately. An empty buffer would force 100 percent of the capacity sum onto the pinned vault leg and overstate the defect; funding it shows the live buffer leg re-flooring correctly next to the pinned vault leg that does not.
Setup
Requires Foundry and git only.
Save the foundry.toml below to the repository root, and the test below to poc-test/CapacityStaleAfterPayout.t.sol.
The OpenZeppelin versions are the pair package.json pins: "@openzeppelin/contracts": "5.2.0" under overrides, and "@openzeppelin/contracts-upgradeable": "~5.4.0". foundry.toml skips contracts/fasset/** and contracts/oracle/**, the only sources importing the flare-smart-contracts npm packages. Verified from a cold cache with exactly these pins.
Run
Scenario: a 1,000,000 USD staked vault with a 200,000 USD first-loss buffer, minCAR 12000 (the contract's own MIN_CAR_BPS), effectiveLeverage 60000 (the maximum _setCapacityConfig accepts, MAX_LEVERAGE_FACTOR * minCAR), divergenceToleranceBps 0, period duration 7 days. Tolerance 0 makes the result harder to reach, not easier, since a nonzero tolerance widens capacity further. Leverage cancels out of the backing ratio, so the tenfold overstatement is set by the payout fraction rather than by the leverage choice.
Result (actual forge output)
What each test establishes
test_capacityIgnoresAPayoutForTheRestOfThePeriod is the defect. The commitment it makes is a first-time commitment for that period, made after the payout, so the pinned read is the only thing authorising it. It also asserts that one wei above the pre-payout figure still reverts MatchingCapacityOverflow(5000000000000000000000001, 5000000000000000000000000), matching the exact revert arguments. That rules out the reading that the check is disabled and shows it live, exact, and computing from a stale input.
test_control_withoutAPayoutTheBoundIsExact is the negative control. With no payout, committing 6,000,000 USD succeeds and one wei more reverts MatchingCapacityOverflow(6000000000000000000000001, 6000000000000000000000000). The check itself works; only its input is wrong.
test_underBackedCoverPaysAFractionOfAValidatedClaim measures what happens when a claim lands against the cover the stale read authorised.
test_scope_aSingleClaimMayDrainTheVaultEntirely answers whether the drawdown size is an artificial parameter. It is not: no admin-settable per-call payout limit exists, payout is bounded only by totalAssetsAt(capturePeriodStart) and live payable assets, so one approved claim can take the vault to zero while the period-start snapshot still reports the full pre-payout figure.
test_scope_staleWindowClosesAtTheNextPeriodBoundary states the limit of the defect rather than leaving it to be found: the window closes at the next period boundary, where the checkpoint becomes visible and capacity re-floors without intervention.
A first run compiles the whole tree, so the compile line differs from a cached run. Gas figures may vary slightly by Foundry version; the asserted values do not.
Build configuration, foundry.toml
Full test file, poc-test/CapacityStaleAfterPayout.t.sol
Was this helpful?