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:
Oracle Price is read live:
$.priceFeedAdapter.getPrice($.maxPriceAge)First-Loss Buffer (FLB) is read live:
config.firstLossBufferToken.balanceOf(...)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.
IncidentManagerapproves 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
IncidentManagerexecutes 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) returns2000e18as 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 withMatchingCapacityOverflow(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
Test File: test/foundry/PoC_StaleCommitCapacity.t.sol
Execution Logs
PoC Test Suite Breakdown
test_PoC_commitCeilingIgnoresPayoutAfterPeriodStart (Protocol Constraint Bypass)
Demonstrates that after an 800 USDC payout in Period 1 reduces live vault assets to 200 USDC,
CoverOrderAllocatorcontinues to calculate a stale capacity of 2000 USD.Direct on-chain proof: Calling
commitAllocationwith2000e18 + 1reverts on-chain withMatchingCapacityOverflow(2000e18 + 1, 2000e18), proving the contract's internal calculation evaluated max capacity to be exactly2000e18.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).
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.
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 (
convertToAssetsyields 0) to honor ghost cover sold before their arrival.
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?