89976 sc high period boundary checkpoint overwrite in firelightvault lets an unprivileged staker suppress approved incident payouts and deflate underwriting capacity
Submitted on Aug 22nd 2026 at 08:37:32 UTC by @Emmy for Audit Comp | Firelight
Report ID: #89976
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:
Temporary freezing of funds for at least 24 hours
Description
Brief/Intro
FirelightVault.payout() caps every incident claim at totalAssetsAt(capturePeriodStart), and CoverOrderAllocator reads that same historical figure when computing capital adequacy and available capacity. The value is served by a checkpoint library whose push overwrites the stored entry whenever the incoming key equals the latest key, and the vault writes a checkpoint at Time.timestamp() on every ordinary deposit, withdraw, and redeem. Whichever unprivileged account transacts in the block whose timestamp equals the period start therefore decides what totalAssetsAt(periodStart) returns for that entire period. A staker who redeems in that block drives the recorded figure down while the redeemed assets remain classified as payable, and because the payout cap is a min, an approved claim is silently paid at the deflated figure. On mainnet this means a cover holder with a fully approved claim receives materially less than the protocol is holding for them, the exiting staker keeps collateral that should have been slashed to fund that claim, and the protocol simultaneously under-reports the capital it will underwrite against for that period.
Vulnerability Details
Background: how the exposure cap is supposed to work
Firelight prices incident exposure against the collateral that backed the vault when the covered period opened. payout() reads that opening figure through totalAssetsAt(capturePeriodStart) and treats it as a ceiling on what any claim for that capture period may draw. Withdrawals requested during the capture period are deliberately kept payable so that a staker cannot dodge a loss by queueing an exit, a protection the contract states explicitly in its own comments.
That design depends on one property: the opening figure for a period must be fixed once the period begins. It is not.
The checkpoint primitive overwrites on a key collision
Checkpoints.Trace256 stores values in a side array and keys in an OpenZeppelin trace. When the incoming key matches the latest key it replaces the stored value in place rather than appending (contracts/core/lib/Checkpoints.sol:198-213):
totalAssetsAt(t) resolves through upperLookupRecent(t), which returns the value of the latest checkpoint whose key is at most t. A checkpoint carrying key exactly t is therefore the entry that answers the query, whether it was freshly appended or overwrote an earlier same-second entry. Both branches of push produce the same outcome for a query at the boundary second, which is why removing only the overwrite branch would not fix this.
Every ordinary user action writes that checkpoint
_logTrace records the post-operation total at the current block timestamp (contracts/core/FirelightVault.sol:1044-1055):
It is reached from the withdraw and redeem paths at :741 and :775, and from the deposit path at :991. None of those paths is role gated. checkpointTotalAssets() at :524-526 writes the same trace with the same key, so an operator snapshot and an ordinary user action collide on equal terms and the later transaction in the block prevails.
The corrupted value is a hard ceiling on claims
payout() computes (contracts/core/FirelightVault.sol:911-936):
Two quantities are computed and the smaller governs. activePayableAmount counts the queued withdrawal bucket precisely so that, in the contract's own words, "exposed assets could escape by withdrawing first" cannot happen. assetsAtCapturePeriod is supposed to be the independent exposure ceiling. Once a boundary write deflates assetsAtCapturePeriod, the min binds on the deflated figure and the payout collapses regardless of activePayableAmount, even though activePayableAmount still contains the very assets that were withdrawn.
The two figures end up contradicting one another. The protocol asserts, in the same function call, that those assets are payable for this capture period and that they do not count toward what may be paid. The pessimistic assertion wins and the claimant absorbs the difference.
The same value is a capacity input
CoverOrderAllocator reads the identical historical figure when sizing available capacity (contracts/core/CoverOrderAllocator.sol:710-714):
A single unprivileged boundary transaction therefore moves the capital adequacy ratio and the amount of cover the protocol will underwrite for that period, in addition to capping claims.
Reaching the boundary second
Period boundaries are deterministic and publicly readable through currentPeriodEnd(), so the target second is known in advance to anyone.
The corruption occurs in two ways. It occurs accidentally whenever any staker's ordinary deposit, withdraw, or redeem happens to be mined in the block whose timestamp equals the period start. No attacker is involved and the protocol receives no signal that the period's exposure cap has been rewritten.
It occurs deliberately when a staker aims an exit at that second. Landing in the boundary block is probabilistic per attempt, because not every second carries a block, but the attempt costs only gas, the boundary is publicly computable, and it can be retried at every period.
A third route is deterministic rather than probabilistic, and its availability depends on operational practice rather than on code. If the protocol calls checkpointTotalAssets() at a period boundary, a staker who back-runs that call in the same block overwrites the official snapshot through the key == lastKey branch with no dependence on timestamp luck. The repository does not schedule such a call: checkpointTotalAssets() is invoked only from VaultRewardDistributor at :150 and :174 after forwarding rewards. That route is therefore noted rather than relied upon, and the project should confirm whether it performs period-start checkpointing operationally. The accidental and probabilistic routes stand on their own.
Proof that the boundary second is the cause
The strongest objection to this finding is that the redeem simply reduced the assets backing the period, making the lower cap correct. The proof of concept refutes that directly by running the same staker performing the same redeem one second later:
One second later the anti-escape mechanism behaves exactly as documented: the queued assets are slashed and the claim pays in full. The economic action is identical in both runs. Only the second in which it lands differs, and that alone moves the claim by 500,000 fXRP.
The shortfall is silent
payout() does not revert when it under-pays. It returns a smaller number. IncidentManager._executePayout passes that return value to IncidentManager.sol:432-434:
vaultPaidAmount is recorded and the incident is closed without ever being compared against vaultRequestedAmount. There is no shortfall check, no retry, and no revert anywhere on that path. The claim closes short and nothing on chain flags it.
Impact Details
The demonstrated impact is incorrect payout accounting producing a quantifiable loss to a cover holder, together with an error in the capacity input the allocator underwrites against.
A fully approved claim is under-paid. With a 2,000,000 fXRP vault and an approved claim of 1,500,000 fXRP, the honest payout delivers the full 1,500,000 fXRP when no boundary write occurs and only 1,000,000 fXRP when one does. 500,000 fXRP of an approved claim is suppressed, which is 3,333 basis points of the claim and 2,500 basis points of vault TVL. The loss falls on the cover holder and the configured payout receiver.
Part of the shortfall is captured by the unprivileged actor. Measured against the same staker performing the same redeem one second later, the boundary timing leaves them holding 500,000 fXRP instead of 250,000 fXRP, a gain of 250,000 fXRP. The remaining 250,000 fXRP stays in the vault and accrues to the other stakers. So the claimant's 500,000 fXRP loss is not destroyed value, it is transferred, and half of it is captured directly by the actor who caused it.
The suppression scales with the actor's stake. The deflation equals whatever the staker removes in the boundary block. A staker holding a larger share of the vault suppresses proportionally more, and a sole staker redeeming in full drives totalAssetsAt(periodStart) to zero and the claim to zero.
It is a dominant strategy for anyone exiting. The staker does not need to predict that an incident will be captured in that period. Aiming an exit at the boundary second costs only gas, applies to withdrawals they were making anyway, and strictly improves their own outcome: if no incident follows, nothing is lost, and if one does, their own slashing is halved. Every ordinary withdrawal therefore carries a standing incentive to corrupt that period's exposure cap.
It recurs every period. Across three consecutive boundaries the proof of concept accumulates 1,500,000 fXRP of suppressed claims, at 500,000 fXRP per period. The window reopens at every period boundary for the life of the protocol.
Underwriting capacity is deflated by the same write. totalAssetsAt(currentPeriodStart) falls from 2,000,000 fXRP to 1,000,000 fXRP after one unprivileged redeem, halving the figure CoverOrderAllocator.sol:711 consumes for capital adequacy and capacity. This direction is conservative in solvency terms, since the protocol under-sells rather than over-sells cover, so the consequence is forgone underwriting rather than insolvency.
On recoverability. The shortfall is not permanent at the vault level. A PAYOUT_ROLE holder who notices the short return value can call vault.payout() again and top the claimant up, and the proof of concept demonstrates that. This is stated plainly because it bounds the severity. What makes it material anyway is that nothing on chain surfaces the shortfall for anyone to act on: payout() does not revert, IncidentManager closes the incident without comparing paid against requested, and the closed incident cannot be re-approved. The correction is therefore an out-of-band call sitting outside the incident accounting, and that same route is uncapped, over-delivering the notional when repeated. The remedy depends entirely on off-chain vigilance rather than on any protocol mechanism.
References
contracts/core/lib/Checkpoints.sol:198-213,pushand itskey == lastKeyoverwrite branchcontracts/core/FirelightVault.sol:1044-1055,_logTracewriting the trace atTime.timestamp()contracts/core/FirelightVault.sol:741,:775,:991, the unprivileged call sites that write itcontracts/core/FirelightVault.sol:524-526,checkpointTotalAssetscontracts/core/FirelightVault.sol:911-936, thepayout()cap and its anti-escape commentcontracts/core/FirelightVault.sol:456-458,totalAssetsAtcontracts/core/IncidentManager.sol:432-434, the incident closing without comparing paid to requestedcontracts/core/IncidentManager.sol:684,_executePayoutconsuming the vault's return valuecontracts/core/CoverOrderAllocator.sol:710-714, the capacity and capital adequacy readcontracts/core/VaultRewardDistributor.sol:150,:174, the only scheduledcheckpointTotalAssetscallsIn-scope commit:
42f9ea5e43d88b35197b901acc2a8c24b314fa62, branchv1_audit_ready
Proof of Concept
Proof of Concept
The test exercises the real FirelightVault implementation behind a real ERC1967Proxy. No Firelight logic is mocked, and the only substituted contract is the external ERC-20 collateral, a plain six-decimal OpenZeppelin ERC20 standing in for FXRP. There is no vm.store anywhere, so every value is produced by real calls. PAYOUT_ROLE, CHECKPOINT_ROLE, and the admin appear only in setup performing their honest, documented duties, and the actor who triggers the defect is an ordinary staker holding no role.
The suite contains six tests: the main proof, the one-second boundary isolation control, a repeatability run across three boundaries, the shortfall-handling test, the capacity-input test, and a negative control that runs the identical sequence against the in-scope contract with the defect repaired.
Setup
The repository ships with Hardhat only, so the following adds a self-contained Foundry harness. src is scoped to contracts/core and libs deliberately avoids the repository's existing ./lib directory, which holds the project's JavaScript helpers. The Hardhat build is unaffected.
The generated control differs from the in-scope contract by exactly one line:
Test file
Save as repo/test-foundry/CheckpointBoundarySuppression.t.sol:
Command
Output
Reading the output
Baseline. Two stakers deposit 1,000,000 fXRP each. At the exact second period 1 opens, the test asserts totalAssetsAt(periodStart) == 2,000,000 and a CHECKPOINT_ROLE holder takes an honest period-start snapshot recording the same figure.
Exploit. One unprivileged staker calls redeem() in that block. _logTrace pushes at Time.timestamp() == periodStart, replacing the official snapshot with the post-redeem 1,000,000.
Proof. withdrawAssets[capture+1] still reports 1,000,000 as payable, so the payable pool on its own would have covered the claim, yet the min against the deflated snapshot caps the honest payout at 1,000,000. The claimant receives 1,000,000 of an approved 1,500,000.
Boundary isolation. The same staker performing the same redeem one second later leaves the cap at 2,000,000, pays the claim in full at 1,500,000, and correctly slashes the exiter down to 250,000. The 250,000 difference in the exiter's own outcome between the two runs is the gain the boundary timing buys them. Note that the "exiter kept, control" figure of 0 in the main test reflects a run in which the staker never redeemed at all and so has no withdrawal bucket to claim; the meaningful attacker-gain comparison is the boundary run against the one-second-later run.
Shortfall handling. payout() returns 1,000,000 against a 1,500,000 request without reverting. A manual re-call delivers the missing 500,000, and a further re-call delivers another 250,000, taking total delivery to 1,750,000 against a 1,500,000 claim. This establishes both that recovery is possible out of band and that the recovery route is uncapped rather than a controlled correction.
Negative control. Against FirelightVaultBoundaryFixed, which differs from the in-scope contract by exactly one line, the identical boundary redeem leaves the claim paid in full at 1,500,000. Applying that change to the in-scope contract flips the main test's assertions to failing, which confirms the test targets the defect itself rather than incidental behaviour.
Was this helpful?