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

90080 sc high period start redemption appends a deflated exposure checkpoint and underpays valid cover claims

#90080 [SC-High] Period-start redemption appends a deflated exposure checkpoint and underpays valid cover claims

Submitted on Aug 22nd 2026 at 21:39:52 UTC by @v_c0d35 for Audit Comp | Firelight

  • Report ID: #90080

  • 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

Description

A period-start redemption lowers the incident payout cap while the redeemed principal remains exposed

Brief/Intro

At the exact start of a vault period, an unprivileged staker can redeem shares and make FirelightVault record the post-redemption active balance as that period's historical payout cap, even though the redeemed principal remains in the withdrawal bucket that is exposed to incidents from the same period. Cover can then be committed and settled honestly within capacity recomputed from the reduced balance, but a later valid incident is paid only up to the reduced, unleveraged checkpoint. The incident closes after the truncated payment, leaving the payout receiver short and allowing the redeeming staker to retain more principal than the same redemption performed one second later.

Vulnerability Details

This report assesses the public v1_audit_ready source at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62.

The vulnerability comes from two incompatible classifications of a redemption executed at capturePeriodStart:

  1. currentPeriod() already returns the new capture period at that timestamp. _requestWithdraw consequently assigns the redeemed assets to currentPeriod() + 1, and payout expressly counts that bucket as exposed to incidents captured in the current period.

  2. The same redemption records the post-redemption active balance in _traceTotalAssets at the exact capture-period-start key. totalAssetsAt(capturePeriodStart) performs an inclusive lookup, so this lower balance becomes the payout cap for the whole period.

The public redeem path first creates the withdrawal request and then records the reduced active-asset total (FirelightVault.sol, lines 717–743):

At the boundary, _requestWithdraw uses the already-incremented currentPeriod() and places those assets in the next withdrawal bucket (FirelightVault.sol, lines 994–1027):

_logTrace stores the post-redemption active balance under block.timestamp (FirelightVault.sol, lines 1044–1055). When the timestamp equals the new period's start and no checkpoint already exists at that key, Checkpoints.push appends a new checkpoint. The later lookup returns the most recent key lower than or equal to the requested key (Checkpoints.sol, lines 193–223). The boundary checkpoint is therefore included in totalAssetsAt(capturePeriodStart).

The payout function uses this reduced snapshot as an absolute cap, while separately adding the redemption bucket back into the amount that remains payable (FirelightVault.sol, lines 900–927):

This creates the contradiction: the redeemed assets are present in activePayableAmount, but the cap against that amount was recorded after excluding them.

The allocator does not prevent the resulting shortfall. It calculates cover capacity by multiplying the period-start collateral value by effectiveLeverage / minCAR (CoverOrderAllocator.sol, lines 689–724). The repository's standard fixture sets minCAR = 12,000 and effectiveLeverage = 24,000, producing 2x capacity (test/setup/fixtures.js, lines 164–170). Capacity therefore remains sufficient to sell cover whose legitimate vault payout exceeds the unleveraged, deflated snapshot.

The end-to-end reproduction uses the following sequence:

  1. An honest staker deposits 60,000 six-decimal FXRP and the attacker deposits 40,000 FXRP.

  2. A curator creates a 200,000-USD cover order through the normal lifecycle.

  3. At the exact start of the target period, the attacker redeems all shares. This appends a 60,000-FXRP checkpoint and assigns the attacker's 40,000 FXRP to withdrawAssets[capturePeriod + 1].

  4. One hour later, the allocator honestly recomputes capacity. At the test price of 2 USD per FXRP, the remaining stake alone provides 120,000 USD of collateral and a conservative 240,000-USD capacity lower bound at the repository's 2x ratio. Including the funded 20,000-USDC buffer, the allocator's formula produces 280,000 USD of capacity, so the 200,000-USD order is valid.

  5. The allocator commits and settles the order after the redemption.

  6. Honest incident roles create, assess, and approve a covered 200,000-USD loss. The funded first-loss buffer pays 20,000 USDC, leaving a valid 90,000-FXRP vault leg.

  7. FirelightVault.payout has 100,000 FXRP available across active assets and the exposed withdrawal bucket, but caps payment at the attacker's 60,000-FXRP boundary checkpoint.

  8. IncidentManager.approveCurrentAssessment records the amount returned by the vault and then sets the incident to CLOSED (IncidentManager.sol, lines 403–438). The unpaid 30,000 FXRP is not recorded as a remaining balance on that incident.

The reproduction compares this execution with an otherwise identical control in which the attacker redeems one second after the boundary. It also asserts that the latest checkpoint key is earlier than capturePeriodStart before the redemption, that the redemption increases the checkpoint-array length, and that the newly appended key equals capturePeriodStart. The vulnerable execution therefore follows the append branch in Checkpoints.push.

The boundary is published in advance by currentPeriodEnd(). Exact equality also occurs naturally on Flare: for example, blocks 67921346, 67843343, and 67764965 have timestamps 1787319769, 1787233369, and 1787146969, respectively, matching consecutive calculated boundaries of the deployed vault's daily Phase-1 period configuration. The same timestamp comparison governs a longer configured period.

Impact Details

The passing end-to-end test demonstrates the following amounts:

Measurement
One-second-later control
Exact-boundary redemption

Period-start payout cap

100,000 FXRP

60,000 FXRP

Vault-stake-only capacity lower bound

400,000 USD

240,000 USD

Allocator capacity including the funded buffer

440,000 USD

280,000 USD

Settled cover

200,000 USD

200,000 USD

First-loss-buffer payment

20,000 USDC

20,000 USDC

Valid vault leg

90,000 FXRP

90,000 FXRP

Vault payment

90,000 FXRP

60,000 FXRP

Redeeming staker's eventual withdrawal

4,000 FXRP

16,000 FXRP

At the test's 2-USD FXRP price, the payout receiver receives 60,000 USD less than the approved loss requires. The attacker retains an additional 12,000 FXRP, worth 24,000 USD at that price, compared with the one-second-later control. The remaining 18,000 FXRP of the shortfall stays with the active stake. Together, the value retained by the stakers exactly equals the 30,000-FXRP amount omitted from the vault payment.

The loss is created after the order has been committed and settled inside capacity recomputed from the already-reduced period-start checkpoint. The only attacker-controlled action is the timing of the public redemption; order creation, matching, settlement, incident creation, assessment, and approval all follow their normal authorized paths. Because the incident is marked closed immediately after the capped transfer, its recorded lifecycle completes while the payout receiver has received less than the approved and covered amount.

The affected amount scales with the difference between the valid vault leg and the attacker-deflated period-start cap. A larger pre-boundary redemption can lower that cap further while the same redeemed principal remains exposed in the payable withdrawal bucket, limited by the cover that still fits within the protocol's leveraged capacity.

References

Proof of Concept

End-to-end reproduction: period-start redemption deflates the payout cap

This guide reproduces the issue against the public v1_audit_ready repository at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62. It deploys the actual FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager implementations locally and drives their complete order, settlement, incident, assessment, payout, and withdrawal lifecycle.

What the test proves

The test executes two otherwise identical timelines:

  1. Control: the staker redeems one second after the capture period begins.

  2. Boundary case: the staker redeems at exactly capturePeriodStart.

The boundary case proves all of the following:

  • there is no checkpoint at capturePeriodStart before the redemption;

  • the redemption appends a new checkpoint instead of replacing an existing same-key checkpoint;

  • the redeemed principal remains in withdrawAssets[capturePeriod + 1] and is therefore still payable for the capture period;

  • the honest allocator commits and settles only after the redemption and recomputes capacity from the reduced snapshot;

  • the same cover remains within that reduced capacity using the repository's standard 2x leverage configuration;

  • a funded and approved first-loss buffer pays the same amount in both timelines;

  • the approved vault leg is 90,000 FXRP in both timelines;

  • the control pays the complete 90,000-FXRP vault leg;

  • the boundary case pays only 60,000 FXRP and then closes the incident; and

  • the boundary redemption lets the staker retain 12,000 FXRP more than the control.

Prerequisites

Install the following tools:

  • Git

  • Node.js and npm

  • Foundry (forge)

The commands below assume a Unix-like shell.

1. Clone the public repository at the assessed commit

Confirm the revision:

Expected result:

2. Install the repository and Foundry dependencies

From the repository root, run:

3. Add the Foundry configuration

Save the following complete file as foundry.toml in the repository root:

4. Add the end-to-end test

Save the following complete file as test/PeriodStartPayoutCap.t.sol:

5. Build the public repository

Run:

The build must finish with:

Foundry may also print lint notes from the repository. They do not affect compilation or the test.

6. Run the end-to-end reproduction

Run:

The relevant output is:

FXRP has six decimals in this reproduction. The output therefore shows:

  • 100,000 FXRP control snapshot versus a 60,000 FXRP boundary snapshot;

  • 400,000 USD control capacity versus 240,000 USD capacity after the boundary redemption;

  • the same 200,000 USD cover fits within both capacities;

  • the funded buffer pays 20,000 USDC in both executions;

  • the resulting vault leg is 90,000 FXRP in both executions;

  • the control pays all 90,000 FXRP;

  • the exact-boundary execution pays only 60,000 FXRP;

  • the approved incident closes with a 30,000-FXRP shortfall; and

  • the redeeming staker receives 16,000 FXRP instead of 4,000 FXRP, an additional 12,000 FXRP.

The checkpoint assertions are part of the passing test. If the boundary write were a same-key replacement, the assertions requiring no existing boundary checkpoint and a one-element increase in checkpoint length would fail.

Was this helpful?