88321 sc high stale period start collateral snapshot lets the allocator sell cover against assets already paid out as claims
Submitted on Aug 12th 2026 at 18:49:41 UTC by @pawelschubert for Audit Comp | Firelight
Report ID: #88321
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
Bug Description
CoverOrderAllocator._computeAvailableCapacity() values the staked collateral backing new cover sales at a historical snapshot anchored to the start of the current period:
// CoverOrderAllocator.sol:710-716
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()),
$.vaultAssetDecimals,
CANONICAL_DECIMALS,
Math.Rounding.Floor
);
uint256 stakedAssetsValueUSDCanonical = totalAssetsCanonical.mulDiv(assetPriceUSD, 10 ** $.priceFeedDecimals);
uint256 availableCollateralCanonical = firstLossBufferUSDCanonical + stakedAssetsValueUSDCanonical;
uint256 strictCapacity = availableCollateralCanonical.mulDiv(config.effectiveLeverage, config.minCAR);However, FirelightVault.payout() - the function that pays approved incident claims - is explicitly allowed to execute during the incident's capture period or the following period:
When a payout executes, it pushes the post-slash totalAssets checkpoint at the current timestamp:
That timestamp is always strictly greater than currentPeriodStart(). Therefore, when a period-P-1 incident is paid out early in period P (the normal, documented settlement flow), totalAssetsAt(currentPeriodStart()) keeps returning the pre-slash balance for the rest of period P.
Every commitAllocation / recommitAllocation in period P then validates matchingCapacity against collateral that no longer exists:
The check is fully binding - one wei above the stale bound reverts - but it is bound to phantom collateral. The only documented slack, divergenceToleranceBps, can only widen the bound (strictCapacity * (BPS + tolerance) / BPS); it cannot tighten it after a slash. Even with divergenceToleranceBps = 0 (strict mode), a commit sized against a 90%-drained vault succeeds.
There is also no on-chain recovery path: recommitAllocation re-uses the same stale _computeAvailableCapacity, so even a diligent operator cannot re-derive post-slash capacity for the remainder of the period. Notably, the protocol's own interface documentation specifies the off-chain capacity formula with the live balance (ICoverOrderAllocator.sol:40-41: vault.totalAssets() * assetPriceUSD / ...), which contradicts the on-chain snapshot anchor exactly in this scenario - the on-chain ceiling is supposed to track the same real collateral.
The asymmetry makes the design intent explicit: deposits are blocked during and after active incidents precisely so that fresh assets cannot back unresolved exposure (_hasActiveIncident, FirelightVault.sol:1115-1118), yet commits are never blocked and the snapshot pretends destroyed collateral still exists.
Step-by-step failure path (PoC-verified numbers)
Setup: 6-decimal vault asset at $1.00, minCAR = 1.2x, effectiveLeverage = 2.0x (well within the post-M-1 5x cap), divergenceToleranceBps = 0, first-loss buffer empty (isolates the vault leg).
Period 0: depositor deposits 10,000 fXRP.
Period 1: curator creates a 12,000 USD cover order targeting period 2. An incident is captured in period 1 (payout window = periods 1-2).
Period 2 begins. Early in period 2 - inside the legal payout window - the period-1 incident pays out 9,000 fXRP. Live
totalAssets() = 1,000.totalAssetsAt(period2Start)still returns 10,000.commitAllocation(2, root, 12_000, matchingCapacity = 20_000 USD):On-chain bound: 10,000 × 2.0 = 20,000 USD (stale) - commit succeeds.
Honest bound from live collateral: 1,000 × 2.0 = 2,000 USD.
20,000 + 1 weirevertsMatchingCapacityOverflow- the check is binding, just on collateral that no longer exists.
settleCoverOrdersucceeds: premium pulled, CoverNFT minted, 12,000 USD of live period-2 cover.A period-2 incident claims the covered 12,000 USD →
payout()caps at live assets → pays only 1,000. 91.7% of the approved claim is unpayable.
Impact
Protocol insolvency. Cover is sold at effectiveLeverage/minCAR against collateral already paid out as claims, and subsequent approved claims on that cover cannot be paid. The on-chain solvency invariant - the only on-chain constraint binding the privileged allocation flow - silently stops constraining for the remainder of any period in which a payout executes. Cover buyers hold NFTs backing unpayable claims; stakers face total slash; the vault's remaining assets are a fraction of outstanding cover.
No misbehavior is required to trigger this: incident payouts executing in the period after capture, and once-per-period commits inside the period, are both the normal documented operating flow. The trigger is timing-inevitable in any period following a large incident, which is exactly when continued cover sales matter most.
This is not any known/accepted issue:
Not M-1 (unbounded leverage): leverage is within the enforced cap; the flaw is the collateral input to the capacity formula, not the multiplier.
Not the accepted "aggregate claims not capped vs cover sold" risk: that is claims exceeding sold cover; this is sold cover exceeding actual collateral - the inverse direction, independent of assessment honesty.
Not the unescrowed-buffer accepted risk: the first-loss buffer is empty in the PoC; the bug is entirely in the vault-asset leg.
Not L-2/L-4: all caps and CAR bounds are respected and enforced.
Recommendation
Value the vault collateral with the minimum of snapshot and live balance, which can never overstate:
and/or block commitAllocation/recommitAllocation while any incident captured in the current or previous period has executed a payout during the current period.
Link to Proof of Concept
https://gist.github.com/schvbertt/c636d7224c2f0481e7a6cbf4ce24769b
Proof of Concept
Runnable Hardhat test (uses the real FirelightVault via the repo's own test/setup/fixtures.js, real CoverOrderAllocator, real CoverNFT, real OZ StandardMerkleTree; only tokens/price feed are mocks).
Setup:
Expected output: ✔ sells 2x-leveraged cover against collateral already paid out as claims (1 passing).
Was this helpful?