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

90127 sc high the period start collateral snapshot is still writable inside the boundary block so cover is committed against one basis while the approved claim is capped by a lower one

Submitted on Aug 23rd 2026 at 06:19:00 UTC by @tinnohofficial for Audit Comp | Firelight

  • Report ID: #90127

  • 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

Brief/Intro

CoverOrderAllocator sizes a period's cover capacity from vault.totalAssetsAt(vault.currentPeriodStart()). FirelightVault.payout caps every incident payment for that period from the same checkpoint key. The key looks immutable because it names the period start. It is not. Every vault write uses block.timestamp as the checkpoint key, and Checkpoints.push overwrites the stored value when the incoming key equals the latest key, so in the single block whose timestamp equals the period start, capacity can be committed against one value and the payout cap then moved to a lower one.

End to end, through the real allocator, cover NFT and IncidentManager: $200,000 of cover is committed, settled, paid for and receipted, then a fully approved $200,000 claim pays 0 fXRP, and the LP whose capital backed that cover claims the entire 100,000 fXRP two periods later.

Vulnerability Details

The period-start basis is writable inside the boundary block

totalAssetsAt resolves the last checkpoint at or before a timestamp (FirelightVault.sol:456-458):

Every accounting write keys that trace by the current block timestamp (FirelightVault.sol:1044-1055):

and an equal-key push replaces the stored value instead of appending (Checkpoints.sol:198-215):

The last _traceTotalAssets write in the block whose timestamp equals periodStart therefore becomes the permanent value of totalAssetsAt(periodStart). Writes later in the period carry strictly greater keys and cannot restore it.

withdraw, redeem and payout deflate that value; deposit and mint inflate it, which is harmless because paidAmount stays bounded by the assessed loss; checkpointTotalAssets can do either.

Two consumers read that same mutable key

Capacity (CoverOrderAllocator.sol:710-716):

Payout cap (FirelightVault.sol:911 and :927):

Neither consumer freezes the value it used. The design requires both to resolve from the same period basis, and inside the boundary block they do not.

The withdrawn capital is still designated payable

This is what makes the result incoherent rather than merely conservative. _requestWithdraw assigns the request to currentPeriod() + 1 (FirelightVault.sol:1008), and at the boundary block currentPeriod() has already advanced to P, so the request lands in bucket P+1. payout deliberately includes that bucket (FirelightVault.sol:916-926), with the comment stating the intent at :920: "otherwise exposed assets could escape by withdrawing first".

The assets are in the contract, are inside activePayableAmount, and are meant to be slashable for a period P incident. Only the corrupted assetsAtCapturePeriod leg of the min blocks them.

Reaching the state

commitAllocation is restricted to the current period (CoverOrderAllocator.sol:343), so it cannot execute before periodStart, and the boundary block is the first block it can occupy. It is also the natural block to occupy, because cover only earns premium for the part of the period it is live for.

The divergence needs one ordering: commit first, deflating write second, same block. An LP submits withdraw behind the public commitAllocation transaction at the same gas price. If the withdrawal lands first, both consumers read the same reduced value and there is no bug, so the ordering is a genuine precondition and is stated as one.

The proof of concept does not use a bundler contract or any helper. The allocator role and the LP send two independently signed transactions from two different accounts, automining is disabled, both are mined in one block, and the test asserts their shared block number, their relative index, and that the block timestamp equals the period start exactly.

Impact Details

Demonstrated result

100,000 fXRP of vault capital at $2, an empty first-loss buffer, $200,000 of committed and settled cover, and a fully approved $200,000 assessment:

Premium was collected and the cover NFT was minted, the loss was approved in full, and the vault paid nothing while the collateral walked out intact. That is protocol insolvency for the sold cover.

The bound, stated before the damage

assetsAtCapturePeriod is a per-call limit, not a cumulative one. The vault says so at FirelightVault.sol:885-886: "The vault applies only these per-call limits". Where the corrupted checkpoint is non-zero, a second payout call reaches the remainder, so the collateral is not lost at the vault level and I do not claim it is.

What the defect costs where the checkpoint is non-zero:

  • the approved assessment still closes short, because IncidentManager.approveCurrentAssessment calls vault.payout exactly once and then sets IncidentStatus.CLOSED (:423-434), and a closed incident cannot be re-assessed;

  • recovery requires reporting additional incidents for the same event inside the payout window, one per cap-sized slice. The count is ceil(payable / cap), measured at 11 for an LP holding 90 percent of the pool.

Where the boundary write drives the checkpoint to zero, the min collapses and no number of calls or incidents recovers anything. That is the demonstrated case. It requires the withdrawing LP to hold all active capital, which is an ordinary condition for a newly launched or single anchor LP vault, and the shortfall degrades smoothly with concentration rather than disappearing.

Who loses, and permanence

The loss lands on the cover buyer. A single payout call slashes the withdrawing LP pro rata on what is actually paid, so the LP is not advantaged relative to the others; all LPs collectively retain what the buyer was owed.

The corrupted checkpoint itself cannot be repaired. Checkpoints.push only overwrites on an equal key, so once the block timestamp has passed, no transaction can restore totalAssetsAt(periodStart). Every payout call for every incident captured in that period is capped by the corrupted value for the whole window, and no admin function rewrites a historical checkpoint.

Novelty

Both consumers of this key are Phase-2 code. The legacy vault has no payout function at all, and OpenZeppelin's July 2025 scope was limited to Checkpoints.sol, FirelightVault.sol and FirelightVaultStorage.sol, so CoverOrderAllocator has never been professionally reviewed.

Give the period an immutable collateral basis and have both consumers read it. Persist it once:

Or, if the intended definition is the state entering the period, query the last value strictly before the boundary in both places:

Every write inside the boundary block then carries a strictly greater key. Both consumers must adopt the same definition. Reassigning a boundary-block withdrawal to bucket P instead of P+1 is not sufficient on its own, because payout and checkpointTotalAssets produce the same divergence without any withdrawal.

References

  • contracts/core/FirelightVault.sol:456-458 - totalAssetsAt is an upperLookupRecent

  • contracts/core/FirelightVault.sol:1044-1055 - _logTrace keys the trace by Time.timestamp()

  • contracts/core/FirelightVault.sol:952 - payout writes the same trace at Time.timestamp()

  • contracts/core/FirelightVault.sol:523-527 - checkpointTotalAssets writes the same trace

  • contracts/core/lib/Checkpoints.sol:198-215 - equal-key push replaces the stored value

  • contracts/core/CoverOrderAllocator.sol:710-716 - capacity reads totalAssetsAt(currentPeriodStart())

  • contracts/core/CoverOrderAllocator.sol:343 - commits are restricted to the current period

  • contracts/core/FirelightVault.sol:911, :927 - payout cap reads the same key and applies it

  • contracts/core/FirelightVault.sol:916-926 - bucket capturePeriod + 1 is inside activePayableAmount

  • contracts/core/FirelightVault.sol:920 - "otherwise exposed assets could escape by withdrawing first"

  • contracts/core/FirelightVault.sol:1008 - _requestWithdraw assigns bucket currentPeriod() + 1

  • contracts/core/FirelightVault.sol:885-886 - the cap is documented as a per-call limit

  • contracts/core/IncidentManager.sol:423-434 - one vault.payout per approved assessment, then IncidentStatus.CLOSED

Proof of Concept

Proof of Concept

Step-by-step

  1. Deploy the real vault, CoverOrderAllocator, CoverNFT and IncidentManager. One LP deposits 100,000 fXRP during period 0. The price feed reports $2, and the first-loss buffer wallet is empty so the whole loss falls on the vault.

  2. The curator creates a period 1 cover order for $200,000 and the buyer approves the premium.

  3. Set the next block timestamp to exactly periodConfigurations(0).epoch + 86400, the start of period 1, and disable automining.

  4. The allocator role sends commitAllocation for period 1 against a capacity of $200,000. It validates against totalAssetsAt(period1Start), which is still 100,000 fXRP.

  5. The LP sends withdraw(100_000e6) from a different account at the same gas price. It is mined in the same block, after the commit.

  6. Mine the block and assert: both receipts share a block number, the commit index is lower than the withdrawal index, and the block timestamp equals the period start. totalAssetsAt(period1Start) is now 0.

  7. Settle the Merkle allocation. The premium is pulled and the cover NFT is minted to the buyer.

  8. Report, confirm, assess and approve a full $200,000 incident for period 1. vaultPaidAmount is 0.

  9. Advance through the withdrawal delay. The LP claims the full 100,000 fXRP bucket.

The bound is established separately: with a non-zero corrupted checkpoint a second payout call reaches the remainder, the number of calls needed scales with LP concentration, and at a zero checkpoint nothing recovers.

Run

Save the two files under ### PoC Code as test/glm_L06_strong_race.js and test/verify_l06_recovery.js, then run:

Neither file requires any additional contract or repository change.

Observed Output

Tested against v1_audit_ready (42f9ea5):

PoC Code

test/glm_L06_strong_race.js:

test/verify_l06_recovery.js:

Was this helpful?