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

88925 sc high c 2 coverorderallocator contract values collateral at a period start snapshot that a mid period payout never updates so cover is sold paid for and receipted against a vault

Submitted on Aug 15th 2026 at 18:03:40 UTC by @web3pavlou for Audit Comp | Firelight

  • Report ID: #88925

  • 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

Severity selected: Critical — Protocol insolvency Assets: contracts/core/CoverOrderAllocator.sol, contracts/core/FirelightVault.sol, contracts/core/lib/Checkpoints.sol Requires an attacker: No. Reached by a routine operator sequence.

Brief/Intro

_computeAvailableCapacity (CoverOrderAllocator.sol:696) values staked collateral at $.vault.totalAssetsAt($.vault.currentPeriodStart()) (:711). payout writes its post-slash checkpoint at Time.timestamp() (FirelightVault.sol:952). Because upperLookupRecent selects the most recent checkpoint with key ≤ the search key, any payout executing after a period's first block is invisible to that period's capacity calculation.

The vault can therefore be drained to zero while the allocator continues to write cover against the pre-payout figure. The PoC below carries this all the way through to the sale: the vault reports totalAssets() == 0, and in the same period the allocator commits $2,000,000 of capacity, settles $100,000 of cover, collects 95.89 USD* of premium from the buyer, and mints the CoverNFT receipt. The obligation exists, the money changed hands, and there is nothing behind it.

Scope: this is not a privileged-actor report

ALLOCATOR_ROLE performs an ordinary commitAllocation and an ordinary settleCoverOrder, both within its granted permissions. The program's exclusion applies "unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint."

The intended protocol constraint here is explicit in the code: _validateMatchingCapacity (:394) exists to bound committed cover by available collateral against minCAR. This report demonstrates that the constraint is bypassed — not by a bad parameter, and not by the role exceeding its permissions, but because the constraint's input is stale. The role cannot detect the staleness and cannot avoid it. Control 1 confirms the bound itself works correctly once the input is current, which isolates the bypass to the input rather than to operator behaviour.

Vulnerability Details

Checkpoints.upperLookupRecent(key) returns the value of the latest checkpoint whose key is ≤ key. A payout at periodStart + Δ for any Δ > 0 writes its checkpoint at a key strictly greater than periodStart, so totalAssetsAt(periodStart) walks past it and returns the pre-payout figure.

Boundary behaviour, proved rather than asserted. A payout landing in a period's first block writes its checkpoint at exactly periodStart and is seen, because the lookup is inclusive. Every later payout is not. Control 2 in the PoC demonstrates this directly: it executes the payout on the boundary, shows totalAssetsAt(periodStart) correctly returning 0, and shows the same commit being correctly rejected. This matters because a reviewer reproducing the issue at the period boundary would otherwise see the mechanism behave correctly and conclude there is no bug. The finding case advances one hour into the period. Given the documented claims pipeline (48h report → 24h assessment → 72h validation), no genuine payout lands in a period's opening block.

The overstated figure flows straight into the capacity bound that _validateMatchingCapacity (:394) enforces on commitAllocation (:332), amplified by effectiveLeverage / minCAR and widened by divergenceToleranceBps. With MAX_LEVERAGE_FACTOR = 5 and MAX_DIVERGENCE_TOLERANCE_BPS = 1000, the sellable overstatement reaches 5.5× the drained amount. The PoC uses a conservative 2.0× with zero tolerance.

The buffer leg is read live (balanceOf) and does update. Only the vault leg is stale, so the two halves of availableCollateralCanonical are measured at different points in time.

Note also that recommitAllocation re-validates against the same stale snapshot, while its NatSpec claims the recommit "stays bound to the period's real collateral within tolerance." For the vault leg it is not.

Attack path

No attacker; this is an ordinary operator sequence.

1

Stakers hold 1,000,000 FXRP

A cover order for period P is authored in P−1.

2

Period P begins

The period-start snapshot records the full 1,000,000 FXRP.

3

An approved incident drains the vault

One hour into P, an incident captured in P−1 is approved. The payout window is capturePeriod or capturePeriod + 1, so this is legal. The vault is drained to zero.

4

The allocator commits against stale collateral

_computeAvailableCapacity reads totalAssetsAt(periodStart) — still 1,000,000 FXRP — and returns $2,000,000 of capacity against a vault holding nothing.

5

Cover is settled and receipted

The allocator's routine settlement pass runs. The order settles, the buyer is charged a full-period premium, and the CoverNFT is minted — against collateral that no longer exists.

Ordering is not a meaningful constraint. Periods are currently 1 day, and commitAllocation requires graceExpiresAt < currentPeriodEnd(), so with MAX_GRACE_PERIOD = 8h the commit must fall inside roughly the first 16 hours of the period — leaving ample room for a payout to precede it. A payout is not an exotic event; it is the protocol's core function.

Impact Details

Protocol insolvency. The protocol sells cover obligations in excess of the collateral backing them, and the PoC carries this through to a completed sale rather than stopping at the accounting error. At the moment settleCoverOrder returns:

Quantity
Value

vault.totalAssets()

0

Cover written (canonical)

100,000 × 10¹⁸

Premium collected from the buyer

95.89 USDC

coverNFT.ownerOf(orderId)

the buyer

Committed capacity

2,000,000 × 10¹⁸

The invariant totalDeclaredAllocated ≤ matchingCapacity ≤ _computeAvailableCapacity() is false the moment the commit lands, because the input to the last term is stale.

Cover buyers pay a full-period premium for cover backed by collateral the protocol has already paid out, and hold a claim on a vault that cannot pay it.

Stakers are levered beyond the minCAR floor the contract exists to enforce.

Every downstream control that derives from commit.totalAvailableCapacity, including the per-protocol concentration caps applied in _settleCoverOrder, inherits the overstatement.

Distinction from the published Known Issues

Two entries on the program's scope page are adjacent and should be addressed directly:

  • "If the underlying staked asset decreases in USD value, available capital and the Capital Adequacy Ratio decrease. Severe collateral drawdowns can reduce coverage capacity and may cause shortfalls or undercollateralization relative to outstanding cover obligations." — This concerns price decline in the staked asset, which the oracle reads live at commit time and which therefore does reduce capacity correctly. This finding concerns a payout, which reduces the asset balance but is structurally invisible to the period-start lookup. Different mechanism, and one of them is a functioning control while the other is not.

  • "Aggregate claims are not capped on-chain against total cover sold." — That is the claims side: payouts not bounded by cover written. This is the underwriting side: cover written not bounded by collateral held. Opposite direction.

This does not appear in Macro A-1's issue list (M-1, M-2, L-1 through L-4, G-1, I-1 through I-11), in the three Known Issues, or in the 46 disclosed reports from the November 2025 competition — which had only the vault in scope. Known Issue #3 concerns consuming the same cover more than once; this concerns selling cover against collateral that is gone.

Bound the snapshot by live assets. At CoverOrderAllocator.sol:711:

This preserves the determinism the period-start snapshot provides for the off-chain matcher, while ensuring a payout can only ever reduce capacity, never leave it overstated. Control 1 shows the bound behaving correctly once its input is current, so no other change is required.

A defence-in-depth companion, if the team wants the settle path guarded independently of the commit path, is to re-validate live capacity inside _settleCoverOrder rather than trusting commit.totalAvailableCapacity alone.

References

  • contracts/core/CoverOrderAllocator.sol:696-726 — _computeAvailableCapacity

  • contracts/core/CoverOrderAllocator.sol:711 — the stale period-start read (root cause)

  • contracts/core/CoverOrderAllocator.sol:394 — _validateMatchingCapacity

  • contracts/core/CoverOrderAllocator.sol:332 — commitAllocation

  • contracts/core/CoverOrderAllocator.sol:457 — _settleCoverOrder, which inherits the overstatement

  • contracts/core/FirelightVault.sol:952 — post-payout checkpoint written at Time.timestamp()

  • contracts/core/lib/Checkpoints.sol — upperLookupRecent, inclusive of its key

Proof of Concept

Foundry. Uses the shared Base.t.sol fixture, a Solidity port of the project's own test/setup/fixtures.js. This PoC calls deployVault() and deployFullStack() — the latter wires the allocator to the real FirelightVault rather than MockCoverOrderAllocatorVault, because the finding depends on genuine vault state and on a real payout executing. The first-loss buffer wallet is left at zero balance so capacity reduces cleanly to assets × 1e12 × effectiveLeverage / minCAR, isolating the vault leg.

IncidentManager is not needed: payout is called directly under PAYOUT_ROLE, and the resulting vault state is identical to the one IncidentManager._executePayout produces.

Four tests: the finding, the mechanism in isolation, and two controls.

test/Foundry/Base.t.sol

test/Foundry/mocks/MockFAsset.sol

test/Foundry/UnderwritingAfterLoss.t.sol

Output

The four runs bracket the defect from both sides:

Run
What it establishes

The finding

The vault holds 0; $100,000 of cover settles, premium is collected, the receipt is minted.

Mechanism

The same stale snapshot lets $2,000,000 of capacity be committed against a zero vault — no order needed.

Control 1

Once the snapshot is current, the identical commit reverts MatchingCapacityOverflow. The bound works; only its input is wrong.

Control 2

A payout on the period's first block is seen, and the commit is correctly rejected. The defect is precisely "payouts strictly after the opening block."

Control 1 forecloses "the capacity check is simply misconfigured" — it is not, and it functions correctly the moment it is given live data. Control 2 forecloses "the lookup is fine, you tested it wrong" — the inclusive-lookup boundary is reproduced and shown behaving correctly, which is the only configuration in which this defect does not appear.

Was this helpful?