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

89946 sc high period start withdrawal corrupts collateral accounting allowing settled cover to pay zero while the staker reclaims slashable assets

Submitted on Aug 22nd 2026 at 02:25:29 UTC by @sftwr for Audit Comp | Firelight

  • Report ID: #89946

  • 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

A permissionless staker can order a withdrawal after the allocator's commitment in a block whose timestamp exactly equals the new period start. commitAllocation() stores the pre-withdrawal 100-unit matching capacity, but the later withdrawal writes the post-withdrawal asset total at the same period-start checkpoint key. Settlement does not revalidate vault capacity and therefore mints 100 units of cover and collects the premium. During a later valid incident, payout() includes the 100 pending-withdrawal assets in activePayableAmount but independently caps payment by the corrupted totalAssetsAt(periodStart) == 0. The beneficiary receives zero, the incident closes, and the staker later claims all 100 assets.

Impact

A staker can remove up to all of the vault collateral that was just committed to a cover period from the payout cap without removing it from the already-committed matching capacity. After normal settlement:

  • the buyer pays the premium;

  • the Cover NFT records the full allocation;

  • the withdrawal assets remain inside the vault and are nominally included in activePayableAmount;

  • a valid payout is nevertheless capped by the corrupted assetsAtCapturePeriod;

  • after the withdrawal window, the staker claims the assets that should have been slashed.

In the PoC, 100 assets support and settle 100 units of cover. The exploit makes the valid 100-asset payout return zero and lets the staker later claim all 100 assets. The strongest same-state control changes only the withdrawal timestamp from the boundary second to one second later; it pays the full 100 and leaves the staker with zero.

This is not merely a historical-view mismatch. The corrupted historical value crosses into both committed cover capacity and executable payout accounting, causing settled liabilities to exceed assets that the payout path is willing to slash.

Root cause

Three individually reasonable mechanisms compose unsafely:

  1. commitAllocation validates capacity from vault.totalAssetsAt(vault.currentPeriodStart()) and freezes the caller's matchingCapacity in the commitment (CoverOrderAllocator.sol, capacity read, commit storage).

  2. Every withdrawal writes the post-withdrawal asset total under Time.timestamp() (FirelightVault.sol, trace write). A transaction in a block whose timestamp equals the boundary therefore writes directly to the period-start key and rewrites the historical view used for that period.

  3. Settlement trusts the stored commitment and does not revalidate vault capacity (CoverOrderAllocator.sol), while payout independently rereads the now-lower period-start value as an exposure cap (FirelightVault.sol).

The generic checkpoint library also replaces a checkpoint when its timestamp key equals the latest key (Checkpoints.sol). The exploit does not require a pre-existing boundary checkpoint, however: appending the first post-withdrawal checkpoint at the exact start timestamp is already enough for upperLookupRecent(periodStart) to return the wrong state.

Attack scenario

  1. A permissionless staker has 100 FXRP active in the vault. A buyer has a pending 100-USD cover order for the next period.

  2. In the first block of that period, the authorized allocator submits its normal commitment. Capacity validation reads 100 assets at currentPeriodStart, and the commitment stores 100.

  3. The staker backruns that public transaction in the same block and requests withdrawal of all 100 assets. Because both transactions have the period-start timestamp, _logTrace writes zero at the period-start key and makes totalAssetsAt(periodStart) equal zero. The 100 assets remain physically in the vault as a pending withdrawal.

  4. After the configured eight-hour grace, the authorized allocator normally settles the committed order. The buyer pays the premium and receives a Cover NFT allocating 100 cover.

  5. A covered incident occurs later in the period and the ordinary curator and assessment-approver path approves a 100-asset loss.

  6. activePayableAmount includes the 100 pending assets, but assetsAtCapturePeriod is zero, so paidAmount is zero. IncidentManager accepts the zero vault payment and closes the incident, leaving no normal automatic retry path (approval and closure, payout execution).

  7. At the end of the withdrawal window, the staker claims all 100 assets.

The staker must initiate the boundary withdrawal before settlement, but can wait until settlement reveals the covered market before deciding whether to trigger or participate in an external incident. If no attractive opportunity appears, their Firelight action remains an ordinary delayed withdrawal.

Same-state control and economics

Item

Shared pre-state

Exploit: withdrawal later in boundary block

Strongest control: withdrawal at start + 1 second

Active vault assets before boundary

100

100

100

Committed matching capacity

—

100

100

Settled cover / premium paid

—

100 / yes

100 / yes

totalAssetsAt(periodStart) after withdrawal

—

0

100

Valid requested payout

—

100

100

Actual payout

—

0

100

Staker's final recovered assets

—

100

0

Incremental beneficiary shortfall

—

100

0

Incremental staker wealth

—

+100

0

The staked 100 is present in both branches and is not an exploit-only setup cost. Incremental execution cost is one ordinary withdrawal transaction plus any ordering fee. If the staker is part of the group exploiting the covered market, the external exploit supplies the incident and profit while this bug preserves the entire stake that the protocol should slash. There is no donation, oracle manipulation, privileged key, or loss-making capital cycle.

The zero first-loss-buffer balance isolates the vault leg and is not counted toward the committed capacity. A funded buffer could pay its own portion, but it would not restore the vault contribution erased by the corrupted period-start snapshot.

Reachability and limitations

  • Firelight documents that matching runs at period boundaries and that withdrawals initiated during a period remain slashable for incidents in that period. See How Firelight Works and Deployments and Withdrawals.

  • Flare targets approximately 1.8-second block times (official network overview). The relevant condition is not a one-second cadence, but a block whose integer timestamp exactly equals periodStart. Read-only verification found block 67921346 at timestamp 1787319769, exactly the then-current Phase 1 vault boundary. This shows that an exact-boundary block is an ordinary chain state, not a locally fabricated timestamp.

  • The attacker's withdrawal must be the final relevant vault-asset checkpoint written at the period-start timestamp, or a later same-timestamp write must leave the snapshot below committed exposure. A subsequent deposit or reward checkpoint at the same timestamp could otherwise replace the checkpoint again.

  • The operator can cancel or recommit during the grace window if it detects the changed snapshot before any order settles. This is a manual monitoring/recovery path, not an on-chain prevention. Once any settlement starts, _replaceableCommitment rejects cancellation/recommitment, and later checkpoints cannot rewrite the historical start key.

  • The approved assessment path does not recover automatically from the zero vault payment: IncidentManager permits the zero payment and still closes the incident. Restoring accounting or upgrading the vault afterward therefore does not reopen the unpaid claim through the normal on-chain lifecycle.

  • A blocklist/rescue response can stop or redirect an identified staker's eventual claim, but it does not restore the zero payout cap or automatically compensate the already-paid cover buyer. An upgrade or manual compensation is still required after settlement.

  • The exact boundary-block backrun is the strongest dismissal and is why High is the honest fallback. It is nevertheless rationally reachable: boundaries are deterministic, allocator commitments are public transactions expected at the boundary, the proposer determines transaction ordering under Flare's default priority-gas-auction model, an exact-boundary block exists on-chain, and the attacker needs no cooperation from a privileged role.

Duplicate and scope posture

  • FirelightVault.sol, CoverOrderAllocator.sol, and the checkpoint library are explicitly in scope, and commitment correctness, capacity/solvency constraints, and payout accounting are named priority areas.

  • Phase 2 is intended for deployment as the upgrade/new-contract set; the competition explicitly covers the frozen v1_audit_ready code before deployment.

  • The July 29 Macro report does not disclose this same-timestamp commitment/checkpoint/payout composition. Its public findings cover leverage/CAR, settlement batching, zero-share deposits, concentration updates, grace bounds, reward checkpointing, and related administrative issues.

  • Duplicate Bug submissions remain valid until a fix is public. A submission fee is shown only at submission time.

Recommendation

Do not derive an immutable period-start exposure from a timestamp trace that can still accept writes at that timestamp.

Prefer one of these approaches:

  1. Pin a dedicated assetsAtPeriodStart[period] exactly once during capacity commitment and make both commitment validation and payout consume that same immutable value.

  2. Define boundary actions as intra-period activity and read the latest checkpoint strictly before periodStart (not <= periodStart) for both capacity and payout.

  3. At minimum, bind the committed Merkle root to the exact vault exposure value/hash and require payout to use that committed exposure rather than independently rereading mutable history.

Add regression tests for both transaction orders in a single boundary block and for the start + 1 second control.

Proof of Concept

Proof of Concept

Pinned baseline

  • Repository: https://github.com/immunefi-team/audit-comp-firelight

  • Branch: v1_audit_ready

  • Commit: 42f9ea5e43d88b35197b901acc2a8c24b314fa62

  • Test path: test/FirelightVault/periodStartWithdrawalCheckpoint.poc.js

  • PoC SHA-256: b55ba4b3cd7c0b052c55f42f22f10aa68568d727a8c3cdfe9984e2bdf91927e2

Steps to reproduce

  1. Check out the pinned commit.

  2. Add the test below at the stated test path.

  3. Install dependencies using the repository's ordinary setup path:

  1. Run:

Expected output

The complete upstream suite also passes with 449 passing.

The test uses the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. It executes commitment, boundary withdrawal, eight-hour grace, settlement, premium collection, Cover NFT minting, incident creation and confirmation, assessment submission and approval, payout, incident closure, and final withdrawal claim. Mocks are limited to the local premium ERC-20 and one-dollar price feed.

Test file

Was this helpful?