For the complete documentation index, see llms.txt. This page is also available as Markdown.

88604 sc high stale collateral basis in computeavailablecapacity lets a period underwrite cover below the protocol s own minimum backing

Submitted on Aug 14th 2026 at 04:25:38 UTC by @Dead_Aizen for Audit Comp | Firelight

  • Report ID: #88604

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/interfaces/ICoverOrderAllocator.sol

  • Impacts:

    • Protocol insolvency

Description

Summary

_computeAvailableCapacity values the vault leg of collateral at totalAssetsAt(currentPeriodStart()). FirelightVault.payout() records its collateral-reducing checkpoint at Time.timestamp(). A payout executed after a period has begun therefore never reaches that period's capacity basis.

Because an incident captured in period P stays payable through P+1, a claim on period P's cover can be settled during P+1, draining collateral that P+1's capacity was already sized against. The cover being sold in P+1 is not the cover being claimed against — it is new cover for different buyers.

The snapshot itself is deliberate and correct for its intended case. Withdrawal requests raised during a period are assigned to withdrawAssets[period + 1], and payout() draws from that bucket, so those assets remain exposed to the period's incidents and must keep counting toward capacity. The snapshot preserves them correctly. It does not distinguish assets that are leaving but still exposed from assets that have already left via payout and are exposed to nothing.

Severity / impact: Critical — Protocol insolvency Commit: 42f9ea5e43d88b35197b901acc2a8c24b314fa62 (v1_audit_ready) Files: contracts/core/CoverOrderAllocator.sol, contracts/core/FirelightVault.sol

Root cause

CoverOrderAllocator.sol:711

FirelightVault.sol:456

upperLookupRecent returns the latest checkpoint at or before the key. FirelightVault.sol:952, inside payout:

That key is strictly greater than currentPeriodStart() for any payout after the period boundary, so it is invisible to every capacity computation for the period in flight.

Within the same function the first-loss buffer is read live:

The two collateral legs use different time bases. If period-start valuation expressed a solvency policy, it would apply to both.

Invariants broken

CoverOrderAllocator.sol:79-82, above MAX_LEVERAGE_FACTOR:

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.

MAX_LEVERAGE_FACTOR = 5, MAX_DIVERGENCE_TOLERANCE_BPS = 1000. Asserted floor: 18.18%. Measured: 12.5%.

MIN_CAR_BPS = 12_000 (L84, enforced L662) makes a 1.20 CAR a code invariant. Measured: 0.30.

CoverOrderAllocator.sol:75-78, above MAX_DIVERGENCE_TOLERANCE_BPS:

Kept small: the tolerance is accepted under-collateralization vs the CAR target, only meant to absorb the price/FLB drift between the off-chain matcher snapshot and commit inclusion.

The protocol caps acceptable under-collateralization at 10%. This produces roughly 31%, from a source the tolerance does not cover.

Sequence

Every role acts correctly. No role exceeds its authority.

  1. P0 — stakers deposit 2,000. A cover order is created (orders target currentPeriod() + 1).

  2. P1 — ALLOCATOR_ROLE commits the matching tree; the order settles for 1,500 of cover. An incident is captured in P1.

  3. P1 — CURATOR_ROLE confirms and files an assessment for the full allocated cover. Submitted.

  4. P2 — ASSESSMENT_APPROVER_ROLE approves. IncidentManager calls FirelightVault.payout(); 1,500 leaves the vault. Collateral is now 500.

  5. P2 — commitAllocation(P2) reads totalAssetsAt(startP2), still 2,000, and accepts 4,000. An order created in P1 targeting P2 settles for 3,000 of cover, minting a CoverNFT.

Ordering in steps 4 and 5 is not load-bearing. Committing before the payout ends in the same state: cover sold against collateral that has since left.

Proof of concept

A single self-contained file, test/integration/firelight-capacity-poc.js. It depends only on the repository's own helpers (test/setup/fixtures.js, test/oracle/fixtures.js) and needs no other new file.

Production contracts in the path, unmodified: FirelightVault, CoverOrderAllocator, IncidentManager, CoverNFT, FtsoChainlinkAdapter — five of the fourteen in-scope assets. Stubbed: the ERC20 tokens and the Flare FTSO/registry services, using the repository's own contracts/test/ mocks and its own plantFlareMocks() helper, which stamps MockFlareRegistry at the canonical registry address the adapter hard-codes. No mock contract was authored for this report and nothing in the defective path is mocked.

Asserted, in order: totalAssetsAt(startP2) == 2000e6 before the payout; the payout receiver gains exactly 1500e6 and totalAssets() falls to 500e6; totalAssetsAt(startP2) is still 2000e6 afterwards, and remains so after checkpointTotalAssets(); a commitment of capacity + 1 wei reverts MatchingCapacityOverflow, establishing 4,000 as a hard ceiling rather than slack; the P2 order settles for 3000e18 with totalAssets() still 500e6; and both floors, computed from the live getCapacityConfigAt(P2) values.

No mitigation exists

checkpointTotalAssets() (FirelightVault.sol:523) pushes at Time.timestamp(). Since totalAssetsAt(startP2) reads only checkpoints at or before startP2, a checkpoint written mid-period cannot repair the basis. The PoC grants CHECKPOINT_ROLE, calls it, and asserts totalAssets() == 500e6 while totalAssetsAt(startP2) == 2000e6.

Settlement provides no second check. _settleCoverOrder bounds totalSettledCover against the commitment's totalDeclaredAllocated and checks concentration caps against the stored totalAvailableCapacity — both frozen at commit time. Live vault assets are never re-read. The PoC settles a P2 order for 3,000 of cover after the payout to show this.

VaultRewardDistributor.sol:148 shows the inflow case was handled deliberately:

Checkpoint atomically so historical totalAssetsAt lookups (period-start snapshots) include the forwarded assets.

The outflow path has no equivalent treatment for the period in flight.

Impact

Cover obligations for a period can exceed backing by a margin bounded only by how much the protocol pays out during it. At the 2.0× multiplier used in the PoC, a full-loss claim yields a 6× notional-to-collateral ratio. At maximum permitted leverage, a payout of ~9.1% of the vault breaches the 18.18% floor.

Cover buyers in the affected period hold receipts the collateral cannot honour; a later claim is silently short-paid, since payout caps at available assets and returns the reduced amount without reverting. Stakers carry claim exposure at a ratio minCAR and MAX_LEVERAGE_FACTOR exist to prevent.

The protocol is intentionally fractionally reserved. The defect is that it reserves below the minimum ratio its own code asserts it holds regardless of configuration.

Remediation

Value the vault leg live — replace totalAssetsAt(currentPeriodStart()) with totalAssets() — matching the treatment already given the first-loss buffer. Matcher determinism is already served by divergenceToleranceBps, whose stated purpose is absorbing snapshot-to-commit drift.

Alternatives: re-validate capacity in _settleCoverOrder against the live ceiling; or have payout() additionally write its reduced total at the current period's start key.

Proof of Concept

Was this helpful?