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

89428 sc high inclusive period start snapshot in firelightvault lets any liquidity provider cap incident payouts to near zero and destroy 80 of a period s underwriting capacity

Submitted on Aug 19th 2026 at 09:21:41 UTC by @emilh for Audit Comp | Firelight

  • Report ID: #89428

  • 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

FirelightVault.totalAssetsAt(periodStart) resolves through Checkpoints.upperLookupRecent, which returns the last checkpoint whose key is lower or equal to the search key. A withdrawal mined in the boundary block therefore writes its own checkpoint at exactly periodStart and is included in that period's own snapshot, even though the same withdrawal is bucketed as belonging to the period. Two independent consumers read that value: FirelightVault.payout uses it as the ceiling on incident payouts, and CoverOrderAllocator._computeAvailableCapacity uses it as the ceiling on cover sales. A liquidity provider with no privileged role can therefore either collapse the payout ceiling, leaving an approved cover claim permanently unpayable while its own capital stays intact, or collapse the underwriting ceiling, preventing the protocol from selling any cover for a full period while the collateral backing it sits untouched in the vault.

Vulnerability Details

Root cause: the snapshot lookup includes the boundary block.

// contracts/core/FirelightVault.sol:456-458
function totalAssetsAt(uint48 timestamp) public view returns (uint256) {
    return _traceTotalAssets.upperLookupRecent(timestamp);
}

A checkpoint written at key == periodStart is returned by totalAssetsAt(periodStart).

Every withdrawal writes such a checkpoint. withdraw and redeem both push the post-withdrawal figure at block.timestamp:

totalAssets() is super.totalAssets() - pendingWithdrawAssets (:427-429), and _requestWithdraw adds the requested amount to pendingWithdrawAssets, so the pushed value is already reduced by the withdrawal.

But the bucketing places the same withdrawal inside the period. _requestWithdraw assigns it to currentPeriod() + 1:

and periodAtTimestamp uses floor division, so at block.timestamp == periodStart of period C the vault already reports currentPeriod() == C:

The withdrawal therefore lands in withdrawAssets[C + 1], which payout explicitly treats as still exposed.

Consumer 1: the incident payout ceiling.

The assets remain formally inside the payable set, while the ceiling that governs how much of that set can be drawn is computed as if they had already left.

This is not the intended trade-off. The contract's own comment states the opposite intent:

and at :918-920, that withdrawals during the following period stay payable "otherwise exposed assets could escape by withdrawing first". The boundary block defeats exactly that protection.

Consumer 2: the period's underwriting ceiling. The same snapshot gates cover sales:

Because the lookup is inclusive, the value governing an entire period is simply whatever the last vault operation at or before the boundary left behind, and it is immutable for the rest of the period: any later deposit writes a checkpoint with a strictly greater key and cannot raise it. A provider whose redeem is the last vault operation before a boundary fixes that period's capacity at the post-redeem figure, and re-depositing in the next block restores the live collateral without restoring the snapshot.

Note that this second path does not require any attacker intent. Any sufficiently large redeem that happens to be the last vault operation before a period boundary silently destroys that period's capacity, so the defect also degrades the protocol under ordinary use.

Impact Details

Path 1: an approved cover claim becomes permanently unpayable while the exposed capital escapes.

Measured on the repository's own fixtures, with a vault of 1000 tokens (whale 900, small LP 100) and an approved incident loss of 500:

Honest exit (boundary + 1 second)
Boundary block

Paid to the cover buyer

500

100

Whale's pending bucket remaining

450

810

The buyer is short 400 of the 500 it was owed, and the whale avoids 360 of loss it should have absorbed. The only difference between the two runs is a one second shift in when the withdrawal is mined.

The shortfall is unrecoverable. IncidentManager.approveCurrentAssessment closes the incident with whatever the vault returns and does not retry:

_validateIncidentPeriod then blocks any new incident for that period once the payout window closes, so the difference cannot be claimed later.

Path 2: the cover market cannot open for an entire period.

Measured: live totalAssets 100,000 fXRP before the boundary, snapshot pinned at 20,000 after the boundary redeem, live value restored to 100,000 when the provider re-deposits, snapshot still 20,000. On-chain maximum capacity is 40,000e18 canonical against a live capacity of 200,000e18, so 80% of the period's underwriting capacity is destroyed while the collateral is fully present, and commitAllocation sized against the real collateral reverts MatchingCapacityOverflow for the whole period (one week at the configured duration). Matched orders for that period expire PENDING.

Preconditions. The attacker holds a material share of the vault; the reduction in the ceiling equals the amount withdrawn. Period boundaries are public and deterministic (periodConfigurations is a public array, currentPeriodEnd() is a public view), and no incident needs to exist yet, so the position can be taken pre-emptively at any boundary and retried at the next one. Path 2 does not even require landing in one specific block: being the last vault operation before the boundary is sufficient. No privileged role is involved in either path; the trigger is the permissionless withdraw / redeem.

Recommended fix. Read the snapshot as of the last instant before the period opens, at both call sites, so that no operation belonging to period C can influence period C's own snapshot:

A dedicated test (C01_fix_check.js, included in the PoC) confirms this closes the hole without regressing honest pre-period exits: a withdrawal requested at capturePeriodStart - 1 is bucketed into withdrawAssets[C], which payout never draws from, and is correspondingly absent from the capturePeriodStart - 1 snapshot, so inclusion in the payable set and inclusion in the exposure ceiling coincide for every timestamp.

References

  • contracts/core/FirelightVault.sol:456-458 (totalAssetsAt)

  • contracts/core/lib/Checkpoints.sol:221-224 (inclusive upperLookupRecent)

  • contracts/core/FirelightVault.sol:741, :775, :1044-1057 (checkpoint written on every withdrawal)

  • contracts/core/FirelightVault.sol:1008 (bucketing to currentPeriod() + 1)

  • contracts/core/FirelightVault.sol:316-320 (floor division places the boundary inside the period)

  • contracts/core/FirelightVault.sol:911, :916, :927, :914-920 (payout ceiling and the stated intent)

  • contracts/core/CoverOrderAllocator.sol:711, :400-401 (underwriting ceiling)

  • contracts/core/IncidentManager.sol:432-434 (incident closes without retry)

  • CWE-193: Off-by-one Error

https://gist.github.com/emilhuseyn/a7bf97c27b86dfdd3cdc2205bb6ad7cb

Proof of Concept

Proof of Concept

All tests run against the competition branch with no modification to any contract, using the repository's own fixtures.

Path 1: collapsing the incident payout ceiling

Step by step:

  1. A whale (900 of the vault's 1000 tokens) and a small LP (100) are deposited. No incident exists yet.

  2. The whale reads the next period boundary from the public currentPeriodEnd() and submits withdraw() so that it is mined in the block whose block.timestamp equals that boundary, which is the start of period C.

  3. _requestWithdraw assigns the request to withdrawAssets[C + 1], because currentPeriod() already reports C at that timestamp. payout treats that bucket as still exposed.

  4. The same transaction pushes a _traceTotalAssets checkpoint with key exactly capturePeriodStart and the reduced value, because totalAssets() is net of pendingWithdrawAssets.

  5. An incident is later captured in period C and approved for a loss of 500.

  6. payout computes assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart), which now returns the post-withdrawal figure, and caps the payment at it.

  7. The cover buyer is paid 100 instead of 500. IncidentManager closes the incident and the remaining 400 can never be claimed. The whale's pending bucket keeps 810 instead of 450.

Test output:

The baseline case performs the identical withdrawal one second after the boundary and the buyer is paid in full, isolating the boundary block as the sole cause.

Path 2: destroying the period's underwriting capacity

Step by step:

  1. The vault holds 100,000 fXRP of live collateral.

  2. A large provider calls redeem() as the last vault operation before a period boundary. Its checkpoint becomes the one returned by totalAssetsAt(currentPeriodStart()) for the whole of the new period.

  3. In the next block, already inside the new period, the provider deposits the capital back. Live totalAssets() returns to 100,000, but the snapshot cannot be raised, because the new checkpoint has a strictly greater key.

  4. The off-chain matcher sizes an allocation commitment against the real, live collateral and calls commitAllocation.

  5. _validateMatchingCapacity compares it against _computeAvailableCapacity, which is still derived from the pinned snapshot, and reverts MatchingCapacityOverflow.

  6. No cover order can be committed or settled for that period. Matched orders expire PENDING.

Test output:

Fix validation

C01_fix_check.js applies the proposed capturePeriodStart - 1 read and asserts three properties: the boundary block withdrawal no longer moves the ceiling, an honest exit made before the period opens is still excluded from it, and that same exit is bucketed outside the payable set, so the two mechanisms agree at every timestamp.

The full test sources are in the linked secret Gist: C01_boundary_block_snapshot.js, C01_fix_check.js, S5_capacity_boundary_dos.js, and the harness S3_seam_harness.js that the third test imports.

Was this helpful?