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:
currentPeriod()already returns the new capture period at that timestamp._requestWithdrawconsequently assigns the redeemed assets tocurrentPeriod() + 1, andpayoutexpressly counts that bucket as exposed to incidents captured in the current period.The same redemption records the post-redemption active balance in
_traceTotalAssetsat 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:
An honest staker deposits 60,000 six-decimal FXRP and the attacker deposits 40,000 FXRP.
A curator creates a 200,000-USD cover order through the normal lifecycle.
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].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.
The allocator commits and settles the order after the redemption.
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.
FirelightVault.payouthas 100,000 FXRP available across active assets and the exposed withdrawal bucket, but caps payment at the attacker's 60,000-FXRP boundary checkpoint.IncidentManager.approveCurrentAssessmentrecords the amount returned by the vault and then sets the incident toCLOSED(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:
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:
Control: the staker redeems one second after the capture period begins.
Boundary case: the staker redeems at exactly
capturePeriodStart.
The boundary case proves all of the following:
there is no checkpoint at
capturePeriodStartbefore 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?