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:
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(inclusiveupperLookupRecent)contracts/core/FirelightVault.sol:741,:775,:1044-1057(checkpoint written on every withdrawal)contracts/core/FirelightVault.sol:1008(bucketing tocurrentPeriod() + 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
Link to Proof of Concept
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:
A whale (900 of the vault's 1000 tokens) and a small LP (100) are deposited. No incident exists yet.
The whale reads the next period boundary from the public
currentPeriodEnd()and submitswithdraw()so that it is mined in the block whoseblock.timestampequals that boundary, which is the start of period C._requestWithdrawassigns the request towithdrawAssets[C + 1], becausecurrentPeriod()already reports C at that timestamp.payouttreats that bucket as still exposed.The same transaction pushes a
_traceTotalAssetscheckpoint with key exactlycapturePeriodStartand the reduced value, becausetotalAssets()is net ofpendingWithdrawAssets.An incident is later captured in period C and approved for a loss of 500.
payoutcomputesassetsAtCapturePeriod = totalAssetsAt(capturePeriodStart), which now returns the post-withdrawal figure, and caps the payment at it.The cover buyer is paid 100 instead of 500.
IncidentManagercloses 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:
The vault holds 100,000 fXRP of live collateral.
A large provider calls
redeem()as the last vault operation before a period boundary. Its checkpoint becomes the one returned bytotalAssetsAt(currentPeriodStart())for the whole of the new period.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.The off-chain matcher sizes an allocation commitment against the real, live collateral and calls
commitAllocation._validateMatchingCapacitycompares it against_computeAvailableCapacity, which is still derived from the pinned snapshot, and revertsMatchingCapacityOverflow.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?