90429 sc high a period start withdrawal can remove collateral already committed to cover causing a zero vault payout on a valid claim
Submitted on Aug 24th 2026 at 23:23:29 UTC by @dxhod for Audit Comp | Firelight
Report ID: #90429
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
I found a time-of-check/time-of-use issue between CoverOrderAllocator and FirelightVault. At the exact start of a period, the allocator can commit cover capacity using the vault collateral visible at totalAssetsAt(periodStart). A public staker can then withdraw later in the same block. That withdrawal writes the lower post-withdrawal balance at the same period-start timestamp, so later calls to totalAssetsAt(periodStart) return a different value from the one used for the commitment. The committed cover can still be settled, but a valid incident payout can be reduced to zero while the staker's withdrawal remains claimable.
Vulnerability Details
CoverOrderAllocator.commitAllocation() calculates capacity from:
vault.totalAssetsAt(vault.currentPeriodStart())
The result is consumed and stored in the allocation commitment, but the underlying period-start lookup result is not frozen.
The problematic ordering is:
Before the first transaction at the new boundary writes a vault trace,
totalAssetsAt(periodStart)resolves to the latest earlier checkpoint. In the PoC this is100.The operator performs a normal, valid
commitAllocation()in a block whose timestamp equalsperiodStart, committing capacity based on that100.A staker orders a normal public
withdraw()after the commitment in the same block.withdraw()moves the collateral into the delayed-withdrawal bucket and_logTrace()writes the lower active balance atblock.timestamp, which is exactlyperiodStart. In the PoC this makes futuretotalAssetsAt(periodStart)calls return0.The commitment is neither invalidated nor recomputed. Its Merkle allocation can still be settled and the cover NFT can still be minted.
When a legitimate loss is later approved,
FirelightVault.payout()re-readstotalAssetsAt(capturePeriodStart)instead of using the exposure already committed by the allocator.IncidentManagerrecords the actual zero vault payment and closes the incident. The withdrawal bucket is untouched, so the staker can claim the collateral after the delay.
The operator, allocator, assessor, and approver do not need to behave maliciously or incorrectly. Their transactions are the ordinary Firelight workflow. The adversarial action is the public staker's withdrawal and transaction ordering at the boundary.
Why the exact boundary matters
The PoC includes a negative control with the same withdrawal at periodStart + 1. In that case the new checkpoint is after the queried boundary, totalAssetsAt(periodStart) remains 100, the vault pays/slashes the full 100, and the withdrawal bucket is reduced to zero. Changing only one second changes the result from a full payout to a zero payout.
I also checked the live Phase 1 period schedule read-only. Four of the five most recent 86,400-second boundaries had a block timestamp exactly equal to the period start. Those exact-boundary blocks contained 32, 39, 26, and 26 transactions. I did not send any mainnet or testnet transaction.
Impact Details
This creates a real solvency shortfall: cover is sold using collateral that the payout path later treats as absent, while the withdrawing staker keeps that collateral.
In the reproducible test:
vault collateral used for the commitment:
100committed and settled cover:
100totalAssetsAt(periodStart)after the boundary withdrawal:0collateral left in the staker's delayed-withdrawal bucket:
100approved vault payout request:
100actual vault payout:
0final incident status:
CLOSEDamount later claimed by the staker:
100
The economic shortfall is bounded by the minimum of the withdrawn collateral, settled cover, and approved loss. This is not guaranteed instant arbitrage because a covered incident must later occur, but when it does, the collateral already counted to sell the cover is no longer available to satisfy the claim.
Scope and source identity
The report is against the frozen Phase 2 source at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62 (tree 6c38527edd6f8d28b4b8fce5c5cdbad331c86124). The vault, allocator, IncidentManager, checkpoint library, and V2 upgrade/storage context are all listed in scope.
Phase 2 is an intended upgrade and is not deployed, so I am not claiming deployed V2 bytecode parity. I separately validated the supplied legacy-to-V2 upgrade with OpenZeppelin's storage-layout checks and confirmed that collateral, total supply, staker shares, current period, and period configuration are preserved. The complete repository suite passes with the added tests (451 passing).
Recommended Fix
Store the vault exposure used when the allocation is committed and use that same value during payout. Alternatively, invalidate or recompute the commitment if a withdrawal changes that exposure before settlement.
References
CoverOrderAllocator.commitAllocation(): https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/CoverOrderAllocator.sol#L332-L403capacity snapshot: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/CoverOrderAllocator.sol#L696-L714
checkpoint write/lookup implementation: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/lib/Checkpoints.sol#L193-L224
FirelightVault.withdraw()/redeem(): https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L717-L775FirelightVault.payout(): https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L892-L927_logTrace(): https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1044-L1055incident close path: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/IncidentManager.sol#L403-L438
Proof of Concept
Proof of Concept
I added the following test to the frozen competition repository. It uses the actual scoped FirelightVault, CoverOrderAllocator, CoverNFT, IncidentManager, and checkpoint implementation; none of the vulnerable logic is replaced with a simplified model.
The first case executes the complete commitment -> boundary withdrawal -> settlement -> approved incident -> zero payout -> later withdrawal claim flow. The second case changes only the withdrawal timestamp to periodStart + 1 and proves that the full collateral is then paid/slashed.
Setup
Save the code below as test/sca_period_start_checkpoint_poc.js, then run:
PoC test
Expected output:
The positive case asserts all material state changes: committed capacity 100e18, future totalAssetsAt(periodStart) == 0, delayed-withdrawal bucket 100, approved vault request 100, actual vault payment 0, incident CLOSED, and later claim of 100.
The negative control changes only the timestamp to periodStart + 1. It asserts totalAssetsAt(periodStart) == 100, actual vault payment 100, and withdrawal bucket 0.
I also ran an OpenZeppelin upgradeProxy layout/state test from the repository-supplied legacy vault to the frozen V2 implementation. It preserves collateral, total supply, staker balance, current period, and period configuration. This upgrade test is supporting source-context evidence; the exploit reproduction above is self-contained.
Was this helpful?