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

88279 sc high stale period start collateral accounting lets prior period payouts leave newly settled cover fully unbacked

Submitted on Aug 12th 2026 at 15:19:30 UTC by @Porcupine17126 for Audit Comp | Firelight

  • Report ID: #88279

  • 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

Summary

CoverOrderAllocator._computeAvailableCapacity() values vault collateral exclusively through:

$.vault.totalAssetsAt($.vault.currentPeriodStart())

That historical value is not reduced when a claim from the previous period is paid during the current period. Firelight explicitly permits a claim to be paid in its capture period or the following period. Consequently, a legitimate period P payout executed in period P + 1 can drain the vault, while a later period P + 1 allocation commitment continues to count the pre-payout balance recorded at the start of P + 1.

The allocator therefore accepts and settles new cover against collateral that has already left the vault. The buyer is charged a premium and receives a MATCHED CoverNFT, but a valid claim for that cover can receive a zero vault payout. The PoC funds and approves the protocol's external first-loss buffer, then exhausts it through the legitimate prior claim before demonstrating the stale vault accounting.

Suggested severity: Critical — protocol insolvency. The discrepancy can reach 100% of the vault-backed cover sold for the new period.

Affected code

Scope commit: 42f9ea5e43d88b35197b901acc2a8c24b314fa62

In contracts/core/CoverOrderAllocator.sol:696-724, capacity always uses the historical period-start balance:

In contracts/core/FirelightVault.sol:903-927, payouts are intentionally allowed in the capture period or the following period and are paid from currently available vault assets:

The payout records the reduced active balance only at the payout's current timestamp (FirelightVault.sol:952):

Because totalAssetsAt(periodStart) performs an inclusive historical lookup at the earlier period-start key (FirelightVault.sol:456-457), the later checkpoint cannot reduce that value.

Root cause

The protocol uses two incompatible meanings of vault collateral:

  1. Allocation capacity treats the period-start asset checkpoint as immutable for the whole period.

  2. A previous-period claim is allowed to consume those same assets after the new period has started.

There is no reservation for the unresolved previous-period liability, and no post-payout adjustment to the current period's matching capacity.

The contract's own documentation confirms that this validation is meant to reflect current backing: commitAllocation() describes capacity as "recomputed live," and recommitAllocation() says that fresh validation keeps commitments bound to the period's "real collateral." However, both ultimately validate through _computeAvailableCapacity(), which combines the live first-loss-buffer balance with the immutable period-start vault checkpoint—even after those vault assets have already been paid out. The snapshot may be intentional; counting already-spent collateral as "real collateral" in a fresh commitment is the failure.

This is especially likely in the intended workflow because the matching engine pins its state to the period-start block for deterministic matching. Even if operators resolve the old incident before committing the new allocation—the safest possible ordering—the on-chain capacity check still accepts the stale period-start balance.

Impact

  • New cover can be fully matched with no remaining vault collateral.

  • The buyer pays the premium and receives a valid CoverNFT, but an approved loss can close with vaultPaidAmount == 0.

  • The protocol records liabilities exceeding its remaining assets: protocol insolvency.

  • The maximum discrepancy is the amount paid to prior-period incidents during the new period, up to the entire vault balance.

This does not rely on oracle manipulation, an asset depeg, a fee-on-transfer token, a malicious privileged role, or an unfunded/unapproved first-loss buffer.

The first-loss-buffer wallet starts with 1,000 USDC and unlimited approval to IncidentManager, satisfying the documented operational assumption. The first valid 11,000 USD claim legitimately consumes that 1,000 USDC buffer plus 10,000 vault assets; the approval remains sufficient afterward. At the next commitment, the allocator correctly observes the buffer's live zero balance and attributes zero capacity to it, but still attributes 10,000 to the already-paid vault checkpoint. Only then does it settle a different cover for the next period. The root cause is therefore stale FirelightVault capacity, not first-loss-buffer custody. It is also distinct from aggregate duplicate claims because the PoC uses two different settled covers in consecutive periods and one valid incident for each.

Capacity must reflect prior-period claims that consume collateral during the current period.

At minimum:

  1. Prevent commitment/recommitment and settlement while a previous-period incident remains unresolved, or reserve its maximum payable liability when calculating capacity.

  2. After prior-period incidents are resolved, cap the period-start snapshot by the vault assets that remain exposed to a current-period incident. Conceptually, this is the lesser of:

    • the original period-start snapshot; and

    • current active assets plus still-exposed withdrawals for the current period.

For example, the vault can expose a dedicated remainingBackingForPeriod(period) view that starts from the period-start snapshot and is reduced by every prior-period payout affecting that period. Both the off-chain matcher and _validateMatchingCapacity() should use that same value.

Add an invariant test asserting that after any valid previous-period payout in period P, a new period-P commitment cannot exceed the vault assets that remain payable to a period-P incident.

https://gist.github.com/aviad12g/c4837d265dfa23eab07ef38aff4535e1

Proof of Concept

Proof of concept

The runnable PoC is:

test/research/overlappingPeriodCollateral.test.js

Run from the repository root:

Expected result:

PoC sequence

  1. A staker deposits 10,000 vault assets. The external first-loss-buffer wallet is separately funded with 1,000 USDC and grants IncidentManager unlimited approval.

  2. Cover order 0, for period 1, is settled for 11,000 USD of cover, exactly backed by the 1,000 USDC buffer plus 10,000 vault assets.

  3. While period 1 is healthy, renewal order 1 is created for period 2.

  4. A valid period-1 incident occurs and is fully assessed.

  5. Period 2 begins. totalAssetsAt(period2Start) is 10,000.

  6. The period-1 incident is approved in its valid following-period payout window. It first consumes the properly funded and approved 1,000 USDC buffer, then pays all 10,000 vault assets. It closes normally with the buffer balance and totalAssets() both equal to zero.

  7. The allocator commits and settles order 1 after the old incident is closed. It observes the live first-loss-buffer balance of zero, but the commitment for 10,000 still succeeds because _computeAvailableCapacity() reads totalAssetsAt(period2Start) == 10,000 for the already-paid vault assets.

  8. The buyer is charged and receives CoverNFT 1 with status MATCHED.

  9. A valid period-2 incident is assessed and approved for that cover. It closes with vaultPaidAmount == 0 because no vault collateral remains.

All calls use the production contracts together: FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager.

Runnable evidence

Secret Gist: https://gist.github.com/aviad12g/c4837d265dfa23eab07ef38aff4535e1

The Gist contains the exact test/research/overlappingPeriodCollateral.test.js file and the captured focused/full-suite execution log (1 passing; 455 passing).

Complete runnable test

Run:

Observed: 1 passing (979ms). Full repository suite: 455 passing (10s).

Was this helpful?