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

88287 sc high incorrect period boundary checkpoint accounting in firelightvault withdraw lets stakers evade incident slashing and causes protocol insolvency

Submitted on Aug 12th 2026 at 15:48:23 UTC by @ngosytuan for Audit Comp | Firelight

  • Report ID: #88287

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

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

    • Protocol insolvency

Description

Brief/Intro

FirelightVault uses totalAssetsAt(periodStart) both to determine cover capacity and to cap incident payouts. A withdrawal executed at block.timestamp == periodStart is already classified as a withdrawal made during the new period and remains in withdrawAssets[newPeriod + 1], which payout() expressly includes as payable for that period. However, withdraw() also writes the post-withdraw active balance at exactly periodStart, and totalAssetsAt() performs an inclusive <= checkpoint lookup. The result is contradictory accounting: the assets remain physically held and payable, but the exposure cap becomes zero. The complete PoC in Section 3 uses the real FirelightVault, CoverOrderAllocator, and IncidentManager; an exact valid $200 cover loss pays $100 from a fully funded/approved First-Loss Buffer and $0 from a vault still holding 1,000 FXRP, after which the staker claims all 1,000 FXRP.

Selected in-scope impact: Protocol insolvency Affected in-scope contracts: FirelightVault.sol, CoverOrderAllocator.sol, IncidentManager.sol, and Checkpoints.sol Tested branch/commit: v1_audit_ready / 42f9ea5e43d88b35197b901acc2a8c24b314fa62

Vulnerability Details

Root cause

Firelight's withdrawal accounting and payout accounting agree that a withdrawal initiated during capture period P remains available for a payout for P.

At timestamp equality, currentPeriod() has already advanced. _requestWithdraw() therefore assigns the request to currentPeriod() + 1:

For example, a withdrawal at the first timestamp of period 1 is stored in withdrawAssets[2]. The underlying tokens are not transferred to the staker at this point; they remain held by the vault throughout the cooldown.

The same withdraw() call removes the amount from the active-assets checkpoint:

_logTrace() stores the reduced amount under the current block timestamp:

totalAssetsAt() delegates to an inclusive checkpoint lookup:

Consequently, if the withdrawal block timestamp equals periodStart, the newly written post-withdraw state is included in totalAssetsAt(periodStart). This retroactively changes what should represent the pre-boundary exposure snapshot.

The allocator consumes that value when computing the current period's capacity:

The PoC does not make the matcher submit stale or excessive capacity. After the boundary withdrawal, the honest on-chain capacity consists only of the fully funded $100 FLB multiplied by the configured 2x leverage, so the matcher submits exactly $200 and settles exactly $200 of cover.

When a valid $200 assessment is approved, IncidentManager first transfers the full $100 FLB balance and then requests the remaining $100 from the vault:

FirelightVault.payout() correctly finds the 1,000 FXRP in the capture-period withdrawal bucket, but then caps the transfer by the corrupted zero-valued start checkpoint:

In the exploit transaction sequence:

The incident is processed with vaultPaidAmount == 0, and the withdrawing staker later claims the complete 1,000 FXRP.

Exploit versus negative control

The PoC executes the same protocol flow twice. The only relevant difference is whether the unprivileged withdrawal is mined at periodStart or at periodStart + 1:

Observed state/result

Withdrawal at periodStart

Withdrawal at periodStart + 1

FXRP physically held after withdrawal

1,000

1,000

withdrawAssets[2] before payout

1,000

1,000

totalAssetsAt(periodStart)

0

1,000

Honest settled cover

$200

$200

Funded/approved FLB paid

$100

$100

Vault remainder requested

100 FXRP

100 FXRP

Vault remainder paid

0 FXRP

100 FXRP

Staker claims after cooldown

1,000 FXRP

900 FXRP

This isolates the bug to the inclusive period-start checkpoint. The real payout path charges the pending withdrawal bucket in the +1 control, as intended.

Why the disclosed MVP assumptions do not cause this result

  • No malicious or incorrect role action: the matcher commits the exact $200 capacity reported after the withdrawal, the order settles for exactly $200, and the incident assessment contains one loss equal to that settled cover.

  • No external FLB underfunding or allowance issue: the FLB wallet holds exactly $100, approves exactly $100, and the complete $100 transfer succeeds before the vault leg.

  • No duplicate or excessive aggregate claim: there is one NFT-backed cover allocation and one assessment loss equal to its allocation.

  • No stablecoin depeg or collateral drawdown: both local tokens are valued at $1 for the complete test.

  • No beneficiary-distribution assumption: the PoC measures the balances received by the configured payout receiver directly on-chain.

  • No unauthorized use of a privileged function: the only adversarial action is the public withdraw() call. All permissioned calls execute their documented normal workflow and stay within the values enforced by the contracts.

The protocol-constraint bypass is that assets explicitly classified by payout() as current-period payable assets are excluded by the other half of the same payout formula solely because the withdrawal occurred at timestamp equality.

Expected behavior

Once currentPeriod() has advanced, a withdrawal initiated in that period must not alter the immutable starting exposure used for that same period. Because the assets are stored in withdrawAssets[capturePeriod + 1], a valid payout for the capture period must be able to charge them.

Actual behavior

At exact timestamp equality, the withdrawal is simultaneously classified as a current-period payable withdrawal and admitted into the inclusive period-start checkpoint. The checkpoint sets the payout cap to zero, so the still-held payable assets cannot be charged.

Use an immutable pre-boundary exposure snapshot consistently in both CoverOrderAllocator and FirelightVault.payout(). Two suitable approaches are:

  1. Add a strict checkpoint lookup that returns the latest value with checkpoint.timestamp < periodStart; or

  2. Store a dedicated exposure snapshot once per period transition and never allow later transactions at the same timestamp to mutate it.

With the current second-based timestamps, querying the state at periodStart - 1 is also a direct fix if all period configurations guarantee a nonzero start. Add regression tests for deposit, withdrawal, reward distribution, matching, and payout at periodStart - 1, exactly periodStart, and periodStart + 1.

Changing only the equal-key replacement branch in Trace256.push() is insufficient. Even where there was no prior checkpoint at the boundary key, inserting a new checkpoint at periodStart still affects the inclusive lookup.

Impact Details

The demonstrated impact is Critical: protocol insolvency.

  • A valid $200 settled cover obligation closes after paying only $100. The configured payout receiver suffers an on-chain $100 shortfall.

  • The vault holds 1,000 FXRP throughout the payout, and its own accounting includes all 1,000 FXRP in the capture-period payable bucket. The failure is not a lack of funds; the corrupted exposure cap prevents those funds from being used.

  • The unprivileged staker retains the exact economic amount denied to the claimant: the boundary case lets the staker claim 1,000 FXRP, whereas the one-second-later control correctly pays 100 FXRP to the receiver and lets the staker claim only 900 FXRP.

  • At a 2x configured capacity ratio, cover backed by a funded FLB can leave a vault remainder equal to the FLB amount. The same accounting error can suppress that complete vault leg when the active-assets snapshot is reduced to zero.

  • The condition is timing-sensitive: the withdrawal transaction must be included in a block whose EVM timestamp equals a deterministic period boundary. It does not require ordering before a commitment. The honest commitment is created after the withdrawal and uses the exact capacity then reported on-chain.

The PoC uses small numbers only for clarity. The shortfall scales with the legitimate vault remainder of settled cover and can leave valid cover obligations partly unpaid while collateral remains held by the protocol.

References

Proof of Concept

The complete runnable end-to-end Hardhat PoC is included in this report. It deploys and calls the real in-scope FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Local mocks are used only for the ERC-20 assets and the fixed $1 external price feed; no vulnerable behavior is implemented in a mock. No separate attachment is required.

Prerequisites

  • Node.js and npm

  • Dependencies from the audit repository's package-lock.json

  • No private keys or deployed contract addresses

  • For the optional fork run, a read-only Flare RPC endpoint

Complete PoC source

Save the following code as test/FirelightVault/PocVault_PeriodStartPayoutBypass.js in the frozen audit repository.

Reproduction steps

  1. Clone the in-scope repository and check out the frozen audit commit:

Expected commit:

  1. Create test/FirelightVault/PocVault_PeriodStartPayoutBypass.js and paste the complete PoC source included above without modification.

  2. Install the locked dependencies:

  1. Run the PoC on the local Hardhat network:

  1. The exploit test performs these actions:

    1. Deploys the real vault with one-day periods and deposits 1,000 FXRP from an ordinary staker.

    2. Funds the external FLB wallet with $100 and configures the supported effectiveLeverage / minCAR ratio to 2x.

    3. Creates one real $200 cover order.

    4. Advances to the exact first timestamp of period 1.

    5. Calls public withdraw(1,000 FXRP, staker, staker) from the unprivileged staker before any matching commitment exists.

    6. Verifies that the vault still physically holds 1,000 FXRP and records all of it in withdrawAssets[2], but totalAssetsAt(periodStart) == 0.

    7. Reads the resulting honest capacity: $100 funded FLB multiplied by 2x equals $200. The allocator commits exactly $200 and settles exactly $200.

    8. Funds and approves the FLB for the complete $100 first-loss leg.

    9. Creates one incident, submits one assessment loss equal to the settled $200 allocation, and approves it through the real IncidentManager.

    10. Verifies that the FLB transfers $100, the manager requests the remaining 100 FXRP from the vault, and the vault pays zero.

    11. Advances through the normal cooldown and verifies that the staker claims all 1,000 FXRP.

    12. Repeats the same flow with the withdrawal at periodStart + 1; verifies that the FLB pays $100, the vault pays 100 FXRP, and the staker claims only 900 FXRP.

Observed local output

Local Flare-fork verification

The same test was also run against a local Hardhat fork pinned to Flare block 67,242,970. This does not broadcast any transaction to Flare or to a public testnet.

The public RPC can be replaced with any archival/read-capable Flare RPC:

Observed fork result:

Full-suite verification

The complete repository test suite passes with the PoC present:

No production contract was modified. No transaction was sent to mainnet or a public testnet.

Was this helpful?