88214 sc high period p 1 cover capacity is computed from collateral a pending period p claim will consume
Submitted on Aug 12th 2026 at 09:29:19 UTC by @shadowHunter for Audit Comp | Firelight
Report ID: #88214
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
Impacts:
Protocol insolvency
Description
Description
The amount of cover Firelight may sell in a period is bounded on-chain by _computeAvailableCapacity. That function values the collateral as the live first-loss buffer balance plus the staked assets snapshot, and applies the leverage / capital adequacy ratio:
contracts/core/CoverOrderAllocator.sol:696-725
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), // :705 live balance
_readDecimals(address(config.firstLossBufferToken)),
CANONICAL_DECIMALS,
Math.Rounding.Floor
);
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()), // :711 period-start 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
);
}Neither term is reduced by exposure that is already owed. The payout window for a capture period P spans periods P and P+1:
contracts/core/FirelightVault.sol:1130-1132
So a period-P claim is legitimately paid during period P+1, drawing on exactly the first-loss buffer and vault assets that commitAllocation(P+1) already counted as free capacity for period P+1's cover. The same collateral backs two periods of cover simultaneously.
The protocol already recognises this entanglement — on the wrong path
FirelightVault deliberately blocks deposits for precisely this reason. The comment is explicit, contracts/core/FirelightVault.sol:1112-1118:
The team identified that fresh capital must not back unresolved exposure, and hard-gated the depositor path on-chain. The capacity path — the one that actually determines solvency — was left ungated. CoverOrderAllocator.sol contains zero references to hasActiveIncident; neither commitAllocation nor _settleCoverOrder consults incident state at all.
Anticipated objection
matchingCapacity is supplied by the ALLOCATOR_ROLE and only upper-bounded on-chain (_validateMatchingCapacity, contracts/core/CoverOrderAllocator.sol:394-403), so one could argue the off-chain matcher should net out pending incidents. Three reasons that does not dispose of the finding:
The documented off-chain formula the matcher is told to implement,
contracts/core/interfaces/ICoverOrderAllocator.sol:39-41, contains no term for unresolved incidents either. A matcher implementing the specification exactly will over-sell:Note in passing that this specification also disagrees with the on-chain implementation on which asset figure to use: the spec says
vault.totalAssets()(live), while_computeAvailableCapacityat:711readsvault.totalAssetsAt(vault.currentPeriodStart())(period-start snapshot). The two diverge by every deposit, payout and reward distribution that lands inside the period, so a compliant matcher'smatchingCapacityand the chain's own bound are computed from different collateral figures.The entire purpose of
_validateMatchingCapacity,MAX_LEVERAGE_FACTOR,MIN_CAR_BPSanddivergenceToleranceBpsis that the chain, not the allocator, enforces capital adequacy. That enforcement evaporates in the one window where it matters._settleCoverOrderhas no incident check either, so a gate oncommitAllocationalone would be insufficient.
Why this is not the disclosed known issue
The program lists as a known issue:
"Aggregate claims are not capped on-chain against total cover sold; the MVP relies on trusted incident/assessment roles and the approval process to prevent duplicate, excessive, or otherwise invalid claims."
That disclosure is about the claims side: nothing stops assessors from approving losses exceeding the cover that was sold. This finding is on the capacity side and concerns a different pair of quantities entirely:
Quantities compared
assessed claims vs. cover sold
cover sold vs. unencumbered collateral
Control
Trusted assessment roles
The on-chain guard _validateMatchingCapacity / MIN_CAR_BPS / MAX_LEVERAGE_FACTOR
Mitigation relied on
Human approval process
Nothing — the chain computes and accepts the figure itself
Here the claim is entirely legitimate, correctly sized, and within the cover sold for its period. The defect is that the collateral backing period P+1's capacity has not been reduced by that legitimate period-P obligation. No assessor misbehaves and no claim is excessive; the numbers the chain itself computes are wrong.
Likewise this is not Macro M-1 (unbounded effectiveLeverage, Fixed) or L-4 (CAR floor 1.0 vs. 1.2, Fixed). Both concern the multiplier applied to collateral. This finding concerns the collateral figure the multiplier is applied to, and reproduces at a fully compliant 2× leverage with divergenceToleranceBps = 0.
On the privileged-actor exclusion
matchingCapacity is submitted by ALLOCATOR_ROLE, but this report does not require that role to misbehave — it submits the figure the documented formula produces, and the chain independently computes and accepts it. The report demonstrates a bypass of an intended protocol constraint: _validateMatchingCapacity, MIN_CAR_BPS, MAX_LEVERAGE_FACTOR and divergenceToleranceBps exist precisely so the chain, not the allocator, enforces capital adequacy, and step 6 below shows the chain approving a capacity figure it rejects one period later on unchanged collateral.
Steps to reproduce
No malicious actor is involved. Every step is a role performing its documented function.
1. Collateral is in place. The first-loss buffer holds $1,000,000 and the vault has $4,000,000 of staked assets at the period-start snapshot.
2. Period 2: the allocator commits $10,000,000 of capacity and settles $5,000,000 of cover.
3. A period-2 claim is assessed but not yet paid. This is normal: assessment rounds take time, and the protocol explicitly allows payment during period 3.
4. Period 3 begins. Nothing is added to the buffer and nothing new is staked.
5. The allocator commits period 3 — and the chain grants the identical capacity. _computeAvailableCapacity re-reads the same buffer balance and the same staked snapshot, with no deduction for the outstanding period-2 claim.
At this instant vault.isPeriodInPayoutWindow(2) == true — the period-2 claim is still payable from exactly this collateral. $10,000,000 of live cover now stands against $5,000,000 of collateral.
6. The period-2 claim is paid. _executePayout drains the first-loss buffer first, then the vault (contracts/core/IncidentManager.sol:641-684). Buffer → $0, staked → $500,000.
7. Period 4 proves the collateral was never there. The allocator submits the same capacity figure it was granted for period 3, and the chain now rejects it:
The identical number is accepted for period 3 and rejected for period 4. The only thing that changed in between is that the period-2 claim was actually paid — which is precisely the exposure period 3's capacity should have netted out.
Impact
Protocol insolvency. Cover is sold against collateral that is already committed to paying an earlier period's claims:
After the period-2 payout in the worked example, $5,000,000 of period-3 cover is backed by $500,000 of remaining collateral — a 10× breach of the committed capital adequacy ratio, produced by parameters the chain itself validated.
The example uses the most conservative legal configuration (
divergenceToleranceBps = 0). At the repository fixture's own 2× leverage with a non-zero tolerance the overhang is strictly larger.No role misbehaves. The allocator submits a capacity number the on-chain guard computed and accepted; the approver executes a payout inside its documented window.
The condition recurs on every period boundary where an incident is unresolved, and unresolved incidents are the normal state during an assessment.
The on-chain capital-adequacy machinery (MIN_CAR_BPS, MAX_LEVERAGE_FACTOR, divergenceToleranceBps, per-protocol concentration caps) provides no protection here, because every one of them is applied to a collateral figure that has not been reduced by outstanding obligations.
Recommendation
Net outstanding assessed-but-unpaid exposure out of both collateral terms. Have
IncidentManagerexpose per-period outstanding exposure and subtract it inside_computeAvailableCapacitybefore applyingeffectiveLeverage / minCAR:A
hasActiveIncidentgate alone is not sufficient. If the commit lands before the incident is created, such a gate never fires. Only a reservation/netting model closes it — or deferring settlement of periodP+1until periodP's payout window has closed.Gate
_settleCoverOrderas well ascommitAllocation. Capacity consumed at settlement must be validated against the same netted figure, otherwise orders committed before an incident can still be settled after it.Escrow the first-loss buffer in a contract with per-period reservations instead of reading a live EOA balance at
CoverOrderAllocator.sol:705. A balance that can be spent between the commit and the period end is not collateral.
Proof of Concept
Proof of Concept
Environment
42f9ea5e43d88b35197b901acc2a8c24b314fa62 is v1_audit_ready
Create hardhat.poc.config.js in the repository root
Save the PoC as test/poc_capacity_double_count.js
Run
Output
What the output proves
The passing assertions establish the following chain of facts against the real CoverOrderAllocator:
commit2.totalAvailableCapacity == $10,000,000— the chain computed $10M of capacity from $5M of collateral at the configured 2× multiplier, and$5,000,000of cover settled.usdc.balanceOf(firstLossBufferWallet) == flbBeforeafter the period-3 sale — proof that no new collateral was added between the two periods.vault.isPeriodInPayoutWindow(2) == truewhile committing period 3 — proof that the period-2 claim is still payable from that same collateral at that exact moment.commit3.totalAvailableCapacity == commit2.totalAvailableCapacity— the decisive assertion. Period 3 received the identical capacity grant, with no deduction for the outstanding period-2 exposure.liveCover == $10,000,000— two periods of cover, each $5M, live simultaneously against $5M of collateral.The final
commitAllocation(4, ..., CAPACITY)revertsMatchingCapacityOverflow. This is the same capacity number the chain accepted at step 4. The only intervening change is that the period-2 claim was paid, draining the buffer to zero and reducing staked assets to $500,000.
Step 6 is the clearest statement of the bug: the protocol's own capacity guard rejects, one period later, the exact figure it approved — because the obligation it should have netted out has now been settled in cash.
Was this helpful?