For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

  1. During period P-1, the attacker has 100 FXRP staked. Its last total-assets checkpoint is (T, 100e6), where T < S, and an honest curator has created a 100 USD cover order targeting period P.

  2. In a block with block.timestamp == S, the honest allocator first calls commitAllocation(P, root, 100e18, 100e18). totalAssetsAt(S) still resolves to the older (T, 100e6) checkpoint, so all capacity checks pass.

  3. Later in the same block, the attacker calls withdraw(100e6, attacker, attacker). The withdrawal burns its shares, assigns the assets to withdrawal period P+1, and writes (S, 0). The same totalAssetsAt(S) query now returns zero.

  4. The honest allocator settles the valid 100e18 Merkle allocation. Settlement succeeds, charges the premium, and mints the CoverNFT without revalidating vault capacity.

  5. When honest incident roles approve a real 100 USD loss for period P, the vault is asked to pay 100e6 but emits PayoutExecuted(..., requestedAmount = 100e6, paidAmount = 0, ...). After the period-P payout window closes, the attacker claims the intact 100e6 withdrawal.

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.sol

  • test/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:

State
Value after exploit

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

  1. Check out the in-scope repository and branch:

  1. Create contracts/test/BoundaryBlockCheckpointAttacker.sol with 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.

  1. Create test/FirelightVault/boundaryBlockCheckpointMutation.js with the following content:

  1. Run the PoC:

Expected result

The passing assertions establish that:

  1. commitAllocation accepts and stores 100e18 of capacity derived from the staked 100e6 assets.

  2. The later withdrawal in the same block changes totalAssetsAt(periodStart) to 0 while preserving the stored commitment.

  3. The valid Merkle allocation still settles and mints the cover receipt without a fresh capacity check.

  4. A requested 100e6 payout emits paidAmount = 0.

  5. After the payout window, the attacker receives the entire 100e6 withdrawal.

Was this helpful?