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

89865 sc high period boundary withdrawal rewrites the period start exposure checkpoint in firelightvault letting a staker evade incident slashing and permanently underpaying the incident payout

Submitted on Aug 21st 2026 at 15:28:18 UTC by @SavantChat for Audit Comp | Firelight

  • Report ID: #89865

  • 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

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief

FirelightVault.payout caps every incident payout by the vault's active assets at the start of the covered period, read through the inclusive checkpoint lookup totalAssetsAt(capturePeriodStart). withdraw/redeem, however, write their post-withdrawal total-assets checkpoint under the current block timestamp.

A withdrawal executed in a block whose block.timestamp is exactly a period start therefore lands on the period-start checkpoint key and becomes the value that payout later reads as "assets committed at the start of the covered period" — even though _requestWithdraw deliberately assigns that request to currentPeriod() + 1 so that it stays exposed to incidents captured in the new period.

The result is that a staker can shrink (or fully zero) the exposure cap of the period they are exiting from. The incident payout is capped at the reduced value, the funds stay in the withdrawal bucket, and the staker claims them in full once the payout window has closed. The shortfall is permanent: payout can only be executed during the capture period or the one after it.

Vulnerability Details

The two sides of the mismatch

1. payout reads the period-start snapshot inclusively — contracts/core/FirelightVault.sol:911:

totalAssetsAt (FirelightVault.sol:456) resolves to Checkpoints.upperLookupRecent, which returns the most recent checkpoint with key lower or equal to the search key (contracts/core/lib/Checkpoints.sol:217-224). A checkpoint written at key == capturePeriodStart is therefore included in the "period start" reading.

2. withdraw/redeem write the post-withdrawal value under the current timestamp — FirelightVault.sol:755-778:

and _logTrace (FirelightVault.sol:1044-1056):

Checkpoints.push (contracts/core/lib/Checkpoints.sol:198-214) either appends a checkpoint at ts or, when ts equals the latest key, replaces the stored value:

Both branches corrupt the snapshot: if a period-start checkpoint already exists it is overwritten with the post-withdrawal figure; if none exists, a new one is appended at exactly the period-start key and the inclusive lookup picks it up.

Why the withdrawal is supposed to stay slashable

_requestWithdraw (FirelightVault.sol:1008) assigns the request to currentPeriod() + 1:

and payout explicitly adds withdrawAssets[capturePeriod + 1] (and [capturePeriod + 2]) back into activePayableAmount, with the in-code comment "otherwise exposed assets could escape by withdrawing first". The design intent is unambiguous: assets withdrawn during a period must remain slashable for incidents captured in that period.

The exposure cap defeats exactly that intent for boundary transactions: the assets are still in activePayableAmount, but assetsAtCapturePeriod has already been reduced by the same amount, so Math.min clamps the payout to the post-withdrawal figure. paidAmount can even be 0, and payout then takes the paidAmount == 0 path — the incident is consumed as processed and the payout window advances toward expiry.

Attack path

Period starts are fully deterministic and public: _periodAndStartAt (FirelightVault.sol:1123) computes start = pc.epoch + periodsSinceEpoch * pc.duration from the on-chain PeriodConfiguration, and currentPeriodStart() / currentPeriodEnd() are public views. There is no waiting, no unusual balance, and no privileged role involved:

  1. The attacker (any staker) reads the next period start B = vault.currentPeriodEnd().

  2. They submit withdraw(assets, attacker, attacker) through a wrapper that reverts unless block.timestamp == B (or simply conditionally re-submits each period), so the transaction only lands in the block timestamped exactly B.

  3. _logTrace writes _traceTotalAssets[B] = totalAssets() - assets, i.e. the period-start snapshot of the new period is now the post-withdrawal figure.

  4. Any incident captured during that new period is paid at most totalAssets_after_withdrawal; the attacker's own assets, sitting in withdrawAssets[period + 1], are excluded from the cap even though the code deliberately keeps them in activePayableAmount.

  5. After the payout window closes the attacker calls claimWithdraw and receives the assets that should have absorbed the loss.

The same primitive applies to redeem (FirelightVault.sol:735-748), which uses the identical _requestWithdraw + _logTrace sequence.

Impact

Cover buyers are underpaid, permanently, by exactly the amount a staker moves across the period boundary, while the staker keeps funds that the protocol's own accounting had committed as loss-absorbing capital for that period.

PoC 1 — total evasion. A single depositor holding 100% of the vault withdraws everything in the boundary block. totalAssetsAt(periodStart) becomes 0, so an honest PAYOUT_ROLE call for a 10,000-unit incident captured in that period emits PayoutExecuted(..., requestedAmount = 10_000e6, paidAmount = 0, ...). The payout receiver gets nothing while the vault still physically holds the 10,000 units; the attacker claims all of it after the window.

PoC 2 — partial evasion. The attacker withdraws 50% of a 200-unit vault at the boundary. The cap drops from 200 to 100, so a legitimate 200-unit payout settles at 100. The beneficiary is short 100 units that the vault held and that the protocol's activePayableAmount accounting had marked as payable.

Because _isPeriodInPayoutWindow (FirelightVault.sol:1130) only allows payout during the capture period or the next one, the shortfall cannot be recovered later — there is no re-payout path, and the withdrawal becomes claimable as soon as the window closes.

Consequences:

  • Loss of funds for the covered party: an approved incident claim settles below the vault's real capacity, with no recovery path.

  • Protocol insolvency against outstanding cover: sold cover is not backed by the collateral the capacity check assumed, even though the collateral is present in the contract.

  • Griefing surface beyond self-interest: the cap is global, so a boundary withdrawal reduces the payout for every incident captured in that period, not only the attacker's pro-rata share.

  • The corruption is silent — no revert, no event anomaly; payout reports success with a reduced paidAmount.

Recommendation

Read the exposure cap strictly before the period boundary, so that anything happening in the boundary block is excluded from the period-start snapshot:

A more robust alternative is to persist an immutable per-period opening-assets snapshot, written by the first state-changing call of a period before it mutates totalAssets(), and to have payout read that value instead of a timestamp lookup. That removes the dependence on inclusive/exclusive lookup semantics entirely.

Either fix should be paired with a regression test asserting that totalAssetsAt(currentPeriodStart()) is unaffected by a withdraw/redeem executed at block.timestamp == currentPeriodStart().

References

  • contracts/core/FirelightVault.sol:911 — assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart)

  • contracts/core/FirelightVault.sol:456 — totalAssetsAt → upperLookupRecent

  • contracts/core/FirelightVault.sol:755-778 — withdraw (and :735-748 redeem)

  • contracts/core/FirelightVault.sol:1008 — _requestWithdraw, period = currentPeriod() + 1

  • contracts/core/FirelightVault.sol:1044-1056 — _logTrace

  • contracts/core/FirelightVault.sol:1123-1131 — _periodAndStartAt, _isPeriodInPayoutWindow

  • contracts/core/lib/Checkpoints.sol:198-224 — push (same-key replacement) and upperLookupRecent (inclusive)

Relation to previously reported issues

The closest prior item is Macro/OZ "Period duration cannot safely be changed", which was resolved by introducing the append-only periodConfigurations[] array and the timestamp-keyed checkpoint traces. This report is a defect inside that replacement mechanism — the inclusive period-start lookup combined with same-timestamp checkpoint writes — a failure mode the prior fix neither contemplated nor prevents. It is not covered by any of the program's listed known issues (first-loss-buffer valuation, unescrowed buffer wallet, or uncapped aggregate claims).

Proof of Concept

Proof of Concept

Runnable Foundry PoC. Verified against a clean clone of the audited repository at tag v1_audit_ready (commit 42f9ea5) with forge 1.5.1 / solc 0.8.28. Everything needed to reproduce it is inlined below — no external download required.

1. Where each file goes

The repository ships with a hardhat setup only, so the PoC adds a foundry profile on top of it. Place the four files below at these exact paths, relative to the repository root:

1.1 foundry.toml (repository root)

via_ir = true is mandatory — the plain pipeline hits Stack too deep in CoverOrderAllocator.

1.2 remappings.txt (repository root)

2. The PoC — test/foundry/F01_BoundaryWithdrawSlashEvasion.t.sol

3. Shared harness — test/foundry/utils/Deployers.sol

Deployment harness used by the PoC: it wires FirelightVault behind an ERC1967 proxy with the roles the test needs (PAYOUT_ROLE, PAYOUT_ALLOWLIST_ROLE) and a 1-day period configuration. It uses only mocks that already exist in the repository under contracts/test/.

4. How to run

5. Expected output

6. What each test proves

test_boundaryWithdrawEvadesSlashing — total evasion. A single depositor holds 100% of a 6-decimal vault (10,000 units) and calls withdraw at boundary = vault.currentPeriodEnd(). The period-start snapshot is rewritten to 0 (assertEq(vault.totalAssetsAt(boundary), 0)) while the assets sit in withdrawAssets[2], which payout still counts as payable. An honest PAYOUT_ROLE payout for a 10,000-unit incident captured at boundary emits PayoutExecuted(receiver, 10_000e6, 0, boundary) — the beneficiary receives 0 while the vault still physically holds the funds. After the payout window closes, claimWithdraw(2) returns 100% of the deposit to the attacker.

test_boundaryWithdrawUnderpaysPartialWithdraw — partial evasion. Attacker and an honest staker deposit 100 units each. The attacker withdraws their 100 at the boundary, cutting the cap from 200 to 100 (assertEq(vault.totalAssetsAt(boundary), amount)). A legitimate 200-unit payout then settles at 100 instead of 200. The shortfall is unrecoverable: _isPeriodInPayoutWindow only allows payout during the capture period or the next one.

Was this helpful?