89198 sc high stale collateral snapshot in computeavailablecapacity lets the protocol sell cover against funds it has already paid out driving car to 0 17 against the enforced 1 2 floor
Submitted on Aug 17th 2026 at 19:20:52 UTC by @webrainsec for Audit Comp | Firelight
Report ID: #89198
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
CoverOrderAllocator._computeAvailableCapacity values staked collateral at a period-start snapshot, while FirelightVault.payout writes its post-payout checkpoint at Time.timestamp(). A payout executed inside a period therefore never moves that period's snapshot, and the payout window is {capturePeriod, capturePeriod + 1}, so payouts landing mid-period are routine flow rather than an edge case. For the rest of that period the on-chain solvency check computes capacity from collateral the protocol has already paid out, and sells new cover against it. On a Flare mainnet fork of the live vault, $90,000,000 of cover settles against $7,687,143 of remaining collateral, a Capital Adequacy Ratio of 0.17 against the 1.2 floor the contract enforces.
Vulnerability Details
Capacity is the protocol's on-chain solvency constraint (CoverOrderAllocator.sol:719-724):
uint256 strictCapacity = availableCollateralCanonical.mulDiv(config.effectiveLeverage, config.minCAR);
totalAvailableCapacity = strictCapacity.mulDiv(BPS_DENOMINATOR + config.divergenceToleranceBps, BPS_DENOMINATOR);config.minCAR is the Capital Adequacy Ratio floor itself: MIN_CAR_BPS = 12_000 is the documentation's 1.2 floor, and _setCapacityConfig reverts below it (:662). With CAR = Available Capital / Solvency Capital Requirement and the SCR's "coverage-over-leverage component", the ceiling above is exactly CAR >= minCAR / (1 + divergenceTolerance).
The two collateral legs of that ceiling are read at different times (:704-717):
FirelightVault.payout checkpoints the post-payout balance at :952:
At Time.timestamp(), never at the period start, so upperLookupRecent(currentPeriodStart()) returns the pre-payout value for the remainder of the period. One waterfall spends both legs (IncidentManager.sol:637, :658, :684), so the ceiling falls by the buffer's contribution and does not fall by the vault's.
The sale happens inside the period, not at the boundary. commitAllocation requires commitmentPeriod == currentPeriod (:343) and rejects a commit whose settlement grace would run past the period end (:346). The ceiling is therefore computed at an arbitrary block inside the period while the staked leg stays frozen at that period's start.
Nothing corrects it before the period ends. _computeAvailableCapacity is reached only from _validateMatchingCapacity (:400), via commitAllocation (:348) and recommitAllocation (:378). Settlement never re-checks capacity, and recommitAllocation is available only "if no orders have been settled yet" (ICoverOrderAllocator.sol:564). The divergence tolerance does not absorb it either: :75-78 scopes the tolerance to "the price/FLB drift between the off-chain matcher snapshot and commit inclusion" and caps it at 10%, while a payout can remove 100% of staked assets in one transaction.
Remediation. Have payout accumulate the paidFromActive it already computes at :937/:941 into a per-period total, and value the staked leg at totalAssetsAt(currentPeriodStart()) minus that total, saturating at zero. That keeps the period-start basis for staker flows and corrects only the movement the protocol itself executes.
Impact Details
Measured on a fork of the live vault proxy 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3, upgraded to the in-scope V2 implementation, at 2.0x effective leverage with the divergence tolerance at zero:
The protocol writes $90,000,000 of cover obligations against $7,687,143 of collateral. Stakers carry exposure the solvency check reported as covered, and cover buyers pay premium upfront for cover the remaining collateral cannot meet. Neither side can exit: cover "is enabled for a fixed 30-day period and cannot be cancelled mid-period". The live vault holds nine figures of FXRP, and any covered incident in that period pays out pro-rata against collateral the ceiling said was there.
This is not the protocol operating as documented. "Claims Liquidation" defines payout capacity as the same two legs and states "Capacity is measured at settlement. ... After each incident, capacity is reduced by the amount paid." "Capital Adequacy" states the guard is automatic: "capacity that would push CAR below the 1.2 floor cannot be allocated. Manual intervention is possible for edge cases but the baseline protection is automatic."
No role acts outside its authority and no attacker is involved. In PoC test 3 the matcher does exactly what step 4 of the documented matching flow prescribes (ICoverOrderAllocator.sol:40-41) and reads live vault.totalAssets() before the payout lands, which is when a matcher runs. The chain accepts its honest number afterwards: $2,000,000 of capacity computed against $1,000,000 of live collateral that is $200,000 by the time the commit is mined. The trigger is the protocol paying a legitimate assessed claim, on-chain, inside the period the capacity validator is measuring.
References
contracts/core/CoverOrderAllocator.sol:704-724(the two legs and the ceiling),:705live buffer,:711snapshotted vaultcontracts/core/CoverOrderAllocator.sol:75-78,:343,:346,:348,:378,:400,:662contracts/core/FirelightVault.sol:952(the checkpoint atTime.timestamp(), the root cause),:937/:941(paidFromActive)contracts/core/IncidentManager.sol:637,:658,:684(one waterfall spends both legs)contracts/core/interfaces/ICoverOrderAllocator.sol:40-41(published matcher formula, livetotalAssets()),:564https://docs.firelight.finance/risk-and-pricing-model/capital-adequacy (CAR definition, the 1.2 floor, "the baseline protection is automatic")
https://docs.firelight.finance/protocol-architecture/claims-liquidation ("After each incident, capacity is reduced by the amount paid")
Link to Proof of Concept
https://gist.github.com/webrainsec/8ceb37689a5748f48f522b30e8c4f574
Proof of Concept
Two runnable Hardhat tests against the real FirelightVault, CoverOrderAllocator, IncidentManager and CoverNFT. Only the price feed is mocked, standing in for the Flare FTSO adapter. Gist: https://gist.github.com/webrainsec/8ceb37689a5748f48f522b30e8c4f574
_computeAvailableCapacity has no public getter, so both tests bracket it: committing c succeeds and committing c + 1 must revert MatchingCapacityOverflow. That pins the ceiling to exactly c, so each figure below is a measurement rather than an inference.
Mainnet fork, FORK_SUBMISSION3_poc.js
Upgrade the proxy
Upgrade that proxy to the in-scope V2 FirelightVault through the real ProxyAdmin 0x15f89292cef63405a53ab806966681ecb430df85 using its owner 0xF811E83b45d8De67858eFA3aC1202A8B46B8d0b3. Assert contractVersion is 2 and totalAssets is unchanged, so the real balance sheet and checkpoint history carry into the test.
The test also asserts the sequence happens inside one period with no boundary crossed: period start, payout at +3,661s, cover sold at +3,663s, period end at +86,400s, currentPeriod() unchanged throughout.
Local, SUBMISSION3_poc.js
Test 1 runs the same flow on a fresh stack at the contract's ceilings (
effectiveLeverage60000,divergenceToleranceBps1000). Capacity measures $6,600,000 before the payout and $5,500,000 after. The drop is exactly the buffer leg's $1,100,000; the vault's spent $800,000 contributes nothing to it. $5,000,000 of cover then settles against $200,000 of live collateral.Test 2 is the control. The identical payout, same amounts and roles, executed one period earlier so it precedes the boundary. The snapshot carries it, capacity measures $1,100,000, and the same $5,000,000 sale is refused on both routes: claiming $5,500,000 reverts
MatchingCapacityOverflow, claiming the honest $1,100,000 revertsTotalAllocationOverflow. Payout timing is the only variable.Test 3 shows no role deviates. The matcher reads live
vault.totalAssets()per step 4 of the published flow and computes $2,000,000 before the payout lands. The payout executes, live collateral drops to $200,000, and the chain still accepts the matcher's $2,000,000. The buffer is empty throughout, so the buffer leg cannot account for any of it.Test 4 repeats at 2.4x leverage with the divergence tolerance at zero, inside the documented 2.0x to 2.5x launch band. Real CAR again lands at 0.24.
Was this helpful?