tradi3_ Kurt Landman
Public report archive

Firelight T3-009

CoverOrderAllocator reuses collateral still backing previous-period claims, leaving new cover insolvent

CoverOrderAllocator computes current-period capacity from the vault's opening balance without reserving collateral still liable for unresolved previous-period claims, allowing a late period P-1 payout to drain the backing of settled period P cover.

Severity
High
Outcome
paid
Competition
Audit Comp | Firelight
Vulnerability class
collateral accounting / solvency
Published

Summary

Published Immunefi report #88281.

CoverOrderAllocator calculates the collateral capacity available for matching new cover orders in period P from the vault’s balance at currentPeriodStart(). However, IncidentManager and FirelightVault allow valid incidents from period P - 1 to be reported and paid out during period P from the same live vault assets.

Because no reserve is held for unresolved previous-period exposure when period P capacity is committed and settled, the protocol sells and charges full premiums for period P cover backed by collateral that is subsequently consumed by a valid period P - 1 claim. When a valid period P claim later arrives, the vault has insufficient assets to honor it.

Root cause

In contracts/core/CoverOrderAllocator.sol (v1_audit_ready @ 42f9ea5e43d88b35197b901acc2a8c24b314fa62), _computeAvailableCapacity() reads the vault’s opening checkpoint for the current period without subtracting outstanding prior-period cover liabilities:

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);
totalAvailableCapacity = strictCapacity.mulDiv(
    BPS_DENOMINATOR + config.divergenceToleranceBps,
    BPS_DENOMINATOR
);

commitAllocation() locks in that capacity and settleAllocation() issues the new period P cover against it. Meanwhile, FirelightVault.payout() allows claims captured in period P - 1 to execute during period P:

if (!_isPeriodInPayoutWindow(capturePeriod, _currentPeriod)) {
    revert InvalidCapturePeriod();
}

uint256 activePayableAmount =
    currentActiveAssets + capturePeriodWithdrawals + nextPeriodWithdrawals;
paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount));

Proof of concept

Reproduction sequence at 1x leverage with all protocol roles operating honestly:

  1. An LP deposits 1,000 units into FirelightVault.
  2. A 1,000-unit cover order is committed and settled for Period 1.
  3. At the start of Period 2, totalAssetsAt(currentPeriodStart()) is still 1,000. The allocator commits and settles a new 1,000-unit cover order for Period 2, charging the buyer the full premium.
  4. Still within Period 2’s valid reporting window, a genuine Period 1 incident is reported and approved, paying out the 1,000 vault units.
  5. A genuine Period 2 incident is then reported and approved. With no first-loss buffer (FLB = 0), vaultPaidAmount == 0 (100% shortfall). With a funded 100-unit first-loss buffer (FLB = 100), the Period 2 buyer receives only 100 of the promised 1,000 units (90% shortfall).

Running npx hardhat test test/PoC_ConsecutivePeriodDoubleUse.js --no-compile:

PoC: consecutive-period collateral is used twice
  ✔ charges and settles period-2 cover before a late period-1 payout drains its backing (FLB=0)
  ✔ charges and settles period-2 cover before a late period-1 payout drains its backing (FLB=100)

2 passing

Impact

Protocol insolvency: buyers pay full premiums for settled cover whose backing collateral is still encumbered by prior-period exposure and can be drained before their own valid claim is paid.

Reserve unresolved prior-period cover exposure (while its reporting/payout window remains open) when computing _computeAvailableCapacity() for a new period, or segregate vault capacity by coverage period until the prior period’s claim window closes.