89803 sc high a withdrawal at the exact period start rewrites the historical asset snapshot so approved incident claims are underpaid or paid nothing while the vault still holds the money
#89803 [SC-High] A withdrawal at the exact period start rewrites the historical asset snapshot, so approved incident claims are underpaid or paid nothing while the vault still holds the money
Submitted on Aug 21st 2026 at 09:51:16 UTC by @jecikpo for Audit Comp | Firelight
Report ID: #89803
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
A withdrawal at the exact period start rewrites the historical asset snapshot, so approved incident claims are underpaid or paid nothing while the vault still holds the money
Brief/Intro
FirelightVault.payout decides how much of an approved claim it will pay by looking up how many assets the vault held at the start of the period the incident happened in. That stored number is not fixed. A withdraw or redeem in a block whose timestamp is exactly the period start overwrites the value for that timestamp, and every later reader sees the lowered figure. On mainnet this means an approved, validated claim is paid only a fraction of what it is owed, or nothing at all, while the vault is still physically holding the full amount. The incident is then marked closed, and the user who withdrew collects the assets that should have paid the claim. Anyone holding vault shares can do this in one permissionless transaction, and it can also happen by accident.
Vulnerability Details
The problem
Firelight pays claims out of what the vault held at the start of the period the incident happened in. That figure is meant to be a settled historical fact. It is not. It is a checkpoint that can still be rewritten after the period has begun, by anyone, in one permissionless transaction.
A withdraw(...) or redeem(...) landing in the block whose timestamp is exactly the period start does not append a new entry to the vault's history. It overwrites the entry already sitting at that timestamp, lowering it. payout(...) then reads the lowered figure and caps the claim by it, while the vault is still holding the full amount.
Why the past is rewritable
payout(...) bounds the payment by totalAssetsAt(capturePeriodStart) (FirelightVault.sol:911-927), which is a checkpoint lookup, not a stored snapshot:
Two properties of the checkpoint library combine. Checkpoints.push(...) overwrites instead of appending when the incoming key equals the latest key (Checkpoints.sol:207-209), and upperLookupRecent(...) matches keys less than or equal to the query (Checkpoints.sol:221-224). A write stamped T therefore becomes the answer to a query for T.
withdraw(...) and redeem(...) perform exactly that write: both call _logTrace(...), which stamps the new total with the current block timestamp (FirelightVault.sol:1051-1055). The value is lower because totalAssets() is net of pendingWithdrawAssets (FirelightVault.sol:428), so merely queuing a withdrawal reduces it. No token has left the contract.
The contradiction
At the period start T, the vault puts the same withdrawal on two different sides of the boundary within one transaction. For payability it is inside the period: _requestWithdraw(...) files the assets under currentPeriod() + 1 (FirelightVault.sol:1008), which at T is capturePeriod + 1, and payout(...) counts that bucket as still exposed and still payable (FirelightVault.sol:916). For the cap it is before the period: the checkpoint written at key T is now the vault's answer for what it held when the period opened. Math.min(...) resolves the disagreement in favor of the cap, and the claimant absorbs the difference.
One second in either direction and the two agree. At T - 1 the write lands on the previous key. At T + 1 the key exceeds the query and the snapshot is untouched. T is the only timestamp where the write key collides with the query key while the period assignment has already advanced.
Trigger conditions
A single withdraw(...) or redeem(...) in a block whose timestamp equals a period start. No privileged role, no capital beyond the shares being withdrawn (returned in full one period later), and no coordination with the incident, which need not exist yet. On a chain with roughly two-second blocks a block bearing any given second usually exists, and the attempt costs only gas and repeats at every period boundary.
A zero result is also accepted: payout(...) does not revert, emits PayoutExecuted with paidAmount = 0 (FirelightVault.sol:929-930), and IncidentManager.approveCurrentAssessment(...) records the amount only if nonzero while closing the incident either way (IncidentManager.sol:432-434). Nothing marks the claim as unpaid and there is no retry path.
Impact Details
Selected impact: Protocol insolvency (High).
Firelight sells cover on the promise that it is backed by the vault's assets for that period. This bug breaks the promise from both ends. Capacity is sold against a figure that can be revised downward afterwards, and an approved claim against that same period is then capped by the revised figure. The protocol ends up unable to honor a claim it has already validated and approved, out of assets it is still holding.
Measured losses, from the PoC. A vault holding 1,000 fXRP, an incident captured at the period start asking for the full 1,000:
900 withdrawn one second after the boundary (control)
1,000
0
1,000
900 withdrawn at the boundary
100
900
1,000
Entire position withdrawn at the boundary
0
1,000
1,000
The first two rows leave the vault in an identical live state: the same 900 queued in the same withdrawal bucket, the same 100 active. The only difference is one second on the withdrawal's block timestamp, and it is worth 900 fXRP to the claimant.
In the last row payout completes without reverting, emits paidAmount = 0, the payout receiver gets nothing, the incident is closed, and the user who withdrew claims the entire 1,000 once the next period ends. Nothing was ever insolvent in the ordinary sense. Every asset was present and the vault declined to use it.
The shortfall is bounded by the size of the boundary withdrawal, up to 100% of the vault-funded part of any claim. The first-loss buffer pays before the vault and is unaffected, so the loss lands on the portion the vault was supposed to cover.
Two further consequences:
Cover keeps being sold against assets the claims path has written off. Capacity committed at the period start stays frozen in
commit.totalAvailableCapacity, so orders continue to settle against collateral thatpayoutwill no longer count.It happens without an attacker. Any ordinary user withdrawing in the boundary block corrupts the snapshot the same way, and the next claim in that period silently underpays. Neither the protocol nor the claimant would have any obvious signal that it happened.
References
Proof of Concept
Two new files. Nothing under contracts/core/, contracts/oracle/ or contracts/legacy/ is touched, and no existing test is modified.
contracts/test/PeriodSnapshotReader.sol
Test-only harness. Performs the allocator's period-start read in the same shape, without standing up the oracle and Merkle machinery.
test/vault_boundary_snapshot.js
The five tests.
How to run
From a clean checkout of v1_audit_ready at commit 42f9ea5:
Everything runs on the project's existing Hardhat network and its existing fixture, deployVault() from test/setup/fixtures.js. No forking, no RPC URL, no .env.
What each test shows
Setup is the same in all five: 1,000 fXRP deposited during period 0, and T is the exact second at which period 1 begins.
1
snapshot rewritten
The harness reads 1,000 at T. After a withdrawal of 900 in that same block, totalAssetsAt(T) returns 100, with all 1,000 still in the vault.
2
payout underpaid
An incident captured at T requesting 1,000 is paid 100. The receiver gets 100, the vault keeps 900.
3
withdrawal alone
No harness, no second transaction. The lone withdraw at T reproduces the underpayment, and asserts the contradiction directly: bucketed as exposed at withdrawAssets[capturePeriod+1] = 900, capped as absent at totalAssetsAt(T) = 100.
4
control, withdrawal at T + 1
Identical live state, 100 active and 900 queued in the same bucket. The snapshot is untouched at 1,000 and the payout pays 1,000 in full.
5
drained at T
totalAssetsAt(T) becomes 0. payout emits paidAmount = 0 without reverting, the receiver gets nothing, and the withdrawer claims all 1,000 a period later.
Test 4 is the one that isolates the bug. Compared with test 2 the live state is identical, the withdrawal is the same size, and it falls in the same period and the same withdrawal bucket. The only difference is one second on the withdrawal's block timestamp, and it is worth 900 fXRP to the claimant.
A note on the same-block setup
Tests 1, 2 and 5 need an on-chain read and a vault write to share one block at timestamp T. Hardhat mines one transaction per block by default, so the helper turns automine off, queues both transactions from the same signer (nonce ordering pins the sequence), then mines. It uses raw eth_sendTransaction rather than the ethers contract helpers, which block waiting for a receipt that cannot arrive until the block is mined. Each test asserts that the receipts share a block number, so the setup cannot silently degrade into two blocks and still pass.
Test 3 needs none of that. It is a single ordinary transaction and demonstrates the same loss.
Expected output
The full suite stays green with these files added: npm test reports 457 passing, up from 452.
File 1 — contracts/test/PeriodSnapshotReader.sol
File 2 — test/vault_boundary_snapshot.js
Was this helpful?