90131 sc high boundary time checkpoint rewrite lets settled cover collateral escape payouts causing insolvency
#90131 [SC-High] Boundary-time checkpoint rewrite lets settled-cover collateral escape payouts, causing insolvency
Submitted on Aug 23rd 2026 at 06:54:07 UTC by @crypt0n1337 for Audit Comp | Firelight
Report ID: #90131
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
Bug Description
Brief/Intro
The allocator sizes and commits cover using vault.totalAssetsAt(vault.currentPeriodStart()). The vault later computes the maximum payout for that same cover period by querying the same historical timestamp again.
That timestamp is not immutable during the boundary block. If the allocator commits and settles cover in a block whose timestamp is exactly the new period start, a normal staker can place withdraw() later in that same block. withdraw() moves the assets into the delayed-withdrawal bucket and writes the reduced active-asset value into _traceTotalAssets under the same timestamp as the period start. From then on, totalAssetsAt(periodStart) returns the post-withdrawal value even though the allocation was already committed and settled using the pre-withdrawal value.
The delayed withdrawal remains physically in the vault and is explicitly intended to remain slashable during the payout window. Nevertheless, payout() first caps the payment by the rewritten assetsAtCapturePeriod. If that value was rewritten to zero, the vault pays zero and does not reduce the pending withdrawal. The incident is still closed, and the staker later claims the full amount.
The attached local PoC executes this through the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. It uses a normally funded and approved 25 USDC first-loss buffer against a 100 USD valid assessment. In the attack case the first-loss buffer pays 25, the vault incorrectly pays zero, and the staker later claims all 200 FXRP units. In the periodStart + 1 second negative control, the historical snapshot remains 200, the first-loss buffer pays 25, the vault correctly pays the remaining 75 from the pending withdrawal, and the staker can claim only 125.
Affected frozen code
Root cause
There are two incompatible assumptions about the timestamp checkpoint:
commitAllocation()treatstotalAssetsAt(currentPeriodStart())as a fixed underwriting snapshot.Vault accounting treats timestamp checkpoints as the state after the last vault mutation carrying that timestamp.
At the exact period boundary these assumptions conflict. The allocator can consume a value early in the block, while a later vault action in the same block can change what every future lookup for that timestamp returns.
The commitment itself does not create a vault checkpoint, and the exploit does not assume that it does. In the demonstrated state, the withdrawal inserts the first checkpoint whose key is the boundary timestamp. Before insertion, an upper lookup at that key returns the earlier 200-unit checkpoint; afterward, it returns the newly inserted zero value. If another vault mutation has already created a checkpoint with the boundary key, Trace256.push() instead replaces the equal-key value. Both cases mutate the observable period-start snapshot after it has been consumed.
The relevant write path is:
The allocator separately reads:
The payout path later re-derives the exposure cap instead of using an immutable value captured by the commitment:
Although activePayableAmount includes withdrawAssets[capturePeriod + 1], that protection is ineffective when the earlier assetsAtCapturePeriod cap has been rewritten to zero.
End-to-end trigger flow
The PoC uses the following production-path state and actions:
An ordinary staker deposits 200 units of the real fixture FAsset before period 1.
A normal curator creates a 100 USD cover order for period 1. The buyer funds and approves the premium.
A normal first-loss-buffer wallet holds 25 USDC and gives
IncidentManagerthe required allowance.In the first eligible period-1 block, whose timestamp is exactly
currentPeriodStart():transaction index 0: the allocator commits a genuine Merkle allocation after observing 200 vault assets at the period start;
transaction index 1: with the contract's default zero settlement grace, the allocator settles that order, charges the premium, and mints the
CoverNFT;transaction index 2: the unprivileged staker requests withdrawal of the 200 vault assets.
The withdrawal burns the staker's shares, creates
withdrawAssets[2] = 200, and writes active assets of zero at the exact period-start timestamp.The commitment remains stored with the full 225 USD live matching capacity (200 vault assets plus the funded 25 USDC buffer) and 100 USD settled cover. Because settlement has started, both cancellation and recommitment are blocked by
SettlementsAlreadyStarted.A covered loss is captured in a later block, after the receipt exists and after the withdrawal request. The normal incident roles confirm it and assess exactly the settled 100 USD allocation - no duplicate or excessive loss is used.
The first-loss buffer correctly transfers 25 USDC. The vault is requested to pay the 75-unit remainder.
FirelightVault.payout()sees 200 units in the pending withdrawal bucket but re-readstotalAssetsAt(periodStart) == 0, sopaidAmountis zero.IncidentManagerrecordsvaultPaidAmount == 0and closes the incident.In period 3, the staker claims the entire 200-unit withdrawal. The covered payout receiver received only 25 of the valid 100-unit assessment.
Exact demonstrated state transitions
Value
Boundary-block attack
boundary + 1 negative control
Staked vault assets before period 1
200
200
Valid settled cover / assessment
100
100
Funded and approved first-loss buffer
25
25
totalAssetsAt(periodStart) observed by commitment
200
200
totalAssetsAt(periodStart) at payout
0
200
First-loss-buffer payout
25
25
Vault payout
0
75
Pending withdrawal after payout
200
125
Staker's eventual claim
200
125
Valid assessment shortfall
75
0
One standalone PoC run produced:
Timestamps vary between local runs, but the equality/offset and all balance deltas are asserted.
Why the incident/deposit gate does not prevent this
_hasActiveIncident() is checked by deposit and mint paths. withdraw() is deliberately not gated, because delayed withdrawals are expected to remain part of activePayableAmount and be slashed while still in the payout window.
The PoC does not rely on racing an already active incident. It settles the cover, executes the withdrawal, and only then creates a genuine incident with a later capture timestamp. Consequently:
no incident exists when the user withdraws;
deposit gating is irrelevant;
the cover receipt already exists at capture;
the assessed order period equals the incident period; and
the pending withdrawal should remain exposed under the payout comments and formula.
Even adding an active-incident check only to withdraw() would not fully fix this ordering, because incidents can legitimately be reported after the withdrawal with a capture timestamp inside the still-covered period.
Privileged actors act normally
The exploit-triggering staker has no allocator, curator, incident, payout, or vault-admin role. The PoC explicitly verifies this.
The role holders perform only intended production operations:
the order curator authors a normal buyer-funded order;
the allocator commits no more than live capacity;
the allocator settles the exact committed leaf and charges the correct premium;
the incident curator reports a later covered loss;
the assessment is exactly the settled allocation, with one order and one market; and
the approver approves that valid loss.
No role fabricates a claim, overstates a loss, repeats an assessment, misconfigures an oracle, drains the first-loss buffer, or acts outside its documented permissions. The protocol is expected to commit and settle orders and to approve genuine claims; requiring those ordinary actions is not malicious-role cooperation.
Exact-boundary feasibility and limitation
This finding has a narrow but concrete ordering precondition for the fully finalized path demonstrated by the PoC:
the commitment, at least one genuine settlement, and the withdrawal must execute in a block whose timestamp equals
currentPeriodStart(); andtheir order must be
commitAllocation()->settleCoverOrder()->withdraw().
If the commitment is mined at periodStart + 1 or later, the withdrawal checkpoint has a later timestamp and cannot change the start lookup. The negative control proves this.
The condition is nevertheless reachable in the intended deployment:
period boundaries are deterministic integer timestamps;
commitAllocation()cannot be called for period N before period N begins, so the boundary block is the first eligible block;the off-chain matcher can compute the allocation from the pending next-period order book before that boundary and submit at the first eligible block;
the contract intentionally supports its default zero-grace commit-and-settle path; and
Flare transaction ordering is proposer-controlled with a default priority-gas auction, so a staker can watch or anticipate the operator transactions and submit a lower-priority withdrawal to execute after them.
As passive feasibility evidence only, the deployed Phase-1 vault currently uses a one-day period with boundary time 13:42:49 UTC. Read-only Flare block-header observations for 13-22 August 2026 found a block at the exact boundary timestamp on 8 of those 10 days. For example, blocks 67464427, 67538763, 67613933, 67689328, 67764965, 67843343, and 67921346 all have exact boundary timestamps and contain between 7 and 58 transactions. No transaction was sent for this observation. This supports timestamp reachability but does not prove that the external off-chain matcher will publish both commit and settlement in the boundary block after Phase 2 is deployed.
Flare documents approximately 1.8-second blocks, so a block is not guaranteed to carry every integer timestamp. If the chain skips the exact boundary timestamp, if the operator waits until a later timestamp, or if no settlement lands before the withdrawal, the fully finalized path demonstrated by the PoC is unavailable for that period. This constrains likelihood and should be considered during triage; it does not make the demonstrated state transition theoretical when the exact boundary block and ordering occur.
The PoC's same-block settlement removes a discretionary recovery objection. At the end of the boundary block totalSettledCover > 0, and the actual contract rejects both cancel and recommit. If an operational runner instead settles in a later block, the accounting bug still exists; proactive cancellation before settlement would be an emergency mitigation, not an on-chain invariant. Immunefi's program rules state that emergency actions are not an automatic reason to downgrade a valid issue.
Impact
Selected impact: Critical - Protocol insolvency
The contract sells and settles a 100-unit cover obligation while backed by 200 vault units and a normal 25-unit first-loss buffer. A genuine 100-unit assessment is later approved, but only 25 is delivered. The vault returns all 200 slashable units to the withdrawing staker instead of paying the remaining 75. The protocol therefore cannot satisfy a valid, premium-paid covered liability even though the collateral remained physically present in the vault throughout the payout window.
This is an accounting-driven insolvency, not a market-price or liquidity shortfall. The failure scales with the amount withdrawn by stakers in the boundary block and can reduce the vault-funded leg to zero. If the affected withdrawing stake represents most or all of the period-start vault backing, the corresponding sold-cover obligations become underfunded by the same vault leg.
The report does not select or rely on direct theft of user funds. The sharper program mapping is protocol insolvency: the valid covered liability exceeds what the payout path will deliver because its underwriting snapshot was mutated after commitment.
Attacker and victim deltas
In the attack test:
the withdrawing staker starts with zero liquid vault asset after depositing 200 and later receives all 200 back;
the payout receiver receives the normally funded 25 USDC first-loss-buffer leg but zero of the 75-unit vault leg;
IncidentManagercloses the incident withvaultPaidAmount == 0; andthe 200-unit pending withdrawal is not reduced at all.
In the negative control, the same assessment causes a 75-unit vault payout and reduces the staker's later claim from 200 to 125. The 75-unit difference is the exact collateral that escapes solely because the withdrawal used timestamp periodStart rather than periodStart + 1.
Scope and known-issue distinctions
In-scope assets and rules
The official Firelight competition scope explicitly includes:
historical vault checkpoints and payout execution;
cover allocation commitments and settlement;
incident assessment and payout waterfall; and
the local checkpoint library.
The scope's listed Critical impacts include Protocol insolvency. Duplicate bug submissions remain valid until a public fix, and the frozen code remains unchanged for the competition.
Distinct from Macro M-2
Macro M-2, "Atomic batchSettle with initial pinned values can DoS runner settlement," concerns a buyer who appears funded at an off-chain pin block and later revokes premium allowance or moves premium funds. One transferFrom then reverts the allocator's atomic batch, causing settlement liveness failure. The report describes manual recovery by recommitting or individually settling fundable orders.
This report has a different root and final state:
the buyer remains funded and the premium transfer succeeds;
the real order settles and the
CoverNFTis minted;no batch call or reverting buyer is involved;
the commitment becomes non-replaceable; and
a later valid claim is underpaid while collateral is returned to the staker.
Distinct from Macro I-3 and I-4
Macro I-3, "Add checkpoint on reward distributor," addressed direct reward/incentive transfers that increased the vault token balance without updating _traceTotalAssets. Its fix adds a checkpoint after the reward arrives.
Here checkpoints are not missing. The vulnerable withdrawal performs the intended checkpoint, but its timestamp equals the period-start key and retroactively changes the value that underwriting and payout treat as immutable. Adding more reward checkpoints does not freeze the period snapshot and does not fix the boundary-block ordering.
Macro I-4 added cancellation of an allocation commitment before settlement. The PoC settles the genuine cover before the withdrawal in the same block. totalSettledCover is therefore nonzero, and the attached test proves cancelCommitAllocation(1) reverts with SettlementsAlreadyStarted. I-4 cannot recover this finalized obligation.
Distinct from accepted aggregate-claim risk
The scope states that aggregate claims are not capped on-chain and relies on trusted assessment roles to avoid duplicate, excessive, or invalid claims. This PoC uses:
one settled order;
one market;
one incident;
one assessment loss;
an assessed amount exactly equal to, not greater than, the settled allocation; and
no prior or competing payout.
The shortfall therefore does not arise from aggregate over-claiming or a bad assessor decision.
Distinct from first-loss-buffer known issues
The scope documents that the first-loss buffer is an external wallet and assumes it remains funded and approved. The PoC satisfies those assumptions. The buffer holds 25 USDC, gives the real IncidentManager the required allowance, and successfully transfers the full 25 during both the attack and control.
Only the remaining 75-unit vault leg differs. Therefore the result is independent of first-loss-buffer underfunding, missing allowance, depeg, or custody behavior.
Distinct from accepted collateral-price undercollateralization
The accepted-risk text notes that a severe decline in the USD price of the staked asset can reduce capacity and create a shortfall. The PoC holds the oracle price constant at exactly $1 and performs no price manipulation. The 200 vault units remain physically held as pending withdrawal when the valid payout executes. The shortfall is caused solely by the mutable historical checkpoint.
Public audit and issue review
I reviewed the published OpenZeppelin/Sentora vault audit, the prior Immunefi Firelight competition findings, the July 2026 Macro Firelight A-1 report, the Coinspect frontend report, public Git history, and the public GitHub issues/PRs. No published item describes this same root: a post-commit transaction at the exact period-start timestamp mutating the underwriting snapshot and suppressing a real settled-cover payout.
The old periodAtTimestamp() reports concerned using the wrong timestamp in historical period-number lookup. They do not concern mutation of a correct period-start asset checkpoint after it has been consumed. The current allocator/incident/payout integration that makes this issue security-critical was introduced in the later V1 code.
Risk Breakdown
Proposed severity: Critical
Exact in-scope impact: Protocol insolvency
Impact magnitude in PoC: 75-unit shortfall on a valid 100-unit assessment despite 200 units of vault collateral remaining present
Triggering attacker: Ordinary vault staker
Attacker privileges: None
Required operator behavior: Normal first-eligible-period commitment and valid settlement
Required incident behavior: A genuine covered loss assessed within the settled allocation
Oracle behavior: Constant valid $1 price
First-loss buffer: Normally funded and approved; pays successfully
Race condition: Yes - exact period-start block ordered as commit, genuine settlement, then withdrawal
Likelihood constraint: If no block has the exact boundary timestamp, the operator commits later, or settlement does not precede the withdrawal in that block, the fully finalized path is unavailable for that period
Repeatability: Deterministic whenever the exact timestamp/order condition is met
On-chain recovery after demonstrated block: Commitment cancel/recommit is unavailable because settlement already started; ordinary payout closes with the reduced amount
Recommendation
Use a dedicated immutable period-start vault-asset snapshot for both commitment validation and payout, rather than re-querying a timestamp trace that remains mutable during the boundary block.
A robust design should:
Persist the raw vault-asset amount used to underwrite a period before or as the commitment is created.
Make that value immutable for the period after first use.
Make payout use that exact persisted value, or an equivalently authenticated value tied to the settled allocation.
Keep pending withdrawals in the payout base exactly as the current payout logic intends.
Add a regression test with multiple transactions sharing
currentPeriodStart().
As a smaller defense-in-depth guard, disallow commitments while block.timestamp == vault.currentPeriodStart(). Requiring block.timestamp > currentPeriodStart() ensures a later vault checkpoint cannot use the period-start key. That introduces a one-timestamp delay and is less explicit than a dedicated snapshot, so the immutable snapshot is preferred.
Regression cases should include:
deposit before the boundary;
commit at the boundary followed by withdrawal in the same block;
settlement before and after the withdrawal;
withdrawal at
boundary + 1as a negative control;normally funded first-loss-buffer and vault-funded payout legs; and
a later incident and claim of the delayed withdrawal.
Proof of Concept
Proof of Concept
This is a runnable, deterministic local Hardhat exploit test against the frozen Firelight competition revision. It deploys the real in-scope FirelightVault proxy, CoverOrderAllocator, CoverNFT, IncidentManager, Merkle commitment/proof flow, premium collection, first-loss-buffer transfer, payout accounting, and delayed withdrawal claim. Only the external ERC20 and oracle dependencies use the repository's standard local fixtures. No public-chain transaction is sent.
Reproduction
Clone the official competition repository.
Check out the frozen revision:
Save the code below as
test/security-period-start-checkpoint-rewrite.js.Install dependencies and run only the PoC:
Expected result:
The exploit branch asserts that the real commitment and settlement precede the withdrawal at the exact period-start timestamp, both cancel and recommit are locked after settlement, the funded FLB pays 25, the vault pays 0, and the staker later claims 200. The negative control changes only the withdrawal timestamp to periodStart + 1; the vault then pays 75 and the staker can claim only 125.
Runnable test
Was this helpful?