89530 sc high a boundary block withdrawal rewrites committed collateral and makes settled cover unpayable
Submitted on Aug 19th 2026 at 18:36:54 UTC by @Nescient for Audit Comp | Firelight
Report ID: #89530
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
An ordinary staker can back-run a legitimate allocation commitment with a full withdrawal in the exact period-boundary block, changing the historical period-start asset lookup from the amount that authorized the commitment to zero. Settlement still sells and charges for the committed cover because it never revalidates capacity, while a later incident payout reads the mutated value and pays zero. The attacker then claims all withdrawn assets after the payout window, leaving settled cover without usable vault backing and causing the in-scope Critical impact of protocol insolvency.
Vulnerability Details
CoverOrderAllocator.commitAllocation validates a current-period commitment using the vault's assets at the inclusive period-start timestamp:
// CoverOrderAllocator.sol:710-716
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()),
$.vaultAssetDecimals,
CANONICAL_DECIMALS,
Math.Rounding.Floor
);totalAssetsAt(timestamp) returns the latest total-assets checkpoint whose key is less than or equal to timestamp:
The protocol treats this lookup as a frozen starting-assets snapshot, but it remains writable during the period-start second. Every deposit or withdrawal records _traceTotalAssets under the current block.timestamp:
If period P starts at timestamp S, a withdrawal executed in a block with block.timestamp == S inserts (S, newTotalAssets). Because the lookup is inclusive, this new checkpoint immediately becomes the value returned by totalAssetsAt(S). If another transaction already wrote a checkpoint at S, Trace256.push replaces that same-key value:
The commitment is not linked to an immutable copy of the vault-asset value that authorized it. It only stores the submitted matching capacity:
Settlement later verifies the Merkle proof and enforces limits against commit.totalAvailableCapacity, but never calls _computeAvailableCapacity or re-reads the vault snapshot. It can therefore settle and mint cover after the historical backing used by the successful commitment has changed.
Payout independently reads that mutable historical value and uses it as a hard cap:
Even though the physical assets remain in the vault's pending-withdrawal bucket and are otherwise payable during the incident window, the mutated assetsAtCapturePeriod == 0 forces paidAmount to zero. IncidentManager._executePayout explicitly accepts a zero vault return, emits the payout result, and allows approveCurrentAssessment to close the incident.
Concrete attack sequence
During period
P-1, the attacker has100 FXRPstaked. Its last total-assets checkpoint is(T, 100e6), whereT < S, and an honest curator has created a100 USDcover order targeting periodP.In a block with
block.timestamp == S, the honest allocator first callscommitAllocation(P, root, 100e18, 100e18).totalAssetsAt(S)still resolves to the older(T, 100e6)checkpoint, so all capacity checks pass.Later in the same block, the attacker calls
withdraw(100e6, attacker, attacker). The withdrawal burns its shares, assigns the assets to withdrawal periodP+1, and writes(S, 0). The sametotalAssetsAt(S)query now returns zero.The honest allocator settles the valid
100e18Merkle allocation. Settlement succeeds, charges the premium, and mints the CoverNFT without revalidating vault capacity.When honest incident roles approve a real
100 USDloss for periodP, the vault is asked to pay100e6but emitsPayoutExecuted(..., requestedAmount = 100e6, paidAmount = 0, ...). After the period-Ppayout window closes, the attacker claims the intact100e6withdrawal.
The timing condition is narrow and stated explicitly: the commitment and withdrawal must execute in that order in a block whose timestamp equals the period-start second. The supplied attacker contract makes attempts safe by reverting unless both block.timestamp == currentPeriodStart() and the current-period commitment root is already stored. A failed timing or ordering attempt therefore does not move the attacker's stake into the withdrawal queue.
Flare's official network documentation states that block time is approximately 1.8 seconds and transaction ordering is determined by the proposer, with a priority gas auction as the default behavior. Multiple ordered transactions in a boundary block are therefore consistent with the production chain model. The exact-timestamp condition is checked again at every period boundary.
Runnable proof of concept
Files:
contracts/test/BoundaryBlockCheckpointAttacker.soltest/FirelightVault/boundaryBlockCheckpointMutation.js
Run:
Observed result:
The test deploys the real FirelightVault, CoverOrderAllocator, and CoverNFT; creates and funds a real cover order; stakes through the conditional attacker contract; places the honest commitment and attacker withdrawal in the required boundary block; settles the valid Merkle leaf; demonstrates the zero payout; then advances past the payout window and proves the attacker receives all 100e6 assets.
The PoC calls FirelightVault.payout through an authorized PAYOUT_ROLE signer to isolate the affected boundary. This is the same call made by IncidentManager._executePayout:
The identity of the correctly authorized caller does not change the mutable snapshot or zero-cap behavior.
Recommended fix
Persist one immutable startingAssets[P] value for each period and require both commitment validation and payout to consume that exact stored value. If the snapshot is frozen lazily, it must be frozen during the first authorized capacity read before the commitment is stored; mutations before the freeze may reduce capacity, while mutations afterward must not alter the payout cap.
Alternatively, define period-start backing as the most recent checkpoint strictly before S, and use the same strict-before lookup in both _computeAvailableCapacity and payout. This can be implemented with a dedicated upperLookupRecentStrict helper or, after safely proving S > 0, by querying S - 1. Fixing only settlement or only payout is insufficient: the value authorizing sold cover and the value limiting payment must remain identical and immutable throughout the period.
Impact Details
The selected in-scope impact is Critical — Protocol insolvency.
The PoC demonstrates the following quantified result with production-compatible six-decimal vault assets and an ordinary non-fee-on-transfer token:
Capacity accepted and stored by the commitment
100e18 canonical USD
Cover successfully settled
100e18 canonical USD
Period-start assets returned after mutation
0
Vault payment for a valid 100e6 request
0
Assets later claimed by the attacker
100e6 FXRP units
The exploit is scalable to the attacker's stake and therefore to a material share of the period's vault-backed capacity. The protocol charges the cover buyer's premium and records valid settled coverage, but the vault backing that passed the enforced commitment check cannot be used to satisfy the resulting claim. The attacker does not need to steal another staker's balance: the insolvency arises because the protocol's enforceable payout for a valid settled liability is reduced below that liability while the backing assets remain fully claimable by the attacker.
This is distinct from the disclosed assumptions that FXRP price may fall, the externally held first-loss buffer may be withdrawn, or aggregate claims are not capped. Here, real vault assets exist and successfully authorize cover under the protocol's capacity check; an unprivileged action subsequently changes the historical value consumed by the protocol's payout constraint. A valid 100 liability is left with a zero vault payout, directly matching the program's Critical protocol-insolvency impact.
References
Proof of Concept
Proof of Concept
This local Hardhat PoC uses the real FirelightVault, CoverOrderAllocator, and CoverNFT implementations. It proves that a valid commitment can be accepted against 100 assets, an unprivileged withdrawal later in the same period-boundary block changes totalAssetsAt(periodStart) to zero, the committed cover still settles, the corresponding vault payout is capped to zero, and the attacker later claims all 100 assets.
No mainnet or public-testnet contracts are used.
Steps to reproduce
Check out the in-scope repository and branch:
Create
contracts/test/BoundaryBlockCheckpointAttacker.solwith the following content:
The attacker wrapper makes the attempt safe: execution outside the exact period-start block or before the legitimate commitment simply reverts without moving the stake into the withdrawal queue.
Create
test/FirelightVault/boundaryBlockCheckpointMutation.jswith the following content:
Run the PoC:
Expected result
The passing assertions establish that:
commitAllocationaccepts and stores100e18of capacity derived from the staked100e6assets.The later withdrawal in the same block changes
totalAssetsAt(periodStart)to0while preserving the stored commitment.The valid Merkle allocation still settles and mints the cover receipt without a fresh capacity check.
A requested
100e6payout emitspaidAmount = 0.After the payout window, the attacker receives the entire
100e6withdrawal.
Was this helpful?