Firelight's withdrawal guide says pending capital continues backing cover for the rest of the period in which unstaking begins: “the pending withdrawal takes the same pro-rata slash as active positions.” Redemption only “removes the position from the next period boundary's coverage-capacity calculation.”
At T = periodStart(P), an ordinary withdrawal executes while currentPeriod() == P and is assigned to the P+1 pending bucket, so it remains exposed to period-P incidents. The same transaction records the post-withdraw active balance at T, and Firelight's inclusive historical lookup uses that lower value as period P's payout cap. Because the period-P book was created during P-1 and still fits within the lower matching capacity, it can be committed in a later block and settle unchanged. The differential PoC settles 200 units of cover in both branches, but the boundary withdrawal erases the 100-unit vault payout leg. The incident closes after paying only 100, and the staker later claims the 100 that should have funded the loss.
Vulnerability Details
1. Exact T records contradictory exposure
createCoverOrder targets currentPeriod() + 1, so every order eligible for period P was created during P-1:
At exact T = periodStart(P), currentPeriod() == P. _requestWithdraw therefore puts the withdrawal in the P+1 bucket and the pending pool:
The assets remain physically held by the vault, but withdraw records the reduced active balance under timestamp T:
Trace256.push replaces the latest value when its key is also T, and upperLookupRecent(T) returns the latest key <= T. Consequently, totalAssetsAt(T) returns the post-withdraw balance S-W, not the S capital carried into P.
For a period-P incident, payout counts the P+1 bucket as payable, then caps that payment by the reduced checkpoint:
The bucket says W remains exposed to period P, while the inclusive start cap excludes that same W. This contradicts both the source comment (“remain payable while payout is allowed”) and Firelight's published withdrawal rule.
2. The same book settles through ordinary later matching
The PoC creates a 200-unit period-P order during P-1. At T, the attack branch contains only the public 100-unit withdrawal and pins that state; the control mines an empty boundary block and pins its intact 100 active units. Both allocators then commit normally at T+1, with no allocator transaction in the boundary block.
The funded 100-unit FLB makes attack capacity 2 × (100 FLB + 0 active) = 200; control capacity is 2 × (100 FLB + 100 active) = 400. The pre-existing book fits both states, both roots allocate the same 200 cover, and both branches settle it at T+2.
The control requests the same withdrawal only after settlement, at T+3. Its commitment therefore used contemporaneous active capital, while both incidents still see 100 in the period-P payable bucket. This removes both a same-block allocator premise and the separate stale-capacity issue.
3. A valid, fully collateralized incident loses the vault leg
Settlement transfers the premium and mints the production CoverNFT. After settlement and withdrawal, the real IncidentManager confirms that the 200-unit loss is within allocated cover, checks that the order and incident periods match, and executes the production waterfall.
The loss is backed by exactly 100 funded and approved USDC in the FLB plus 100 FXRP physically held by the vault. The FLB pays 100 and exhausts its balance and allowance in both branches; the vault must pay the remaining 100:
Control:totalAssetsAt(T) = 100; the vault pays 100; total payout is 200.
Attack:totalAssetsAt(T) = 0 even though withdrawAssets[P+1] = 100 and the vault physically holds 100; the vault pays zero; total payout is only 100.
The approved incident is then marked CLOSED, with no second path for the missing 100. At maturity the attacker calls claimWithdraw(P+1) and receives all 100 FXRP.
4. Pinning the boundary state does not cure the contradiction
The first block at or after T may define matching capacity, but it cannot redefine this withdrawal as pre-P: it executed while currentPeriod() == P, was booked to P+1, remains held, and under Firelight's rules stays slashable through P before leaving capacity at P+1.
Using that transaction as pre-P for totalAssetsAt(T) but in-P for payout exposure and maturity is the bug. The control settles identical cover and pays the full 200, proving the differential is not intentional leverage, an invalid claim, or missing FLB funds or allowance.
Impact Details
The differential test produces a 100-unit incremental unpaid claim with no difference in settled cover, assessed loss, price, FLB funding, allowance, physical vault assets, or trusted-role behavior. Those 100 units are returned to the attacker after the incident closes.
More generally, let S be the slashable vault pool immediately before the boundary and W the exact-boundary withdrawal. For a vault-leg request of at least S, the intact control pays S, while the corrupted branch can pay only S-W; claimant underpayment is therefore W, provided the pre-existing book still fits within the post-withdraw matching and concentration limits.
For partial withdrawals, payout allocation is still pro-rata. A fully exiting attacker who owns W of a pool S retains approximately W²/S; the remainder of the missing W accrues to active stakers. If the withdrawing actor owns the entire pool, as in the maximal PoC, it ultimately retains all W. This distinction affects who captures the diverted value, not the cover claimant's W underpayment.
The incident is irrevocably closed after the partial payment. This is the in-scope Critical impact Protocol insolvency: valid, settled, fully collateralized cover is not honored because slashable assets are released to the withdrawing staker instead.
The live Phase-1 vault currently uses one-day periods. A read-only sample of periods 230 through 246 found a block at the exact mathematical boundary timestamp in 16 of 17 periods. The attack does not depend on consecutive blocks sharing a timestamp: the withdrawal must land at T, while the allocator is expected to observe that block and commit normally at T+1 or later.
The standalone test deploys the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager, plus separate six-decimal FXRP and USDC mocks and a fresh $1 price feed. It then performs these steps in clean A/B branches:
1
Deposit and fund the first-loss wallet
Deposit 100 FXRP and fund the first-loss wallet with 100 USDC plus an exact 100-USDC allowance to IncidentManager.
2
Create the period-P order
During P-1, create a 200-unit order for period P from a buyer separate from the withdrawing staker.
3
Pin the boundary state
Attack branch: withdraw 100 in the first period-P block at exact T. Control: mine an empty first period-P block at T, leaving all 100 vault units active.
4
Commit allocation
In both branches, commit normally at T+1 from the exact pinned/live state, using capacities 200 and 400 respectively.
5
Settle the identical order
Settle the identical 200-unit order in both branches at T+2, transfer the premium, and mint the CoverNFT.
6
Request the control withdrawal and capture the incident
In the control only, request the 100-unit withdrawal after settlement at T+3. Then capture the incident strictly after settlement and withdrawal (T+3 attack, T+4 control), and create, confirm, assess, submit, and approve a loss equal to the allocated 200 cover.
7
Verify the payout differential
Verify the FLB pays 100 in both branches. The control vault pays the remaining 100; the attacked vault pays zero and closes the incident.
8
Claim the escaped withdrawal
Advance through maturity and verify the attacker directly claims the 100 that escaped the incident.
9
Assert the result
Assert identical settled cover and a 100-unit differential in payout.