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-firelightat42f9ea5(tree6c38527).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)
CoverOrderAllocator.sol:711, inside_computeAvailableCapacity(:696-725):_computeAvailableCapacityis the sole capacity ceiling consumed by the commit guard_validateMatchingCapacity(:394-403), reached fromcommitAllocation(:348) andrecommitAllocation(:378). Within that computation the vault-backed component is read from the period-start snapshot above rather than livetotalAssets()(the first-loss-buffer balance is added separately, so the defect is specifically the vault-backed term):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-421and:528-529). The implementation reads the period-start snapshot instead, so implementation and specification disagree.The snapshot goes stale within the period because
FirelightVault.payoutpushes the reduced total at the current block timestamp, not at the period start (FirelightVault.sol:952):totalAssetsAt(t)isupperLookupRecent(t)(FirelightVault.sol:456) andcurrentPeriodStart()floors to the period boundary (FirelightVault.sol:342-345), so a payout atnow > periodStartnever moves the period-start lookup.Settlement does not re-check live collateral.
_settleCoverOrder(CoverOrderAllocator.sol:457-540) enforces the per-market cap against the storedcommit.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, andIncidentManagerholding the vaultPAYOUT_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 whichcommitAllocation(P)revertsPeriodAlreadyCommitted.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.
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).
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)bindsmatchingCapacityto 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.
Recommended fix
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.
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?