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

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).

  1. Period 0: depositor deposits 10,000 fXRP.

  2. 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).

  3. 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.

  4. 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 wei reverts MatchingCapacityOverflow - the check is binding, just on collateral that no longer exists.

  5. settleCoverOrder succeeds: premium pulled, CoverNFT minted, 12,000 USD of live period-2 cover.

  6. 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.

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?