88620 sc medium cover capacity in computeavailablecapacity is sized on a stale period start asset snapshot so a prior period payout lets the allocator commit cover against collateral that has
Submitted on Aug 14th 2026 at 07:40:46 UTC by @enowk for Audit Comp | Firelight
Report ID: #88620
Report Type: Smart Contract
Report severity: Medium
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 mixes two clocks: the first-loss buffer is read live via balanceOf, but the staked collateral — the dominant term — is read from the period-start checkpoint via vault.totalAssetsAt(vault.currentPeriodStart()). A payout for a prior period's incident may legitimately execute during the current period, because the payout window is {capturePeriod, capturePeriod + 1}. That permanently removes assets from the vault but does not move the current period's start checkpoint, so for the remainder of the period the allocator sizes cover against collateral that has already left. _validateMatchingCapacity — the on-chain capital adequacy guard applied by both commitAllocation and recommitAllocation — passes on the inflated figure. On mainnet this lets cover be sold above what the vault can actually back, by the payout amount multiplied by the leverage ratio, with the resulting shortfall landing on cover beneficiaries as a reduced payout.
Vulnerability Details
Two clocks in one function
CoverOrderAllocator.sol#L696-L725:
function _computeAvailableCapacity(
CoverOrderAllocatorStorage storage $,
uint256 period
) internal view returns (uint256 totalAvailableCapacity) {
uint256 assetPriceUSD = $.priceFeedAdapter.getPrice($.maxPriceAge); // LIVE
CapacityConfig storage config = _getEffectiveCapacityConfig(period);
uint256 firstLossBufferUSDCanonical = Decimals.convert(
config.firstLossBufferToken.balanceOf(config.firstLossBuffer), // LIVE
...
);
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()), // PERIOD-START SNAPSHOT
...
);
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);
}Both the price leg and the buffer leg track reality in real time. The staked-collateral leg — which in normal operation is the largest input — is frozen at the period boundary.
Why the snapshot goes stale within the period
payout pushes its new total at Time.timestamp() (FirelightVault.sol#L952):
Because totalAssetsAt resolves via upperLookupRecent(timestamp) (FirelightVault.sol#L456-L458), a checkpoint pushed after the period start is invisible to a lookup at the period start. The snapshot therefore keeps reporting the pre-payout figure until the next period boundary.
The payout that causes this is entirely legitimate. payout explicitly permits execution one period after capture (FirelightVault.sol#L1130-L1132):
So an incident captured in period C-1 can, by design, be paid during period C — after period C's collateral snapshot has already been taken.
Why this is specific to payouts and not to withdrawals
Withdrawal requests also reduce totalAssets() mid-period, and the snapshot ignores those too. That is correct behaviour: requested-but-unclaimed capital is still slashable, and payout explicitly adds capturePeriodWithdrawals (and, in the following period, nextPeriodWithdrawals) to activePayableAmount (FirelightVault.sol#L914-L926). Counting that capital as backing is right.
Assets paid out are simply gone. Counting them is not. The snapshot cannot distinguish the two cases, and no compensating adjustment exists anywhere in the allocator.
Note also the direction of the error is one-sided. Mid-period deposits are likewise invisible to the snapshot, which is conservative and safe. Only the payout case makes the figure too high.
The stale figure is the on-chain solvency guard's only input
CoverOrderAllocator.sol#L394-L403:
This is the protocol's capital adequacy check — minCAR is literally the minimum Capital Adequacy Ratio. It is reached from both write paths:
commitAllocationat line 348recommitAllocationat line 378, whose own comment says it re-validates "against fresh capacity … so recommit can capture recovered price/FLB and stays bound to the period's real collateral within tolerance." The intent to bind to real collateral is explicit; the staked leg does not deliver it.
The stored commit.totalAvailableCapacity then propagates: _settleCoverOrder derives the per-protocol concentration caps from it (line 507), so the inflation scales those caps too.
Nothing constrains the ordering of payout and commit
The allocator contains no reference to incidents at all — an open or recently-paid incident neither blocks nor forces a commit.
commitAllocation's only timing constraint isgraceExpiresAt < vault.currentPeriodEnd()(line 346), which merely requires the commit to happen early enough in the period — not before any payout.recommitAllocationis available for the whole period until the first settlement, and re-derives capacity from the same stale input, so an ordinary mid-period recommit reproduces the condition even if the original commit preceded the payout.
Failure sequence
No role misbehaves at any point. Every action is the intended operation of the system.
Cover is committed against stale collateral
ALLOCATOR_ROLE commits (or CONFIG_ADMIN_ROLE recommits) period C cover. _validateMatchingCapacity computes the ceiling from 1,000, not 600, and accepts a commitment above what the vault can back. The attached PoC reads the ceiling straight out of the contract's own MatchingCapacityOverflow revert: 2,000 canonical USD, when honest capacity is 1,200.
Known-issue check
Three published items are adjacent. None covers this.
Immunefi A5 — "If the underlying staked asset decreases in USD value, available capital and the Capital Adequacy Ratio decrease… may cause shortfalls or undercollateralization." That is the price leg, and the price leg is read live via
priceFeedAdapter.getPrice. This report concerns the quantity leg being stale, which A5 does not describe. A5 does confirm that the program treats undercollateralisation as a real impact rather than an accepted design property.Immunefi A6 / 0xMacro B1 — "Aggregate claims are not capped on-chain against total cover sold." That is claims-versus-cover; this is cover-versus-collateral. Opposite side of the ledger, different guard.
0xMacro B2 / Immunefi A4 — the first-loss buffer "can be funded to inflate capacity at commitment and then withdrawn." Same family (a capacity input diverging from reality) but a different input, a different mechanism, and it requires operator misbehaviour. Here the divergence is created by a correct, expected payout and requires no misbehaviour at all. Note that B2's input is the one read live; the input read stale went unexamined.
0xMacro M-1 (unbounded
effectiveLeverage) is fixed at this commit —effectiveLeverage <= MAX_LEVERAGE_FACTOR * minCARwithMAX_LEVERAGE_FACTOR = 5— and is unrelated. Macro's report quotes thestrictCapacity/divergenceToleranceBpslines of this exact function while analysing M-1, but contains no reference anywhere tototalAssetsAtor the period-start snapshot.Nothing in the OpenZeppelin Phase 1 audit applies —
CoverOrderAllocatoris a new Phase 2 contract.
Recommended fix
Read the collateral leg live and cap it by the snapshot:
min preserves the intent of the period-start snapshot — mid-period deposits must not inflate the current period's capacity — while ensuring assets already paid out cannot back new cover.
Note that a naive switch to a fully live totalAssets() would be wrong in the other direction: withdrawal requests reduce it while that capital is still slashable, so a live-only read would understate capacity. min avoids both errors.
Alternatively, have payout maintain a per-period paidOutThisPeriod accumulator on the vault and subtract it in _computeAvailableCapacity.
Impact Details
Selected in-scope impact: Protocol insolvency.
The protocol's on-chain capital adequacy check can be satisfied while actual capital adequacy is below the configured minCAR floor. Cover in force for the period exceeds the collateral backing it, which is an asset/liability mismatch created by the contract itself rather than by market movement.
Magnitude. The capacity overstatement is:
paidFromActiveis the portion of the payout drawn from active assets (FirelightVault.sol#L952). The remainder of a payout is drawn from withdrawal buckets and does not movetotalAssets(), so the overstatement trackspaidFromActive, not the fullpaidAmount. In the PoC there are no pending withdrawals, so the two coincide at 400.effectiveLeverage / minCARis bounded at 5× byMAX_LEVERAGE_FACTOR(CoverOrderAllocator.sol#L82, enforced at line 665), and is widened further by the divergence tolerance.
In the attached end-to-end PoC, a 400-asset payout at the fixture's 2.0× multiplier lifts the contract's own accepted ceiling from an honest 1,200 to 2,000, and a commitment of 1,800 against 600 of real assets is accepted. At the 5× ceiling the same 400 payout would overstate capacity by 2,000.
Realised loss. The shortfall materialises when an incident hits period C. Cover was sold on the promise of the inflated capacity, but payout for a period-C incident is limited by activePayableAmount, which uses the live currentActiveAssets (FirelightVault.sol#L926-L927). The vault therefore cannot deliver what the cover implies, and beneficiaries receive a reduced payout. The same stale snapshot also inflates the period-C exposure cap, since payout reads totalAssetsAt(capturePeriodStart) at line 911 — the cap says 1,000 while the vault holds 600, so the overstatement is visible on both sides of the ledger.
Duration. One period per occurrence: the snapshot corrects itself at the next period boundary. It recurs whenever a payout lands in the following period, which is an explicitly supported and expected flow rather than an edge case.
Preconditions, stated honestly. An incident whose payout executes in the period after capture — permitted by design — plus a commit or recommit after that payout in the same period. Nothing enforces or forbids that ordering. No role exceeds its authority, no attacker is required, and no market movement is involved; this is the in-scope contract defeating its own on-chain solvency guard while every dependency behaves correctly. That distinguishes it from the program's trusted-operator carve-out, which excludes reports of roles acting within their intended authority but preserves reports demonstrating an intended protocol constraint being bypassed.
Severity calibration. "Protocol insolvency" is the only entry in the program's in-scope impact list that describes this harm; the Low entry ("contract fails to deliver promised returns, but doesn't lose value") is inaccurate, because value is lost by beneficiaries. That said, I assess the practical severity as Medium rather than Critical and would not dispute that rating: the off-chain matching engine computes its own capacity and may catch the divergence independently, the window is bounded to one period, the multiplier is capped at 5×, and the resulting shortfall reaches beneficiaries as a reduced payout rather than as direct theft. The report is filed against the impact entry that fits; the severity is the program's to set.
References
Vulnerable code (commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62, branch v1_audit_ready):
CoverOrderAllocator.sol#L696-L725—_computeAvailableCapacity, the mixed-clock read (root cause)CoverOrderAllocator.sol#L710-L715— the staletotalAssetsAt(currentPeriodStart())leg specificallyCoverOrderAllocator.sol#L394-L403—_validateMatchingCapacity, the defeated solvency guardCoverOrderAllocator.sol#L348and#L378— the two write paths that reach itCoverOrderAllocator.sol#L82—MAX_LEVERAGE_FACTOR = 5, bounding the amplificationFirelightVault.sol#L952—payoutpushing its checkpoint atTime.timestamp(), after the period startFirelightVault.sol#L1130-L1132— the two-period payout window that makes a prior-period payout legal mid-periodFirelightVault.sol#L911and#L926-L927— the inflated exposure cap versus the live payable amountFirelightVault.sol#L456-L458—totalAssetsAtresolving viaupperLookupRecentFirelightVault.sol#L427-L429—totalAssets()net ofpendingWithdrawAssets
Prior audits referenced:
0xMacro, Firelight A-1, 2026-07-29 — M-1 (unbounded leverage, fixed at this commit) and accepted risk B2 (first-loss buffer). Neither addresses the staked-collateral snapshot.
Immunefi published known issues A4, A5 and A6.
Protocol documentation:
Core Concepts — Periods and Coverage Cycles: capacity aligns to period boundaries; periods are currently 1 day and extend to 30-day cover periods at the feature-complete launch.
Protocol Architecture — Claims and Liquidation: the payout waterfall and the two-period claim window.
Proof of Concept
A runnable Hardhat test is provided below. It runs the real CoverOrderAllocator against the real FirelightVault at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62 (branch v1_audit_ready), using the repository's own test/setup/fixtures.js. There is no mock vault and no hand-set vault state: the capacity divergence is produced entirely by an ordinary payout for a prior period's incident, executed inside the contract's own payout window.
The only mocks are the peripherals the allocator needs in any test — MockERC20 for the premium/buffer tokens and MockAggregatorV3 for the price feed, both taken from the repository's existing fixture. The price feed is pinned at $1.00 so the price leg is neutral and capacity is driven purely by the staked-asset quantity leg, and the first-loss buffer wallet is left empty for the same reason.
How to run
Save the file as test/zz-audit-poc-stale-capacity-e2e.js in the audit-comp-firelight repository and run:
Actual output
Test 1 demonstrates the vulnerability. Test 2 is a control: with no payout, the allocator's ceiling is exactly the honest 2,000 against 1,000 of real assets, proving the inflated ceiling in test 1 is caused by the payout and not by the fixture.
Step-by-step explanation
Vault-asset amounts use 6 decimals; capacity figures are in canonical 18-decimal USD. The fixture's capacity multiplier is effectiveLeverage / minCAR = 24000 / 12000 = 2.0×, with divergenceToleranceBps = 0 so the stale-input effect is isolated.
A payout for the prior period's incident executes
The payout remains legally inside its window.
payout(payout_receiver, 400, captureTs) transfers 400 assets out of the vault.
Assertions: totalAssets() == 600 — the real assets dropped — while totalAssetsAt(periodStartC) == 1000 — the capacity input did not move.
The allocator's own capacity ceiling is read directly out of the contract
Rather than inferring it, the test commits a deliberately excessive value and reads the ceiling from the revert, since MatchingCapacityOverflow(matchingCapacity, maxCapacity) carries it as an argument.
Assertion: the revert args are (2000e18 + 1, 2000e18) — the contract states its own ceiling is 2,000 canonical USD, computed from the stale 1,000, when honest capacity against the live 600 would be 1,200.
Result: the on-chain capital adequacy guard accepted cover 3.0× the real collateral, where the configured design intent was 2.0×.
Step 6 is the load-bearing one: it is the contract itself reporting the inflated ceiling, so the finding does not depend on the test's own arithmetic being correct.
Test source
Was this helpful?