88565 sc high capacity uses a period start snapshot a payout cannot lower so cover is sold against collateral already paid out
Submitted on Aug 13th 2026 at 20:34:18 UTC by @FaithfulLayney for Audit Comp | Firelight
Report ID: #88565
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
_computeAvailableCapacity values the vault leg of collateral at vault.totalAssetsAt(vault.currentPeriodStart()), while FirelightVault.payout records its post-payout total at Time.timestamp() and Checkpoints.upperLookupRecent returns the last checkpoint with key ≤ the search key. A payout executed at any moment after the period's first second is therefore excluded from the period-start lookup by construction. Assets already transferred out to pay a claim keep counting as collateral for the rest of the period, so commitAllocation accepts — and settleCoverOrder sells — cover the capital-adequacy floor was written to forbid. The contract's own constants bound the overstatement at 5.5× the amount paid out, persisting until the next period boundary: one day on the deployed vault. No attacker is involved; two individually-correct operator actions in their natural order are enough.
Vulnerability Details
Of the three collateral and price inputs to capacity, two are read live and only the vault leg is a snapshot:
// contracts/core/CoverOrderAllocator.sol:696-725
uint256 assetPriceUSD = $.priceFeedAdapter.getPrice($.maxPriceAge); // :700 LIVE
...
uint256 firstLossBufferUSDCanonical = Decimals.convert(
config.firstLossBufferToken.balanceOf(config.firstLossBuffer), // :705 LIVE
...
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()), // :711 STALE
...
uint256 strictCapacity = availableCollateralCanonical.mulDiv(config.effectiveLeverage, config.minCAR);The two terms of availableCollateralCanonical are read as of two different times, and they are exactly the two pots the payout waterfall (IncidentManager._executePayout, :621-706; the buffer draw at :658, the vault draw at :684) draws from. The first-loss-buffer leg self-corrects after a payout; the vault leg cannot:
Checkpoints.upperLookupRecent (Checkpoints.sol:217-224) returns the last checkpoint with a key lower than or equal to the search key, and the payout's key is Time.timestamp(), greater than currentPeriodStart() for every payout except one mined in the exact first second of the period — which the PoC confirms from the other side, as a control: a payout landing on that exact second does lower the snapshot.
Nothing compensates. There is no per-period "already paid out" accumulator anywhere in the allocator, capacity is validated only at commit or recommit, and settlement never revalidates it: the committed figure is stored at :352 (or :381 on recommit) and thereafter read only at :507, as the base the per-protocol concentration cap is a percentage of. The stale number is consulted exactly once, at :401 (matchingCapacity <= maxCapacity), with :402 chaining totalAllocated <= matchingCapacity onto it — and :388-392 documents that this is deliberate ("A commitment stores the capacity THE MATCHER RAN AGAINST … not a capacity recomputed at transaction time"), which is precisely why the single commit-time check has to be correct.
A period-start valuation is defensible for exposure: withdrawal requests made during the period stay slashable, and payout handles that explicitly by adding withdrawAssets[capturePeriod+1]/[capturePeriod+2] back into activePayableAmount (:916-926). A payout is categorically different — those tokens are transferred out and can never back anything again. payout's own comment says as much, calling the period-start figure "an exposure cap, not a segregated asset bucket" (FirelightVault.sol:909-910): the vault treats it as exposure a payout consumes, while the allocator keeps selling against the same number as if untouched.
The period-start basis is documented, but in one direction only. The @dev at CoverOrderAllocator.sol:690-691 states it, and the sponsor's tests pin the intent on the deposit side: test/coverOrderAllocator/commitAllocation.js:230 asserts "mid-period deposits do NOT inflate capacity", and :248 asserts a commit reverts when it exceeds snapshot-based capacity even if live totalAssets would cover it. Both fix the snapshot below live, making it a conservative floor against an increase. The outflow direction is untested: across the four sponsor test files that call commitAllocation — testVectors.js, commitAllocation.js, capacityConfigCheckpoints.js, premiumTokenDecimals.js — the string payout does not appear once.
Both calls are role-gated and neither role misbehaves: a payout during period P — its window is exactly {capturePeriod, capturePeriod+1} (FirelightVault.sol:1130-1132) — followed by the designed one-commit-per-period allocation for P is the ordinary sequence. The interface's own documented recipe computes capacity from live vault.totalAssets() (ICoverOrderAllocator.sol:40-46), so even a matcher that reads correctly is unprotected: the on-chain check accepts anything up to the stale figure. The constraint that gets bypassed is one the contract asserts about itself three lines above the leverage constant: "Real backing behind sold cover is minCAR / (effectiveLeverage × (1 + tolerance)), so bounding the ratio floors it at 1 / (MAX_LEVERAGE_FACTOR × (1 + tolerance)) regardless of the configured minCAR" (CoverOrderAllocator.sol:79-81) — an absolute floor of 1/5.5 ≈ 18.18% real backing, enforced only by the check at :401, which reads the stale figure. PoC-030 part 2b drives that floor to 0%. This is the exception the scope page's "Privileged actors" rule carves out; that rule reads, in full: "Any bug report that requires a privileged role holder to act within the permissions explicitly granted to that role is out of scope, unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint." Neither role exceeds its permissions — acting strictly within them is what bypasses the constraint.
The fix is to value the vault leg at the live figure the sponsor's own recipe already specifies — $.vault.totalAssets() at :711 — or, if a period-start basis must be kept for exposure, to subtract a per-period paid-out accumulator from it before applying leverage.
Impact Details
Protocol insolvency: cover is issued against collateral that has already left the vault. The overstatement is
bounded by the contract's own constants — MAX_LEVERAGE_FACTOR = 5 (:82, enforced :665-666) and divergenceToleranceBps ≤ 1_000 (:78, enforced :668-669) — at 5.5× the amount paid out, and it persists to the next period boundary. Nobody gains: the victims are the LPs whose remaining stake now backs more cover than the capital-adequacy policy allows, and the buyers settled into the over-issued tranche who pay premium for cover the vault cannot pay.
Measured on the real FirelightVault wired to the real CoverOrderAllocator:
Run
Config
Deposited
Paid out in-period
totalAssets() after
Capacity still granted
Result
PoC-010 #1
minCAR=12000, effectiveLeverage=12000 (1.0×), tolerance 0
30,000 fXRP
15,000
15,000
30,000e18
15,000e18 canonical USD of cover with zero backing; real backing 0.5 against this configuration's floor of 1.0 — 2× over-issued
PoC-010 #2 (claim from P-1 paid during P)
same
30,000 fXRP
20,000
10,000
30,000e18
3× the real collateral
PoC-030 part 2
minCAR=12000, effectiveLeverage=24000 (2.0×), tolerance 0
1,000,000 fXRP
500,000
500,000
2,000,000e18
2,000,000e18 of cover settled; real backing 2500 bps against a 5000 bps policy floor
PoC-030 part 2b
same
1,000,000 fXRP
100% of the vault
0
2,000,000e18
1,200,000e18 of cover committed and settled against $0 of staked collateral
In the first two runs the bound is proved exactly rather than inferred: commitAllocation with matchingCapacity = staleFigure is accepted, and with staleFigure + 1 reverts MatchingCapacityOverflow(staleFigure+1, staleFigure). The on-chain maximum is the pre-payout snapshot.
No external precondition is involved — no price move, no depeg, no oracle misbehaviour, no third party; the PoC holds the price at $1.00 and the first-loss buffer at zero. The scope page's known-issues list discloses that a USD price decrease reduces capacity, and that severe collateral drawdowns "can reduce coverage capacity and may cause shortfalls or undercollateralization relative to outstanding cover obligations"; the price leg is read live at :700, so the price case behaves as disclosed, and both disclosures describe capacity that reduces. This is the opposite case: a collateral outflow at constant price against which capacity provably does not reduce — the allocator returns the identical maxCapacity before and after (2,000,000e18 both times, with totalAssets() halved between them).
The in-scope Phase-2 allocator is not deployed yet; the vault proxy it will be wired to, 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3 on Flare, currently runs the out-of-scope Phase-1 implementation. Read-only eth_call at block 67152319: totalAssets() = 58,333,517.103995 fXRP, periodConfigurations(0) = (epoch 1764682969, duration 86400, startingPeriod 0) — a one-day period. At that TVL a single 10% incident payout (≈ 5.83M fXRP) would license up to ≈ 32M fXRP-equivalent of cover with no collateral behind it before the next boundary; 5.5× is the enforced ceiling, not a configured live value.
References
Repository immunefi-team/audit-comp-firelight @ 42f9ea5e43d88b35197b901acc2a8c24b314fa62:
CoverOrderAllocator.sol#L696-L725—_computeAvailableCapacity; the stale read is:711FirelightVault.sol#L456-L458—totalAssetsAtFirelightVault.sol#L892-L966—payout; the checkpoint push at:952, the withdrawal add-back at:916-926lib/Checkpoints.sol#L217-L224—upperLookupRecentreturns the last key ≤ the search keyICoverOrderAllocator.sol#L40-L41— the sponsor's own recipe uses livevault.totalAssets()
Proof of Concept
Step-by-step
Roles: CURATOR_ROLE (books the cover order, :198), PAYOUT_ROLE (executes an approved claim), ALLOCATOR_ROLE (commits the period's matching tree at :337 and settles at :446); the capacity config is set by CONFIG_ADMIN_ROLE at initialization. No attacker, and no user behaviour beyond the ordinary product flow — LPs deposit, the buyer approves premium: the capacity overstatement itself is produced by two individually-correct operator actions in their natural order.
LPs stake. The vault's
totalAssets()and its period-start checkpoint agree.Period
Pbegins.currentPeriodStart()is fixed for the rest of the period, and_computeAvailableCapacitywill read the vault leg at exactly that key (CoverOrderAllocator.sol:711).A claim is paid during
P.FirelightVault.payouttransfers assets out and pushes the reduced total atTime.timestamp()(:952) — a key strictly greater thancurrentPeriodStart().totalAssets()drops;totalAssetsAt(currentPeriodStart())does not, becauseupperLookupRecentonly considers keys ≤ the search key.The allocator commits
P's matching tree.commitAllocationvalidatesmatchingCapacityagainst the stale figure and accepts it. The bound is proved exactly: the stale figure is accepted andstaleFigure + 1revertsMatchingCapacityOverflow(staleFigure+1, staleFigure).Cover settles against it.
settleCoverOrderconsumescommit.totalAvailableCapacitywith no revalidation (:507-509), so the over-issued cover becomes live cover.Control 1: with no in-period payout, the same commit is bounded by the real collateral. Control 2: a payout mined in the exact period-start second does lower the snapshot — which isolates the checkpoint key as the root cause.
Net result: cover in force 30,000e18 canonical USD against 15,000e18 of real collateral — real backing 0.5 against this configuration's floor of 1.0, i.e. twice the cover the policy permits, with no attacker and no external condition.
Runnable test A — the bound is exactly the stale snapshot
Reproduced from a clean checkout at the competition commit with this single file added. Local Hardhat only; no transaction was sent to any live or public network.
test/poc/h010_capacity_stale_period_start_snapshot.js:
Output (one run; runtimes are machine-dependent, every other value is deterministic):
Test B — end-to-end commit → settle, the 100%-payout extreme, and the checkpoint-key control
A second test wires the production allocator to the production vault at minCAR = 12000, effectiveLeverage = 24000 (2.0×) and drives the full commit → settle path. Same repository, same commit, same run instructions, substituting this file's name in the cp and npx hardhat test lines; add as test/poc/030_capacity_stale_period_start.js. Full source:
Output (one run; runtimes are machine-dependent, every other value is deterministic):
Part 1 isolates the mechanism (totalAssets() halves, totalAssetsAt(periodStart) does not), part 2 shows 2,000,000e18 of cover settled at 2500 bps of real backing against a 5000 bps policy floor, part 2b shows full capacity surviving a payout that empties the vault entirely, and part 3 is the control: a payout mined on the period-start second does lower the snapshot, which pins the checkpoint key as the root cause.
Was this helpful?