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

88308 sc high totalassetsat resolves a period s opening assets inclusively so a withdrawal in the boundary second redefines the ceiling on every claim against that period

Submitted on Aug 12th 2026 at 17:36:09 UTC by @ret2basic for Audit Comp | Firelight

  • Report ID: #88308

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

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

Description

Brief/Intro

Two decisions depend on one number: how much cover a period may sell (CoverOrderAllocator._computeAvailableCapacity) and the ceiling on what that period may pay out (FirelightVault.payout). Both read totalAssetsAt(periodStart). That is not a snapshot taken at the boundary — it resolves to the most recent _traceTotalAssets checkpoint whose key is less than or equal to periodStart. The bound is inclusive, so the figure a period opens with is not frozen when the period begins: it still counts anything written during the boundary second itself. A withdrawal executed in that second therefore redefines the period's own opening number after the period has already started. The assets do not leave the vault: they move into withdrawAssets[period + 1], which payout explicitly still counts as payable. The result is a state where the collateral is present, marked payable, and the ceiling that decides what claims are worth reads zero.

Vulnerability Details

The read is inclusive of the boundary key:

    function totalAssetsAt(uint48 timestamp) public view returns (uint256) {
        return _traceTotalAssets.upperLookupRecent(timestamp);
    }

A write whose key is exactly periodStart is therefore inside the window that totalAssetsAt(periodStart) answers from. Which branch of push performs that write does not change the outcome, and the PoC measures which one runs rather than assuming: test_whichCheckpointBranchTheBoundarySecondActuallyTakes shows totalAssetsAt(boundary) == totalAssetsAt(boundary - 1) before the withdrawal, proving no checkpoint sat on the boundary key, so the boundary-second write appends. The reading still changes from 1,000,000e6 to 0.

The equal-key branch below is a second route to the same outcome, reached whenever a checkpoint already occupies that key — for instance when VaultRewardDistributor forwards assets and calls checkpointTotalAssets() in the boundary second, or when two vault operations share it:

Every vault flow pushes at the current second:

and a period boundary is an ordinary second: currentPeriodStart() returns epoch + k * duration, and a transaction can land in the block carrying exactly that timestamp. When it does, _traceTotalAssets already holds a checkpoint on that key only if something else touched the vault in the same second; otherwise the push appends a new checkpoint whose key equals periodStart. Either way upperLookupRecent(periodStart) now returns the post-withdrawal value, because it selects the greatest key less than or equal to the search key.

The withdrawal itself is period-correct in every other respect. At block.timestamp == periodStart the vault is in period p, so _requestWithdraw files the assets under currentPeriod() + 1 == p + 1:

and payout treats exactly that bucket as still exposed to a period-p incident:

So the two halves of the same statement disagree: activePayableAmount counts the withdrawn assets, and assetsAtCapturePeriod — now rewritten — does not. Math.min takes the smaller, and the claim collapses to whatever the ceiling was rewritten to.

Contrast the same withdrawal one second earlier, in period p - 1: the assets are filed under bucket p, which payout does not count as payable for a period-p incident, and the reduced snapshot is then correct. The accounting is coherent at periodStart - 1 and incoherent at periodStart. The PoC's control test isolates this to exactly that one second.

The project's own suite states the intended property directly. In test/vault_payout.js, "caps payout to assets at the capture period start" deposits 2, crosses into the next period, deposits a further 5 mid-period, and asserts that a payout of 7 delivers only 2:

The ceiling is expected to ignore anything that happens after the period opens — that is the whole point of the test, and it passes. What is not covered is that the same figure can still be rewritten, by a write landing on the boundary key itself. The suite verifies the ceiling holds against later deposits; it does not verify that the ceiling is immutable.

That boundary-second writes are meaningful is not an accident of this report — the protocol depends on it. VaultRewardDistributor forwards assets and then calls checkpointTotalAssets() precisely so that "historical totalAssetsAt lookups (period-start snapshots) include the forwarded assets". A reward landing on the boundary second correctly raises the period's opening figure. The same mechanism, driven by a withdrawal, incorrectly lowers it. The write path is identical; only the direction and the caller differ, and nothing distinguishes them.

Ordering inside the boundary block decides which of two harms lands:

  • Commit first, withdrawal second — the matcher commits against the intact figure and cover is sold and settled, then the ceiling is rewritten. Claims on that cover are capped by the rewritten value. This is the case the PoC measures.

  • Withdrawal first, commit second — _computeAvailableCapacity reads the rewritten figure and the period's matched tree can no longer be committed, so the period sells no cover at all.

Impact Details

How far it scales

The ceiling falls by exactly what the caller withdraws, so the shortfall is the size of their own position rather than the size of the vault. Measured with a holder of 10% of a 1,000,000 fXRP vault:

Every token remains in the vault and in the bucket payout treats as exposed; 100,000 fXRP of it simply cannot be reached by any claim against that period. The "pays zero" case in the table below is the limit of the same behaviour, where the withdrawing holder is the whole of the active pool. On the live vault (58,288,993 fXRP at block 0x4021533) a holder of a tenth of it would put roughly 5.8M fXRP — about $11.6M at the feed price — beyond reach of that period's claims while leaving it in the contract.

The full-holder case, end to end

The PoC measures it with the real vault, allocator, cover NFT and incident manager:

Quantity
Value

Capacity committed for the period

100,000 canonical USD

Cover sold and settled

100,000 canonical USD

Loss assessed on a genuine incident

100,000 canonical USD

Paid to the payout recipient

0

Tokens held by the vault throughout

1,000,000 fXRP (≈ $2,000,000)

Of which marked payable in withdrawAssets[p+1]

1,000,000 fXRP

The control test runs the identical sequence with the withdrawal one second later and the same claim pays 50,000 fXRP in full. The only variable is the second in which one ordinary withdraw call executes.

The caller gains; this is not griefing

The same holder, in two runs identical but for the second their withdrawal lands in, ends with materially different money. Cover of 400,000 USD is sold and settled, the holder exits, and a claim for the full amount is approved:

exit lands
holder's claimable position afterwards

one second after the boundary

800,000 fXRP

on the boundary second

1,000,000 fXRP

gain: 200,000 fXRP

The mechanism is direct. Exiting one second late leaves the ceiling intact, the claim is paid, and because the holder's capital sits in the bucket payout treats as still exposed, the payment is drawn from them — they bear the loss their capital was underwriting, which is the design. Exiting on the boundary second rewrites the ceiling to the post-exit figure, paidAmount collapses to min(amount, ceiling, payable), and their bucket is never touched. The gain is exactly the payout they escaped.

Who is harmed and who acts:

  • Cover buyers lose the claim on cover they paid a premium for.

  • The actor is any depositor calling withdraw — no role, no special capability, no capital beyond the position they already hold — and they keep money they would otherwise have paid.

  • Other depositors who did not use the timing are left carrying whatever the reduced claim still draws.

A note on how easily this happens by accident. While writing an unrelated test — one exercising the payout arm that draws on a second withdrawal bucket — the sequence happened to place a withdrawal on the boundary second, and the claim paid 10,000 fXRP instead of the expected 50,000. Nothing in the protocol's state explains that gap; it took recognising this finding to know why the number was wrong. The test now warps one second past the boundary specifically to avoid it.

Honest scoping of the constraint: the transaction must land in the block whose timestamp is exactly epoch + k * duration. A caller cannot choose a block timestamp. On Flare, blocks arrive roughly every 1.8 seconds, so a given second is carried by a block a large fraction of the time, and an actor can retry at every boundary — daily or weekly — at the cost of one transaction. It can also occur with no intent at all, from an ordinary user who happens to withdraw at the wrong second, in which case the period's claims quietly become unpayable with nothing in the protocol's own state to explain why.

Why the program's out-of-scope rules do not exclude this

No privileged actor appears anywhere in the PoC: the attacker is an ordinary depositor calling withdraw on their own position, so the exclusion for trusted roles acting incorrectly does not apply. Nor is this a "basic economic attack" — no market, price, or liquidity is manipulated; a single ordinary call rewrites a stored accounting checkpoint.

Why griefing is not the right impact here. The in-scope griefing entry is defined as "no profit motive for an attacker". That was measured and refuted: the same holder, running the identical sequence and differing only in the second their exit lands, ends 200,000 fXRP better off. The gain is the payout they escaped, and it is exactly the cover buyer's loss.

What limits it, stated plainly. The withdrawal must land in a block whose block.timestamp equals the period boundary, and a caller cannot choose a block timestamp — so the attack is opportunistic rather than on-demand. Nothing else is required: no role, no capital beyond the position already held, one ordinary call, and the boundary recurs every period.

The defect is in the shared library, not in one call site

_traceTotalAssets is not the only trace built on this push. The vault keeps a per-account checkpoint with the same library and the same key:

and exposes it through the in-scope interface:

A transfer landing on a period boundary rewrites that account's start-of-period balance in exactly the same way. This report does not claim a separate impact for it: no in-scope contract reads balanceOfAt, so today the consequence falls only on off-chain consumers of a documented public view. It is raised because it decides the shape of the fix. Patching payout or _computeAvailableCapacity to compensate would leave the second surface intact and would break again the moment a future contract reads balanceOfAt at a boundary. The fix belongs in the checkpoint library or at every push site, not at the readers.

Read the period's opening state from a key that no in-period transaction can write. Checkpoint keys are monotonic, so any key strictly below the boundary is final once the period starts:

and the matching change in CoverOrderAllocator._computeAvailableCapacity:

This makes both readers ask "what did this period open with", which is the question they mean, instead of "what does the boundary key hold now", which is mutable for one second. Both call sites must change together, or capacity and the payout ceiling will be measured against different instants.

An alternative is to have payout bound the claim by max(assetsAtCapturePeriod, capturePeriodWithdrawals + nextPeriodWithdrawals), which restores coherence between the two halves of Math.min, but it does not fix the capacity side.

References

  • contracts/core/lib/Checkpoints.sol — upperLookupRecent (inclusive bound; the primary mechanism), Trace256.push (equal-key overwrite; the secondary route)

  • contracts/core/FirelightVault.sol — payout, _logTrace, _requestWithdraw, currentPeriodStart

  • contracts/core/CoverOrderAllocator.sol — _computeAvailableCapacity, commitAllocation

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

Proof of Concept

Proof of Concept

Prerequisites

  • Toolchain: forge 1.7.1 (Foundry), solc 0.8.28, Node.js with npm.

  • No RPC required. The PoC runs entirely on a local EVM. No environment variables, credentials, or forked state are used.

  • Target: https://github.com/immunefi-team/audit-comp-firelight, branch v1_audit_ready, commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62.

  • Contracts under test: the real FirelightVault, CoverOrderAllocator, CoverNFT and IncidentManager from contracts/core/, each behind a real ERC1967Proxy, initialized through their production initialize functions.

Attacker capabilities

One ordinary vault shareholder, holding only the position they deposited:

  • lp calls withdraw(...) once. No role, no allowance from anyone else, no flash loan, no capital that did not arrive through deposit().

  • The only environmental assumption is transaction timing: the withdrawal executes in the block whose timestamp equals the period boundary, and after the allocator's commit in that block. A caller cannot choose a block timestamp; on Flare, where blocks arrive roughly every 1.8 seconds, an actor retries at each boundary. vm.warp is used to place the test on that second — it stands in for the timing, not for any capability the caller lacks.

Everything else is a legitimate role doing its job: curator sells cover and reports the incident, allocatorRole commits and settles, admin approves.

No vm.store, no vm.etch, no impersonation of a role to defeat a check.

Environmental doubles, disclosed

  • MockERC20 as the vault asset (6 decimals, matching the live FXRP) and as the premium / first-loss-buffer token — the project's own test doubles.

  • MockAggregatorV3 as the price feed, held at a constant $2.00. The finding does not involve the oracle; a constant feed keeps the arithmetic legible.

  • The first-loss buffer is left unfunded so the whole assessed loss routes to the vault, which is the leg the ceiling applies to.

Production layers this PoC does not execute

  • The off-chain matching engine; the Merkle commitment is a single-leaf tree the allocator accepts.

  • The real FTSO oracle, the real FXRP token, and production deployment scripts.

  • Block production: vm.warp places the test on the boundary second deterministically, where a real actor would be relying on inclusion in that block.

Reproduction steps

  1. Clone the target at the pinned commit and install its dependencies:

  2. Copy every file below into the matching path.

  3. Run:

  4. Expected output:

What each test establishes

  • test_aWithdrawalAtTheBoundarySecondRewritesTheSnapshot — the mechanism in isolation. The period opens with totalAssetsAt(periodStart) == 1,000,000e6 (asserted). One withdraw at that same second later, the vault still holds every token and the assets sit in withdrawAssets[period + 1] — the bucket payout counts as payable — yet totalAssetsAt(periodStart) now reads 0.

  • test_soldCoverBecomesUnpayableWhileTheCollateralIsStillThere — the target behaviour end to end. Capacity is committed against the intact figure, 100,000 USD of cover is sold and settled, the withdrawal then rewrites the ceiling, and a genuine incident assessed at the full cover amount pays 0. The measured victim delta is the cover buyer's entire claim, against a vault that holds 1,000,000 fXRP throughout and marks all of it payable.

  • test_theSameSecondAlsoCollapsesTheCapacityTheMatcherMayCommit — the other half of the same defect, at the second on-chain call site. With the withdrawal landing before the commit in the boundary block, CoverOrderAllocator._computeAvailableCapacity reads the rewritten figure and commitAllocation reverts: the period sells no cover at all, while the vault holds the full 1,000,000 fXRP that would have backed it.

  • test_aPartialHolderShrinksTheCeilingByTheirOwnStake — how far it scales. A holder of 10% of the vault withdraws at the boundary second: the ceiling falls by exactly their 10%, all 1,000,000 fXRP remains in the vault and in the payable bucket, and 100,000 fXRP of present, exposed collateral becomes unreachable by any claim against that period. The zero-payout case above is the limit of the same behaviour, not a separate one.

  • test_whichCheckpointBranchTheBoundarySecondActuallyTakes — written during a false-positive review of this finding, to decide by observation which branch of Checkpoints.push the boundary-second write actually takes. It shows totalAssetsAt(boundary) == totalAssetsAt(boundary - 1) before the withdrawal, which proves no checkpoint occupied the boundary key, so the write appends rather than overwrites — and the reading still falls from 1,000,000e6 to 0. The defect is therefore the inclusive <= key bound in upperLookupRecent, not the equal-key overwrite; the overwrite is a second route to the same outcome. This test exists so the report's root cause matches the branch the PoC executes.

  • test_theWithdrawingHolderEndsRicherForHavingUsedTheBoundary — whether the caller gains, which decides whether this is griefing or an attack. The whole sequence is run twice, identical but for the second the withdrawal lands in. Exiting one second late leaves the holder with 800,000 fXRP claimable; exiting on the boundary second leaves them with 1,000,000 — 200,000 fXRP kept, exactly the payout they escaped, while the cover buyer receives nothing.

  • test_oneSecondLaterTheSameWithdrawalIsHarmless — the same-input control. Every step is identical except that the withdrawal executes at periodStart + 1. The snapshot survives (asserted) and the identical claim pays 50,000e6 fXRP in full. One second of difference separates a full payout from a zero payout, which isolates the equal-key overwrite as the cause and rules out every other explanation.

Complete runnable PoC files

poc-inputs/foundry.toml

poc-inputs/fuzz/FullChainFixture.sol

poc-inputs/fuzz/BoundarySnapshot.t.sol

Was this helpful?