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

89424 sc high a withdrawal at the exact period boundary permanently collapses the period s exposure snapshot voiding paid for cover and denying capacity

Submitted on Aug 19th 2026 at 08:38:18 UTC by @deltasec for Audit Comp | Firelight

  • Report ID: #89424

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

    • Theft of unclaimed yield

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

Description

Brief/Intro

When the block timestamp coincides with a period start T, every withdrawal landing in that block permanently slashes the amount that can be paid for any incident captured in the period. Three facts make this periodic vulnerability possible:

  1. totalAssetsAt(T) returns whatever was last pushed at key T — Trace256.push appends on a new key and overwrites in place on an equal one (Checkpoints.sol:207-209) — and trace keys are monotonic, so once the value at key T is written down it can never be raised again.

  2. The vault answers two questions inconsistently about the same withdrawal: _requestWithdraw books the withdrawn shares into bucket currentPeriod() + 1 — the next, still-exposed period (FirelightVault.sol:1008) — while _logTrace writes the reduced total-assets value onto key T itself (:1055).

  3. Key T is precisely the key the rest of the protocol reads as the period's opening capital: payout reads it as capturePeriodStart (:911), and the allocator reads it in _computeAvailableCapacity (CoverOrderAllocator.sol:711), which gates every commitment through _validateMatchingCapacity (:400-402).

So the assets are simultaneously exposed (booked into the still-covered bucket, and added back by activePayableAmount at :916 so exposed assets cannot escape by withdrawing first) and absent (erased from the snapshot that caps the claim). The cap is the smaller of the two, so the cap wins.

The consequence is permanent: every incident ever captured in that period is capped at the collapsed value — at the extreme, zero — even though the vault physically holds the assets and its own activePayableAmount proves it could have paid. The same collapsed key also drives capacity, so commitments for the period can be rejected outright.

Vulnerability Details

Root cause — bucket and key disagree at the boundary. contracts/core/FirelightVault.sol:

At t == T the period that opens at T is already currentPeriod(), so the withdrawal books into C + 1 and stays slashable for incidents captured in period C. The matching trace write, however, lands on key T — the key that period C is measured by.

The window is exactly one second. Measured by the PoC below (11,000 in the vault, whale withdraws 10,000, full 11,000 claimed against an incident captured in the period):

Offset -1 is self-consistent and correct: the underwriter left before the period opened, so its assets are excluded from the cap and from the payable set. Offset +1 is self-consistent the other way: the assets are in both. Only at offset 0 do the two disagree, and the smaller one — the cap — decides the claim. 10,000 of paid-for cover is voided while the vault holds every token.

Consumers of the collapsed key

Consumer
Effect
Victim

payout (FirelightVault.sol:911)

the claim cap collapses, and the collapsed key is permanent — trace keys are monotonic, so totalAssetsAt(T) can never be raised again, and every incident ever captured in that period is capped at the collapsed value

cover buyers

_computeAvailableCapacity (CoverOrderAllocator.sol:711)

the period's opening capital is understated for the rest of the period, lowering sellable capacity

every buyer in the tree

_validateMatchingCapacity (CoverOrderAllocator.sol:400-402)

reverts with MatchingCapacityOverflow on a commitment that the vault's actual collateral supports

protocol revenue

Trigger path

  1. Any share holder calls withdraw/redeem in the block whose timestamp equals a period start T.

  2. _requestWithdraw books the shares into bucket C+1 — the period that is still exposed (:1008).

  3. _logTrace pushes _totalAssets - assets at key T (:1055, Checkpoints.sol:207-209).

  4. totalAssetsAt(T) is now permanently understated. Every incident with capturePeriodStart == T is capped at the understated figure for the rest of time, and the allocator's capacity basis for the period is understated for the rest of the period.

Impact Details

  • Permanent, irreparable voiding of paid-for cover. Once key T is collapsed it can never be raised — trace keys are monotonic — so every incident ever captured in that period is capped at the collapsed value. The PoC shows a 50,000 refill inside the same period leaving totalAssetsAt(T) at 1,000 and a 51,000 claim still paying 1,000. If the whole vault exits at T the cap is zero: buyers who paid full premium for the period can never be paid anything, while the vault demonstrably holds the collateral and activePayableAmount reports it as present.

  • Understated capacity for the remainder of the period. _computeAvailableCapacity derives the period's sellable capacity from the same collapsed key, so commitments the real collateral supports revert with MatchingCapacityOverflow — lost underwriting revenue for the period, and at the extreme no capacity at all.

  • Cost to trigger: one ordinary withdrawal in one specific block. The loss falls on cover buyers and the protocol; no privileged role is involved.

The scope exclusion for "collateral drawdowns reducing coverage capacity" does not apply: this is not a drawdown. The collateral never left the vault, and activePayableAmount proves the vault could have paid.

References

  • contracts/core/FirelightVault.sol:1008 — withdrawal booked into the still-exposed bucket

  • contracts/core/FirelightVault.sol:1055 — trace push at the boundary key

  • contracts/core/FirelightVault.sol:911, :916 — payout cap vs activePayableAmount

  • contracts/core/CoverOrderAllocator.sol:711, :400-402 — capacity basis and the commitment gate

  • Checkpoints.sol:207-209 — Trace256.push: append on a new key, in-place overwrite on an equal key

  • Tested against commit 42f9ea5, branch v1_audit_ready

Proof of Concept

Proof of Concept

Save as test/poc.js and run npx hardhat test test/poc.js. It uses only the project's own test/setup/fixtures.js; no harness or contract modifications are needed. Both tests pass against the unmodified code.

Was this helpful?