89908 sc high mutable period start exposure snapshot in firelightvault payout leads to protocol insolvency approved incident claims underpaid
Submitted on Aug 21st 2026 at 18:45:12 UTC by @chaincasper for Audit Comp | Firelight
Report ID: #89908
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
Intro
FirelightVault.payout() caps an incident payout at totalAssetsAt(capturePeriodStart), which is meant to be the vault's fixed exposure at the start of the covered period. That value is not immutable: a staker who withdraws in the block whose timestamp equals the period start S writes a lower total at key S and drops the cap by the withdrawn amount, while the allocator has already committed cover capacity against the higher pre-withdrawal exposure and cover later settles against that commitment without re-checking exposure. Once the in-scope V2/Phase-2 topology is deployed, an ordinary permissionless staker can use this to escape the slash that should fund an approved incident claim. The claim is then paid short from the vault leg by the amount withdrawn, and the incident still closes with no on-chain retry, so Firelight cannot honor a capacity-backed obligation. The shortfall equals the attacker's boundary withdrawal, so it is bounded by the stake the attacker controls. That bound is not a comfort: the exploit spares that withdrawal from the slash it would otherwise take (entirely at x = A, when the attacker controls all active exposure), so the attacker preserves its own capital and the damage scales with the stake it holds or coordinates, up to the whole vault (about 57M fXRP, roughly $80M at current XRP prices).
Vulnerability Details
FirelightVault.payout() caps an incident payout at totalAssetsAt(capturePeriodStart) and treats that value as the fixed period-start exposure, but it is not immutable. A withdrawal executed in a block whose timestamp equals the period start appends or replaces the period-start checkpoint, changing the inclusive totalAssetsAt(S) result downward, so collateral that backed sold cover escapes the payout.
This breaks invariants the protocol documents as intended. Firelight's staker documentation states that a pending withdrawal takes the same pro-rata slash as an active position and that no vault position is exempt from that slash ("Deployments and withdrawals", "Risk and exposure"); capital should not be able to exit ahead of the obligations it backed. The claims documentation states that an approved loss within payout capacity pays in full ("Claims liquidation"). The defect below violates both: capture-period withdrawals are kept nominally slashable, yet the mutable snapshot lets the withdrawn capital escape the vault leg while an approved, within-capacity claim is short-paid.
payout() caps the amount by the active assets recorded at the start of the covered period, and separately keeps capture-period withdrawals payable so exposed assets cannot escape a slash:
The design intends assetsAtCapturePeriod to be the full period-start exposure A. A withdrawal of x during the period moves x out of active assets into withdrawAssets[capturePeriod + 1], so currentActiveAssets falls to A - x while capturePeriodWithdrawals rises to x. In the clean single-withdrawal case the PoC demonstrates, this leaves activePayableAmount at A, so the payout can still draw the full A and slash the withdrawal proportionally.
totalAssetsAt() is an inclusive lookup over a timestamp-keyed trace:
Every withdraw() and redeem() calls _logTrace(), which pushes the reduced total at the current block timestamp:
Checkpoints.push() overwrites the stored value when the key already exists, and appends a new entry otherwise:
When a staker withdraws x in a transaction whose block.timestamp equals currentPeriodStart(), _logTrace() pushes A - x at key S = currentPeriodStart(), overwriting or appending at the period-start checkpoint. From then on totalAssetsAt(S) returns A - x instead of A. This same snapshot also feeds the allocator: CoverOrderAllocator._computeAvailableCapacity() reads vault.totalAssetsAt(vault.currentPeriodStart()), so cover is committed against A while the later payout is capped at A - x.
Settlement never rechecks the snapshot. CoverOrderAllocator._settleCoverOrder() checks the order status, the period, the grace period, the Merkle proof, the per-market allocations, the recomputed premium, and the protocol-concentration caps, but it does not recompute vault exposure. Its capacity and concentration checks rely on the stored commitment. Once one leaf settles, CoverOrderAllocator._replaceableCommitment() locks the commitment against cancellation and recommit (commit.totalSettledCover > 0).
When a valid incident whose capture timestamp maps to S is approved, IncidentManager._executePayout() calls payout(), which computes paidAmount = Math.min(amount, Math.min(A - x, activePayableAmount)). For a full-loss request the cap is A - x, so the beneficiary is short-paid by x. At the maximum x = A the cap is zero, the vault transfers nothing, and payout() still returns to the caller because it processes the incident even when paidAmount is zero, so IncidentManager closes the incident. Two periods later the staker calls claimWithdraw() and collects the withdrawal that should have been slashed, up to the full x at x = A.
Impact Details
An approved incident claim that should draw on staked collateral is short-paid from the vault-backed leg. For a period-start exposure A and a boundary withdrawal of x, the payout cap falls from A to A - x, so a full-loss claim is underpaid by x, up to the entire vault-backed leg. At the maximum x = A (the attacker controls, or coordinates withdrawals covering, all active exposure) the vault leg pays zero. The first-loss buffer still pays its own leg, so the loss is bounded by the period-start vault exposure, not the entire covered amount, and the withdrawing staker keeps the collateral that should have been slashed.
The incident closes CLOSED with no IncidentManager-level retry (a closed incident cannot be re-approved), and because the cap totalAssetsAt(S) stays at zero at x = A, even a repeat payout() call draws nothing, so the vault leg cannot be topped up. Firelight cannot honor an approved obligation that was within its contemporaneously committed capacity. This maps to the program's Critical protocol insolvency class. FirelightVault.payout() documents the period-start value as "an exposure cap, not a segregated asset bucket", so the loss is best framed as failure to honor a capacity-backed obligation rather than theft from a reserved bucket.
The PoC drives the full stack at mainnet-scale amounts: a 57,000,000 fXRP vault, a funded 5,000,000-token first-loss buffer, and 62,000,000 of cover sold for the period. The vault leg is 57,000,000. On the approved claim it pays zero, while the first-loss buffer still pays its 5,000,000, so the protocol covers only 5,000,000 of the 62,000,000 assessed loss. The approved claim is short-paid by 57,000,000 (the entire vault-backed leg), and the unprivileged staker later claims that 57,000,000 back.
Funds at risk. The shortfall per qualifying incident is bounded by the period's vault-backed exposure A. The deployed vault holds on the order of 57M fXRP (a public balance read on 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3), which the PoC reproduces as a token quantity. The PoC prices fXRP at $1 with an illustrative oracle, so its 57M vault leg is a token figure, not a USD amount. Valued at the current XRP price (about $1.41, CoinGecko 2026-08-21), that quantity is a theoretical upper bound of about $80M, reached only with enough settled cover, a genuine assessed loss, and a boundary withdrawal covering all active exposure (x = A). A single existing holder reaches only its fraction of the vault, so a lone attacker's shortfall is a proportional fraction, not the full amount. But because the attacker recovers its withdrawn stake (fully at x = A), the ceiling is set by the exposure one actor can hold or coordinate, not by any principal loss, so a large staker is incentivized to boundary-time its exits. XRP is volatile, so the USD figure is a point-in-time estimate.
Exploit Conditions
External (not controlled by the attacker):
An honest
commitAllocation(P)transaction executes at exactly timestampS = currentPeriodStart()and is ordered before the attacker's withdrawal in the same block, so it stores capacity against the pre-withdrawal exposureA. Flare's short block times mean a block at the exact boundary timestamp can occur, but the attacker still needs a block atS, a commit in it, and later same-block ordering.A genuine incident for period P is captured after the period's cover has settled, is on-chain valid, and is honestly approved. The attacker cannot manufacture this external loss.
The assessed loss exceeds the first-loss buffer, so the payout draws on the vault leg. The price feed is fresh and the FLB/receiver allowances and role wiring are operational.
Internal (attacker-controlled):
The attacker already holds vault shares from an ordinary permissionless deposit and is not paused or blocklisted.
The attacker anticipates or observes the boundary commit and obtains later same-block ordering for
withdraw()/redeem()in the boundary block, before the payout.The attacker's withdrawal is the final net-deflating write at key
S. A later same-Sdeposit or reward checkpoint would raise the lookup back up.The
x = Amaximum (vault leg pays zero) additionally requires the attacker to control all active exposure, or to coordinate enough withdrawals to drive the finalSvalue to zero.
These conditions do not invalidate the finding. The state transition is code-backed and demonstrated end to end. They are adjudication-sensitive: a genuine incident must occur, the attacker cannot create or guarantee it, exact-boundary ordering is required, and privileged roles have a detection-and-recovery window (see the mitigation note below). They materially affect likelihood.
Recommendation
Consider the following:
Make the period-start exposure immutable once it can authorize cover, so a later same-timestamp write cannot change it. The smallest additive fix rejects commits at the exact boundary timestamp, which forces the authorizing snapshot to be read only after any boundary-block writes have already executed:
Once a later timestamp is reached, monotonically increasing keys prevent any future checkpoint from overwriting or appending at S, so totalAssetsAt(S) is fixed.
A more explicit design finalizes a single periodExposure[period] value once per period and uses it in both CoverOrderAllocator._computeAvailableCapacity() and FirelightVault.payout() instead of re-reading the timestamp-keyed trace, which removes the dependence on checkpoint key collisions entirely.
Keep capture-period withdrawal buckets inside activePayableAmount; they are meant to stay slashable. Add an invariant test that commits at and after the period rollover, performs same-block deposits and withdrawals in every ordering, settles, captures a post-settlement incident with an exhausted first-loss buffer, and checks the P+2 claim path.
Proof of Concept
Attack Path
At the start of period P (timestamp
S), an honestcommitAllocation(P)transaction executes in the boundary block, readstotalAssetsAt(S) = A, and stores capacity sized againstA.In a later transaction of the same block, still at timestamp
S, the attacker callswithdraw()orredeem()forx._logTrace()pushesA - xat keyS, which appends or replaces the period-start checkpoint, sototalAssetsAt(S)now returnsA - x.The withdrawal is recorded in
withdrawAssets[P + 1], which the vault treats as still slashable, soactivePayableAmountstays atA.After the grace period the cover order settles against the stored capacity, and settlement does not re-read
totalAssetsAt(S). Once the first leaf settles, the commitment can no longer be cancelled or recommitted.A genuine incident for period P (one the attacker cannot manufacture) is captured after settlement, its timestamp maps to
S, and an honest approver approves it;IncidentManager._executePayout()callspayout().payout()readstotalAssetsAt(S) = A - xas the exposure cap, sopaidAmountis capped atA - x(zero whenx = A), and the incident closes even though the vault leg is short-paid by up to the fullx.At period P+2, after the payout window closes, the attacker calls
claimWithdraw()and receives the withdrawal that should have been slashed, the fullxatx = A.
Proof of Concept
File: test/poc/b008_period_boundary_payout_snapshot.js
The PoC is a single Hardhat test file (two cases) in the project's own style, over the repo's own test/setup/fixtures.js deployVault fixture (ethers v6, @openzeppelin/merkle-tree). It deploys fresh, real FirelightVault (the V2 upgrade implementation), CoverOrderAllocator, CoverNFT, and IncidentManager; only the fXRP asset, the premium/FLB stablecoins, and the price feed are mocked external dependencies. The attacker is an ordinary permissionless staker and every privileged role acts honestly; the test asserts the attacker holds none of the allocator, curator, approver, payout, pause, blocklist, or rescue roles. Amounts are mainnet-scale (a 57,000,000 fXRP vault) so the demonstrated shortfall is representative of the deployed vault without a fork.
Setup and cheatcode disclosure (all legitimate; none fabricate exploit proceeds). There is no setStorageAt/setCode. setBalance gas-funds the test's role signers, and (inside the repo's own deployVault/deployFAsset fixture) the impersonated mock FAsset asset-manager that mints the initial test balances of the underlying asset, the same way real FXRP is minted by its asset manager. That impersonation only mints the mocked underlying, never any attacker capability. The exact-boundary case also uses timestamp control, disabled automining, and gas-price ordering. These are deterministic simulations of the disclosed external timing and order precondition (an honest commit and the attacker's withdrawal executing in the same block at timestamp S, commit first), not extra privileges. Funds are traced from the vault's own assets through balance deltas.
Exploit (
x = A). It commits and settles 62,000,000 of cover for a period against 57,000,000 of vault assets and a funded 5,000,000-token first-loss buffer, then has the staker withdraw the full vault exposure in a transaction whose block timestamp equals the period start (commit at tx index 0, withdrawal at tx index 1 in the same block). It captures a valid incident for the period after settlement, approves it, and asserts the vault leg pays zero while the first-loss buffer contributes its 5,000,000. It then advances two periods and asserts the staker claims the full 57,000,000.Control (
S + 1). The same withdrawal one second later shows the vault leg paying the full 57,000,000 and leaving no surviving withdrawal. It asserts the commit and withdrawal block timestamps are exactlySandS + 1, isolating the exact-Swrite: it keeps the same commit-before-withdraw sequence, but the withdrawal is mined in the next block, andclaimWithdraw()then revertsNoWithdrawalAmountbecause the withdrawal was fully slashed into the payout.
Run it self-contained against the in-scope repository branch, with no fork and no external RPC:
This is a runnable, self-contained proof against the in-scope repo that demonstrates the actual on-chain outcome. A mainnet fork is not required.
Expected output (per-test millisecond timings vary between runs):
The exploit test drives totalAssetsAt(S) from 57,000,000 to 0 with the boundary withdrawal (the before value is an observed pre-block read). The funded first-loss buffer is exhausted first. The valid, approved incident then pays 0 of the 57,000,000 requested from the vault leg, and the incident still closes. Two periods later the separate, unprivileged staker calls claimWithdraw() and receives the full 57,000,000, the collateral that should have funded the claim. The control moves the withdrawal one second past the boundary, leaving totalAssetsAt(S) = 57,000,000. The vault leg then pays the full 57,000,000 and claimWithdraw() reverts NoWithdrawalAmount, isolating the exact-S write as the cause.
CoverOrderAllocator, CoverNFT, and IncidentManager are Phase-2 contracts with no mainnet deployment yet, so the full commit → withdraw → settle → incident → payout → claim path is exercised end to end against freshly deployed, in-scope contracts.
Proof of Concept Code
File: test/poc/b008_period_boundary_payout_snapshot.js
Was this helpful?