88555 sc high a depositor withdrawing in the exact period boundary block underpays an approved incident claim by their own withdrawal while the vault still holds the money and counts it as payable
Submitted on Aug 13th 2026 at 19:15:27 UTC by @Mrakgi for Audit Comp | Firelight
Report ID: #88555
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
Protocol insolvency
Description
Impact rows selected on the form: Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield, and Protocol insolvency. Two rows because the defect has two faces and neither alone states it fully. Both are described below with the part of the evidence that supports them, so the triage team can discard either without losing the finding.
Direct theft — what the PoC measures. The funds in motion are an approved incident payout. In test 2 the insured receive 100,000 instead of 200,000, while the withdrawer's own later claim rises from 720,000 to 810,000: 90,000 of the shortfall lands with the actor who caused it. In test 1 the 200,000 shortfall splits 40,000 to the withdrawer and 160,000 retained as vault value. Every one of those figures is asserted in the test, not printed.
The word that can be argued is theft. The withdrawer never takes custody of an asset registered to the insured; what they do is escape the slashing the vault's own accounting assigns to them, while the insured are underpaid by the same mechanism, in the same sequence, by an overlapping amount. Read strictly, that is a transfer of value from claimant to actor. Read conservatively, it is underpayment plus unjust retention.
Protocol insolvency — the other face. The vault carries an approved obligation it does not discharge while holding, and itself classifying as payable, the assets that would cover it. This is deliberately not a claim of balance-sheet insolvency: the money is present, which is precisely what makes the shortfall indefensible rather than unavoidable.
These two rows describe the mechanism. The severity field below follows them, because the form requires it to; my own assessment is lower, and I set it out there rather than leaving the field to speak for me.
Severity: Critical — set to match the impact rows selected above, both of which this program classifies as Critical, which is what the submission form requires when the chosen impacts share a level.
My own assessment is High, and I would rather say so than let the field speak for me. Two things hold it below Critical in my reading: the shortfall is bounded by the withdrawer's share of the vault, and it needs an approved incident in that period large enough to bind the cap — and the withdrawer controls neither. What is not in doubt is the mechanism, the permissionless trigger and the measured amounts; the Impact Details and practicality sections give the numbers for either reading, including the 90% case.
Requires a privileged role to act outside its documented behaviour: no — the trigger is withdraw/redeem, which need no protocol role at all. Their gates are the global pause, the blocklist and the reentrancy guard, plus ordinary validation of amount, addresses and balance — all of which apply to every depositor alike.
Brief / Intro
payout deliberately counts a withdrawal made during the capture period as still-exposed collateral, adding withdrawAssets[capturePeriod + 1] to the payable amount. The same withdrawal also writes a totalAssets checkpoint keyed at block.timestamp. When that timestamp is exactly the period start, the checkpoint becomes the period-start snapshot — already net of the withdrawal — and payout uses that snapshot as the hard cap on the payment. One amount is therefore counted as available to pay the claim and subtracted from the ceiling on that same payment. The ceiling wins, the insured are underpaid, and the withdrawer keeps part of a slashing that would otherwise reach them.
The identical run with the withdrawal one second later pays out everything the vault is holding — and where the claim fits inside that collateral, it pays the claim in full.
Vulnerability Details
All code below is quoted verbatim, including the project's own comments and line wrapping.
A withdrawal request is bucketed by the period the transaction is in:
On a boundary timestamp currentPeriod() already returns the new period P, so the bucket is P + 1. That is because periodAtTimestamp is a floor division from the configuration epoch, and a timestamp exactly equal to a period start divides evenly into the new period:
The same call then checkpoints the post-withdrawal total at the current timestamp. _logTrace is reached from redeem (:741) and withdraw (:775), in both cases with _totalAssets - assets; on the boundary its key ts equals the period start:
Two properties of the checkpoint library make the overwrite exact rather than approximate. This is the in-scope fork at contracts/core/lib/Checkpoints.sol, not the OpenZeppelin original — the vault's totals use its Trace256 type. A push whose key equals the latest key replaces the stored value instead of appending, so a boundary transaction can also overwrite a value another transaction wrote earlier in the same block:
and the lookup is inclusive of an equal key, so the value written at the boundary is the one returned as that period's opening figure:
payout then caps the payment with that snapshot while simultaneously counting the same bucket as payable:
assetsAtCapturePeriod is now net of the withdrawal, while the line below it adds that very amount back through capturePeriodWithdrawals. The contract's own comment states the policy the bucketing exists to enforce: withdrawals requested inside the exposure window remain payable, "otherwise exposed assets could escape by withdrawing first." They escape anyway — not through the bucket, which behaves exactly as written, but through the cap.
Nothing later repairs it. A checkpoint written after the boundary carries a key greater than periodStart and is invisible to the lookup. payout writes its own checkpoint (:952) only after the defective cap has been applied. claimWithdraw (:788-810) converts the caller's share of whatever remains in the bucket and performs no exposure recheck. In the normal same-incident flow IncidentManager records the short payment and closes the incident (:423-438), with no retry for the unpaid balance.
The snapshot design itself is sound and deliberate, and the suite already guards it — but only in the upward direction. test/vault_payout.js:85-107 asserts that a mid-period deposit must not inflate the payout cap. test/coverOrderAllocator/commitAllocation.js:230-245 makes the matching point for allocation capacity, though it does so against a mock vault whose snapshot is set directly via setTotalAssetsAtSnapshot, not through an actual deposit. The boundary case moves the snapshot the other way — downward, by an amount the very same transaction re-declares as payable — and nothing covers it.
Impact Details
Write A for the collateral at the true period start and W for the amount withdrawn on the boundary. The cap becomes A − W instead of A.
There are two regimes, and the measured runs cover both. Where the claim is large enough that both runs hit their caps — the honest one at A, the defective one at A − W — the shortfall is exactly W; that is tests 1 and 3. Where the claim is smaller than A, the honest run pays it in full and the shortfall is bounded by the claim instead: in test 2 the withdrawal is 900,000 but the claim is only 200,000, so the insured lose 100,000, not 900,000. A claim that fits entirely under A − W produces no shortfall at all.
The payment is then split pro-rata between the active assets and the still-exposed withdrawal bucket, so the bucket absorbs about paid · W/A of the slashing and the withdrawer keeps the rest of their W. The contract derives the active-assets share first and assigns the remainder to the buckets (FirelightVault.sol:936-943), so the exact integer split is rounding-sensitive; every figure below is taken from the PoC's assertions rather than from a closed-form formula.
Three measured runs. The first two are each paired with an identical one-second-later control:
Test 1 — 200,000 of 1,000,000 exits on the boundary. A claim large enough to absorb the whole vault is filed.
Collateral physically in the vault at payout
1,000,000 fXRP
1,000,000 fXRP
totalAssetsAt(period start) — the cap
800,000
1,000,000
Payable collateral the contract itself computes
1,000,000
1,000,000
Paid to the insured
800,000
1,000,000
Withdrawer keeps of her 200,000
40,000
0
The insured receive 20% less than the vault was holding and had already classified as payable.
Test 2 — a dominant staker, 900,000 of 1,000,000, exits on the boundary. The claim is 200,000.
totalAssetsAt(period start) — the cap
100,000
1,000,000
Paid on a 200,000 claim
100,000 (50%)
200,000 (100%)
Withdrawer keeps of her 900,000
810,000
720,000
Half of a valid, approved claim is not paid, while the full 1,000,000 sits in the vault. The withdrawer is 90,000 better off for having landed one second earlier.
Test 3 — the commitment and the withdrawal land in the same boundary block, commitment first. This is the case that decides the severity, so it is worth stating why it exists.
The obvious objection to the two runs above is that the effect ought to be self-limiting: allocation capacity is read from the same period-start snapshot (CoverOrderAllocator._computeAvailableCapacity), so a corrupted snapshot should also cause less cover to be sold, bounding the shortfall. That argument holds only when the commitment happens after the withdrawal. It does not survive transaction ordering.
The attacker only has to be ordered behind the commitment in the same block. The commitment merely reads totalAssetsAt(currentPeriodStart()) and writes nothing, so it is sized against the true collateral A. The withdrawal then writes its own checkpoint at the boundary key — appending it, since nothing had written at that key yet — and the inclusive lookup makes that value, A − W, the period-start figure from then on. Capacity is bound to the honest figure; only the payout is capped by the corrupted one.
(The overwrite property quoted earlier is what makes this robust rather than order-dependent: had something already written at the boundary key — a deposit in the same block, say — the withdrawal would have replaced that value instead of appending, with the same result.)
Two things here are assumptions about off-chain operation rather than facts the repository fixes, and I flag them as such: that a matcher would commit early in a period, and that being ordered behind a known pending commitment in the same block is achievable. Neither is guaranteed by the code. What the code does guarantee, and what the test asserts, is that if the two land in that order, the outcome is the one tabulated below.
True collateral at the period start
1,000,000 fXRP
Cover committed against it (2.0x)
$2,000,000
totalAssetsAt(period start) after the withdrawal overwrites it
100,000
Collateral physically in the vault at payout
1,000,000 fXRP
Payable collateral the contract itself computes
1,000,000 fXRP
Claim filed
$2,000,000
Paid to the insured
100,000 fXRP
Shortfall against an honest cap
900,000 fXRP — 90%
Both transactions are asserted to share a block number, with the commitment at the lower index. The 90% figure is therefore not a bound derived from leverage; it is what the withdrawer's share of the vault allows, and a larger share pushes it higher.
Who loses: the insured, directly, in assets the vault holds and has already classified as payable. The incident closes at the short amount, and neither that incident nor any automatic flow revisits the unpaid balance.
To be precise about how far that goes, since it bears on severity: the shortfall is recoverable, but only by a fresh privileged action that nothing triggers automatically. A PAYOUT_ROLE holder can simply call payout again — the function carries only per-call limits (FirelightVault.sol:885) — or a curator can raise a second incident for the same real-world event. The contracts permit it — "incidentRef only prevents exact reference reuse and cannot detect different refs for the same real-world event" (IncidentManager.sol:181-184), and "cumulative loss across different incidents is not capped on-chain" (interfaces/IIncidentManager.sol:466-469). So the shortfall is not structurally irreversible. It is simply unpaid, silently, unless someone notices that the payout was capped below the collateral the vault was holding — and the insured have no on-chain means to compel a second incident.
Where the money goes is worth stating precisely, because it is not a clean transfer to the withdrawer. The unpaid balance stays in the vault and is shared pro-rata between the pending withdrawal buckets and the remaining shareholders. In test 1, of the 200,000 the insured do not receive, the withdrawer captures 40,000 and the other 160,000 stays as active vault value benefiting whoever still holds shares; in test 2 the split of the 100,000 shortfall is 90,000 to the withdrawer and 10,000 to the rest. The withdrawer's gain is thus a fraction of the harm, and the residual remains exposed to later events — but the loss to the insured is the full amount in every case.
How practical is landing in the boundary block
The attacker cannot force a block timestamp, so this is opportunistic rather than deterministic, and I would rather state that plainly than overclaim.
What is certain, from the code: period duration is a whole number of days (FirelightVaultStorage.sol:40, enforced at FirelightVault.sol:1085-1086), so upcoming boundaries are known exactly, absent a newly scheduled period configuration. And a miss is cheap and non-destructive: wrapping the call in a contract that reverts unless block.timestamp == vault.currentPeriodStart() costs the failed transaction's gas and a nonce, leaves the withdrawal position untouched, and can be retried at the next boundary, indefinitely. A failed attempt therefore costs gas and nothing else, which matters more for practicality than the per-attempt success rate.
What I have not measured, and therefore do not claim: the share of period boundaries that actually contain a block, or the inclusion probability for a given transaction. Flare's published material supports a block time of roughly 1.8 seconds, but a short average block time does not by itself establish that a transaction can obtain a chosen integer timestamp, and I am not asserting that it does. Test 3 additionally requires being ordered behind the allocator's commitment within that block — a back-running pattern, and not guaranteed. Landing a payout also requires an approved incident in that period large enough to bind the cap, which the withdrawer cannot force.
There is also a no-attacker version: an ordinary depositor whose routine withdrawal happens to land on a boundary silently reduces that period's payout capacity. The defect degrades the payout guarantee whether or not anyone is trying.
Why the listed out-of-scope items do not apply
The privileged-role exclusion does not reach this: withdraw and redeem need no protocol role — their gates are the pause, the blocklist, the reentrancy guard and ordinary amount and balance validation, all of which apply to every depositor — and notably neither consults _hasActiveIncident(), the guard that blocks deposits during an incident (FirelightVault.sol:660 versus :717-741 and :755-775). The curator, approver and payout roles follow the documented flow throughout.
The disclosure about aggregate claims not being capped on-chain concerns claims exceeding cover. This is the reverse — a single approved claim that does not exceed its cover (the PoC assesses each loss at exactly its allocated cover, never above it), which the vault declines to pay in full while holding the money. The staked-asset price disclosure does not apply either: no price moves, and the asset quantity is unchanged and present.
Suggested fix
Adopt strict-before-boundary semantics for the period-start snapshot, consistently in both readers, so that nothing happening inside a period can define that period's opening figure:
Both must change together. Fixing only payout leaves an exact-boundary deposit able to raise allocation capacity while being excluded from payout capacity, which is the same inconsistency pointed the other way.
Two things to avoid. Re-keying the withdrawal's own checkpoint to periodStart - 1 does not work: checkpoint keys must be non-decreasing, so the push reverts whenever something already wrote at periodStart, and a later same-timestamp write would reintroduce the net-of-withdrawal value anyway. And the boundary withdrawal must stay in the payable bucket — removing it there would let the assets escape slashing outright, which is exactly what the current bucketing exists to prevent.
Worth adding as regression tests: a boundary withdrawal, a boundary deposit, a boundary reward checkpoint, two checkpoints sharing the boundary timestamp, payout in both P and P+1, and multiple withdrawal beneficiaries in one bucket.
Proof of Concept
Proof of Concept
test/poc_boundary_withdraw_shrinks_payout_cap.js — a local Hardhat network against the production contracts, unmodified. Real FirelightVault, CoverOrderAllocator, IncidentManager and CoverNFT behind proxies; only the ERC20s and the price feed are mocks, both taken from the repository's own contracts/test/.
Steps to reproduce
Clone the audit repository and check out
v1_audit_ready(commit42f9ea5), thennpm install.Save the file below as
test/poc_boundary_withdraw_shrinks_payout_cap.js. Change nothing else — no contract is modified.Run:
Three tests pass. Run the full suite (
npx hardhat test) to confirm the repository's own 454 tests still pass with the file added.
What each test does
Test 1 and Test 2 run an attack and a control through the same helper, parameterised only by how many seconds after the period boundary the redemption lands — 0 for the attack, 1 for the control. Scenario inputs and call sequence are otherwise identical, each on a freshly deployed stack. time.setNextBlockTimestamp places the redemption. The sequence in both: two stakers deposit, cover orders are created for the next period, the boundary is crossed with the redemption inside or just after it, cover is committed and settled, an incident is opened against the settled cover, the approver approves it, and the payout is measured at the receiver. The withdrawer then claims her bucket two periods later.
Test 3 disables automining and places three transactions in one block stamped exactly at the boundary, ordered by priority fee: an oracle refresh, then the allocator's commitment, then the redemption. It asserts that the commitment and the redemption share a block number with the commitment at the lower transaction index, so the ordering the finding depends on is proven rather than assumed.
What is asserted
Every figure in the tables above is asserted rather than printed: the period-start snapshots, the assets physically held by the vault at payout time, the still-payable withdrawal bucket, the contract's own payable sum, the committed and settled cover read back from the allocation commitment, the requested and paid amounts read from the vault's PayoutExecuted event, and the amounts the withdrawer later claims. Where a run ends with the withdrawer fully slashed, the test asserts that specific NoWithdrawalAmount revert rather than treating any failure as a zero payout.
Full test file
Was this helpful?