88572 sc high a same block boundary withdrawal can mutate committed period start backing and leave settled cover insolvent
Submitted on Aug 13th 2026 at 21:31:42 UTC by @go4ko for Audit Comp | Firelight
Report ID: #88572
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.commitAllocation validates a period's matching capacity against vault.totalAssetsAt(currentPeriodStart()). However, that historical value is not immutable during a block whose timestamp is exactly the period start. A staker can hold capital before the boundary and have an ordinary withdrawal execute immediately after the allocator's commitment in that boundary block. The withdrawal writes a total-assets checkpoint at the same timestamp used by the commitment, changing subsequent totalAssetsAt(periodStart) lookups even though the commitment has already succeeded.
Settlement trusts the capacity stored in the commitment and does not revalidate the vault snapshot. The order can therefore settle and its premium can be collected against the original capacity, while FirelightVault.payout later rereads the changed period-start snapshot and caps the corresponding vault payout at zero. The withdrawing staker can claim all of the collateral after the payout window.
The fully end-to-end PoC demonstrates a minimal 1:1 case with no First-Loss Buffer contribution: 100 assets support 100 USD of committed and settled cover, the period-start snapshot is changed from 100 to zero after commitment, the real IncidentManager validates and approves a legitimate 100 USD assessment against that settled cover, the resulting 100-asset vault payout transfers zero, and the staker later claims all 100 assets.
Vulnerability Details
Capacity validation reads the vault's total-assets checkpoint at the current period's inclusive start timestamp:
commitAllocation performs this validation once and then stores the caller-supplied matching capacity:
The issue is that totalAssetsAt(periodStart) is not frozen after this read. A withdrawal records the reduced active-asset total using the executing block's timestamp:
Trace256.push accepts that boundary timestamp as a checkpoint key. It appends the boundary value when the last key is older and replaces the stored value when the last key is identical:
Because upperLookupRecent returns the latest checkpoint whose key is less than or equal to the requested timestamp, a checkpoint written later in transaction order with key periodStart changes the result of the exact historical query that already authorized the commitment:
Settlement verifies the Merkle proof and accounts against commit.totalAvailableCapacity and commit.totalDeclaredAllocated; it does not recompute or bind the cover to the now-current value of totalAssetsAt(periodStart). As a result, the affected order still settles, the premium is transferred to the premium collector, and the Cover NFT is minted.
The normal assessment path accepts the settled order. IncidentManager.addAssessmentLosses reads its period and allocation from the real allocator, requires the loss not to exceed the allocation, and requires the order and incident periods to match:
After the assessment is submitted, approveCurrentAssessment executes the actual payout waterfall through _executePayout, which calls the vault:
When an approved incident is paid, FirelightVault.payout independently rereads the mutable period-start lookup and uses it as a hard cap:
Although the attacker's 100 assets remain physically inside the vault as an exposed pending withdrawal and are included in activePayableAmount, the changed assetsAtCapturePeriod == 0 value forces paidAmount to zero. Because payout processing is allowed to continue when paidAmount == 0, none of the pending withdrawal is consumed. The staker can claim it after the withdrawal delay.
The state transition is:
Stage
totalAssetsAt(S)
Commitment / obligation
Attacker's assets
Before period boundary S
100
None
100 active
Commitment executes first at timestamp S
100
100 cover committed
100 active
Withdrawal executes later in the same block
0
100 cover remains committed
100 pending
Order settles
0
100 cover active; premium collected
100 pending
Real incident assessment is approved
0
100 claim; 0 paid by vault
100 pending
Withdrawal becomes claimable
0
Claim remains underpaid
100 claimed by attacker
Attack Sequence
The attacker deposits or otherwise acquires vault shares before period
Pbegins. The attacker does not need to deposit at the boundary.A cover order requesting 100 USD of cover is pending for period
P.A Flare block is produced with timestamp exactly equal to period
P's start,S.The allocator's ordinary
commitAllocationtransaction executes first._computeAvailableCapacityobserves the attacker's 100 active assets throughtotalAssetsAt(S), so a 100 USD matching capacity and declared aggregate allocation are accepted.The attacker's ordinary
withdraw(100, attacker, attacker)transaction executes later in the same block. Because all transactions in the block observe timestampS,_logTracerecords zero active assets at keySand the withdrawal is placed into the delayed-withdrawal bucket.totalAssetsAt(S)now returns zero, but the existing commitment still records 100 USD of available capacity and declared allocation.After the settlement grace period, the allocator follows the normal protocol flow and settles the order. Settlement collects the premium and mints cover for 100 USD without revalidating the vault backing.
The normal incident workflow creates and confirms an incident in period
P, adds a 100 USD assessment loss against the settled Cover NFT and its market, and submits the assessment. The real allocator confirms that the cover is settled for periodPwith a 100 USD allocation.The assessment approver calls
approveCurrentAssessment.IncidentManager._executePayoutconverts the approved loss into a 100-asset vault request and callsFirelightVault.payout.payoutrereadstotalAssetsAt(S) == 0, transfers zero, and leaves all 100 pending-withdrawal assets untouched.IncidentManagerrecordsvaultRequestedAmount = 100,vaultPaidAmount = 0, and closes the valid incident normally.Once the payout window and withdrawal delay expire, the attacker calls
claimWithdrawand receives all 100 assets.
The allocator, settlement operator, assessor, and payout role do not need to behave maliciously. They perform their documented functions with valid inputs. The only adversarial action is the staker's permissionless withdrawal and its ordering after the commitment.
Why the ordering is feasible
The vulnerable Phase 2 system is intended for Flare Mainnet. Flare is EVM-compatible, has approximately 1.8-second blocks, and lets the block proposer determine transaction ordering under a default priority-gas auction. Consequently, two transactions in one block can execute in the required commit-then-withdraw order while observing the same block.timestamp. Flare network documentation
The timing condition is narrow and should be stated precisely: the commitment must land in a block whose timestamp equals the exact period boundary, and the withdrawal must execute after it. This is not guaranteed for every boundary or for every ordinary RPC submission, but neither the contracts nor Flare's ordering rules prohibit this execution order, and it does not require compromising any Firelight role.
This path also contradicts the protocol documentation. Firelight states that period settlement is “deterministic with respect to the period start timestamp” and explains that deployments and withdrawals submitted after a period begins only affect the next period's capacity calculation. Here, a withdrawal after the commitment changes both the already-validated current-period backing and the later payout cap. Firelight: Periods and Coverage Cycles
Known-Issue Distinction
This is distinct from the disclosed First-Loss Buffer trust assumption. The attacker does not control or drain the external First-Loss Buffer wallet; the PoC uses no First-Loss Buffer balance and removes ordinary vault capital through the vault's intended delayed-withdrawal path.
It is also distinct from the disclosed absence of an aggregate on-chain claims cap. The fully end-to-end PoC uses one settled cover and one correctly sized assessment loss. The real IncidentManager validates the loss against the real allocator's settled market allocation before the ordinary approver executes it. No assessor approves duplicate or excessive claims, and no privileged participant makes an incorrect business decision.
The supplied Macro audit discusses checkpointing direct reward transfers and separately discusses deterministic off-chain matching, but it does not identify a boundary-timestamp vault checkpoint changing after capacity commitment. The same mechanism was not found in the program's supplied known issues or previous public Firelight reports.
Impact Details
The appropriate in-scope impact is Critical - Protocol insolvency.
The protocol sells active cover and collects a premium based on capital that the payout path subsequently treats as nonexistent. An attacker can move their otherwise slashable active capital into the delayed-withdrawal bucket after it has been counted for current-period allocation, prevent that capital from being consumed by a valid claim through the zero historical cap, and recover it in full after the payout window. The cover holder or payout beneficiary is left with an unpaid covered loss.
The demonstrated loss is complete: 100 assets support 100 USD of settled cover, but a valid 100-asset payout transfers zero while the attacker recovers all 100 assets. More generally, the amount of vault backing removable through this execution path is limited by the attacker's vault position and the cover allocated against that position. Leverage can make the resulting outstanding cover obligation larger than the escaped vault principal.
This is a solvency failure => the mutated lookup is used by the state-changing payout path to determine how much collateral can be transferred, and settlement has already created the corresponding cover obligation.
Recommended Remediation
Freeze an immutable total-assets snapshot for each period before any transaction at the new period timestamp can mutate active assets. Both capacity commitment and incident payout must consume the same frozen value.
An alternative is to define period-start backing as the last checkpoint strictly before the period-start second and use that same strict-before rule everywhere. For example, both _computeAvailableCapacity and payout could query the snapshot corresponding to periodStart - 1, with explicit handling for the initial epoch. The important invariant is:
Add regression tests covering deposits, withdrawals, transfers, reward checkpoints, and payouts in the exact boundary block, including multiple writes sharing the boundary timestamp.
References
Proof of Concept
Proof of Concept
Please create test/poc_issue1_same_second_checkpoint.js with the following contents. This is a fully end-to-end protocol PoC: it deploys the real upgradeable FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Mocks are limited to external token and oracle dependencies.
The PoC creates and settles real cover, executes the boundary-block back-run, then performs the complete incident lifecycle: createIncident → confirmIncident → addAssessmentLosses → submitCurrentAssessment → approveCurrentAssessment → IncidentManager._executePayout → FirelightVault.payout. It never calls FirelightVault.payout directly. Finally, it advances beyond the payout window and proves that the attacker claims all of the collateral that backed the settled cover.
Run:
Code:
Expected result:
Was this helpful?