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

88992 sc high protocol insolvency and lp fund drain via stale underwriting capacity calculation following cross period payouts

Submitted on Aug 16th 2026 at 10:23:41 UTC by @progabbomala for Audit Comp | Firelight

  • Report ID: #88992

  • 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

Brief/Intro

A critical accounting desynchronization in CoverOrderAllocator._computeAvailableCapacity allows the protocol to underwrite and settle cover obligations far exceeding actual vault collateral, breaking the intended risk invariant represented by MAX_LEVERAGE_FACTOR = 5. When an incident from a previous period (Period P-1) is paid out during the current period (Period P) within the protocol's legal two-period payout window, physical assets exit the vault via safeTransfer. However, _computeAvailableCapacity queries vault.totalAssetsAt(currentPeriodStart()), which continues to return the pre-payout snapshot. Consequently, CoverOrderAllocator calculates an inflated capacity ceiling, allowing the protocol to sell underwritten cover backed by phantom collateral. When legitimate claims are filed against this sold cover, the protocol suffers an immediate 90% default (protocol insolvency), or completely drains deposits made by subsequent Liquidity Providers (LPs) to honor ghost cover.

Vulnerability Details

Root Cause: Asymmetric State Query in Capacity Calculation

In CoverOrderAllocator.sol, _computeAvailableCapacity is responsible for enforcing the global underwriting capacity ceiling for a given period:

function _computeAvailableCapacity(
    CoverOrderAllocatorStorage storage $,
    uint256 period
) internal view returns (uint256 totalAvailableCapacity) {
    uint256 assetPriceUSD = $.priceFeedAdapter.getPrice($.maxPriceAge);

    CapacityConfig storage config = _getEffectiveCapacityConfig(period);

    uint256 firstLossBufferUSDCanonical = Decimals.convert(
        config.firstLossBufferToken.balanceOf(config.firstLossBuffer),
        _readDecimals(address(config.firstLossBufferToken)),
        CANONICAL_DECIMALS,
        Math.Rounding.Floor
    );
    uint256 totalAssetsCanonical = Decimals.convert(
        $.vault.totalAssetsAt($.vault.currentPeriodStart()), // @audit STALE SNAPSHOT
        $.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
    );
}

Notice the state query asymmetry:

  1. Oracle Price is read live: $.priceFeedAdapter.getPrice($.maxPriceAge)

  2. First-Loss Buffer (FLB) is read live: config.firstLossBufferToken.balanceOf(...)

  3. Vault Assets are read from a period-start snapshot: $.vault.totalAssetsAt($.vault.currentPeriodStart())

The Cross-Period Payout Desynchronization

Firelight explicitly designs a 2-period payout window (_isPeriodInPayoutWindow(capturePeriod, currentPeriod) allows execution when currentPeriod == capturePeriod || currentPeriod == capturePeriod + 1).

Consider the following step-by-step operational timeline:

Step

Period

Action

Live Vault Assets

Snapshot (totalAssetsAt)

Capacity Allowed

Real Backing Ratio

1

P0

LP deposits 1000 USDC

1000 USDC

1000 USDC

2000 USD

100% (1.0x)

2

P1

Period 1 starts at T_start

1000 USDC

1000 USDC

2000 USD

100% (1.0x)

3

P1

Payout of 800 USDC for P0 incident (T_1 > T_start)

200 USDC

1000 USDC (Stale)

2000 USD

20%

4

P1

2000 USD Cover committed & settled

200 USDC

1000 USDC (Stale)

2000 USD

10% Backing (10x Leverage)

5

P1

2000 USD Claim approved

0 USDC (paid 200)

1000 USDC (Stale)

2000 USD

1800 USD Shortfall (90% Default)

Violation of Protocol Risk Invariant

CoverOrderAllocator.sol (lines 79–82) explicitly documents the fundamental risk invariant:

This constraint exists to guarantee that the real physical backing behind sold cover never falls below:

Following the cross-period payout:

The effective leverage reaches 10x, cutting the real collateral backing in half (10% vs 20% minimum floor), violating the intended risk envelope established by MAX_LEVERAGE_FACTOR.

Additional Vector: Delayed Withdrawal Requests

The same stale-snapshot issue also applies to delayed withdrawal requests: after a mid-period withdrawal, totalAssets() decreases while totalAssetsAt(periodStart) remains unchanged, creating the same capacity inflation even without an incident payout.

Impact Details

Impact 1: Protocol Insolvency on Legitimate Claims (test_PoC_fullClaimDefaultsOnSoldCover)

  • Cover buyer purchases 2000 USD of valid cover and pays the full premium.

  • An incident occurs in Period 1 for 2000 USD.

  • IncidentManager approves the full 2000 USD claim.

  • At payout time, the vault's live balance is only 200 USDC, while the period-start snapshot used for exposure capping returns 1000 USDC. The payout logic is bounded by the available physical balance, paying out only 200 USDC.

  • The vault transfers 200 USDC and permanently marks the incident as CLOSED.

  • Shortfall: 1800 USD (90% unrecoverable default) on legitimate, settled cover obligations. The protocol is insolvent with respect to its underwritten commitments.

Impact 2: Direct Fund Drain / Slashing of Subsequent Depositors (test_PoC_lateLpIsSlashedToHonorGhostCover)

  • After the 2000 USD cover is settled against 200 USDC live AUM, a new LP deposits 800 USDC in Period 1 (deposits are open because no Period 1 incident has been opened yet).

  • Vault balance becomes 200 + 800 = 1000 USDC.

  • An incident occurs in Period 1 for 2000 USD.

  • When IncidentManager executes payout:

    • Exposure cap is read from the period-start snapshot (1000 USDC).

    • Active payable amount is 1000 USDC.

    • Payout extracts all 1000 USDC, including the 800 USDC deposited by the new LP.

  • The new LP suffers a 100% total loss of their deposit (0 redeemable assets) to pay for ghost cover that was sold before their deposit against capital that had already left the vault.

Scope & Validity Justification

1. Explicit Mapping to In-Scope Priority Area and Impacts

  • Program Priority Area: The Firelight program overview states:

    "Firelight is most concerned about: Commitment and settlement correctness in CoverOrderAllocator. Capacity calculation and solvency constraints. Incident/payout accounting..."

  • Immunefi Impact: This vulnerability maps directly to Protocol insolvency, listed as Critical in the Immunefi Firelight Impacts in Scope table. As a direct consequence, it causes unintended loss of user funds for late LPs.

2. The Trusted Matcher Does Not Save the Protocol (No Privileged Role Misbehavior)

The Immunefi rules state:

"Reports requiring these trusted parties to act maliciously or incorrectly within their documented privileges are not eligible unless they demonstrate that the role can exceed its intended authority or bypass an intended protocol constraint."

  • The matcher is not required to act incorrectly or maliciously. The matcher submits a capacity value that is validated on-chain by _validateMatchingCapacity.

  • The PoC proves that the contract's own internal capacity calculation (_computeAvailableCapacity) returns 2000e18 as the maximum accepted value.

  • Therefore, the matcher is simply operating within the authoritative on-chain capacity ceiling. The bug is in the contract's capacity calculation logic, not in the operator's judgment.

3. Direct On-Chain Proof of Constraint Bypass (MatchingCapacityOverflow)

The PoC provides direct proof using the contract's own revert error payload:

  • Calling commitAllocation(period, root, COVER, COVER + 1) reverts with MatchingCapacityOverflow(2000e18 + 1, 2000e18).

  • The second parameter of the revert (maxCapacity = 2000e18) proves that the smart contract itself evaluates maximum capacity as 2000 USD while holding only 200 USDC of live collateral.

4. Distinct from the "Collateral Drawdown" Known Issue

The Known Issue on collateral drawdown states:

"If the underlying staked asset decreases in USD value, available capital and the Capital Adequacy Ratio decrease. Severe collateral drawdowns can reduce coverage capacity and may cause shortfalls or undercollateralization relative to outstanding cover obligations."

  • The Known Issue refers to a market-driven decrease in the USD value of the underlying asset (e.g. Flare/FLR price drop on an external oracle).

  • Here, the asset price remains completely constant at 1.00 USD.

  • The reduction in backing is caused exclusively by an on-chain transfer of vault assets after a protocol payout, which the capacity calculation fails to reflect.

5. Distinct from the "Aggregate Claims Cap" Known Issue (KI #3)

Known Issue #3 relates to multiple duplicate claims exceeding total cover sold. In this report, there is one single order (2000 USD) and one single claim (2000 USD). The failure occurs because the cover was sold without collateral backing.

6. No Feasibility Limitations

The attack requires no external capital, no flash loans, no oracle manipulation, and no compromised keys. It occurs entirely through standard, sequential protocol operations.

Suggested Fix

To preserve the intended anti-frontrunning protection of the period snapshot while preventing underwriting against phantom collateral, _computeAvailableCapacity should take the minimum between the period-start snapshot and the live physical assets in the vault:

Affected Code References

  • CoverOrderAllocator.sol::_computeAvailableCapacity() (lines 696–725)

  • CoverOrderAllocator.sol::_validateMatchingCapacity() (lines 394–403)

  • CoverOrderAllocator.sol::MAX_LEVERAGE_FACTOR (lines 79–82)

  • FirelightVault.sol::payout() (lines 892–960)

  • FirelightVault.sol::totalAssetsAt() (lines 457–473)

  • IncidentManager.sol::_executePayout() (lines 620–690)

Proof of Concept

Steps to Reproduce

1

Clone the repository and install dependencies

2

Create foundry.toml in the repository root

Enable via_ir = true matching hardhat.config.js:

3

Save the PoC test file

Save the PoC test file below to test/foundry/PoC_StaleCommitCapacity.t.sol.

4

Execute the Foundry test suite

Test File: test/foundry/PoC_StaleCommitCapacity.t.sol

Execution Logs

PoC Test Suite Breakdown

1

test_PoC_commitCeilingIgnoresPayoutAfterPeriodStart (Protocol Constraint Bypass)

  • Demonstrates that after an 800 USDC payout in Period 1 reduces live vault assets to 200 USDC, CoverOrderAllocator continues to calculate a stale capacity of 2000 USD.

  • Direct on-chain proof: Calling commitAllocation with 2000e18 + 1 reverts on-chain with MatchingCapacityOverflow(2000e18 + 1, 2000e18), proving the contract's internal calculation evaluated max capacity to be exactly 2000e18.

  • Sells 2000 USD of cover against 200 USDC of collateral, cutting real collateral backing to 10% (10x leverage) and violating the protocol's 5x leverage risk floor (MAX_LEVERAGE_FACTOR).

2

test_PoC_fullClaimDefaultsOnSoldCover (Protocol Insolvency)

  • An honest 2000 USD claim on the underwritten cover is approved by IncidentManager.

  • The vault pays out its remaining 200 USDC and permanently transitions the incident to IncidentStatus.CLOSED.

  • Produces an unrecoverable shortfall of 1800 USD (90% default) on legitimate, settled cover obligations.

3

test_PoC_lateLpIsSlashedToHonorGhostCover (Direct User Fund Drain)

  • A subsequent LP deposits 800 USDC in Period 1 after the ghost cover is sold.

  • When the claim arrives, the payout extracts the entire 1000 USDC from the vault.

  • The late LP suffers a 100% total loss of their deposit (convertToAssets yields 0) to honor ghost cover sold before their arrival.

4

test_PoC_snapshotIntentIsOneSided (Root Cause Asymmetry Verification)

Empirically tracks state checkpoints to prove that while mid-period deposits correctly do not inflate the period-start snapshot, physical payouts transfer ERC20 tokens out of the vault without adjusting the snapshot, creating the capacity desynchronization.

Was this helpful?