89499 sc high same timestamp redemption creates competing withdrawal and cover liabilities against the same collateral
Submitted on Aug 19th 2026 at 16:41:59 UTC by @Diavol0 for Audit Comp | Firelight
Report ID: #89499
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
Brief/Intro
At the exact period-start timestamp T, an unprivileged staker can redeem all shares immediately after the allocator commits a valid 1,000 USD cover allocation. That redemption rewrites the vault's opening-assets checkpoint from 1,000 FXRP to zero even though the FXRP remains locked in the withdrawal queue. When a legitimate incident is later paid, the vault returns 0 for the 999 FXRP vault-funded leg; the staker then claims the full 1,000 FXRP withdrawal. The result is a direct shift of the valid incident loss from the covered claimant to the boundary redeemer, with no privileged-role abuse.
Vulnerability Details
The capacity and payout paths both use an inclusive historical lookup:
// CoverOrderAllocator._computeAvailableCapacity
uint256 totalAssetsCanonical = Decimals.convert(
$.vault.totalAssetsAt($.vault.currentPeriodStart()),
$.vaultAssetDecimals,
CANONICAL_DECIMALS,
Math.Rounding.Floor
);The vault records the withdrawal as a next-period entitlement, but logs the reduced active balance at the current block timestamp:
_logTrace() calls Trace256.push(Time.timestamp(), _totalAssets). The custom checkpoint implementation replaces the last value when the key is equal to the last key:
totalAssetsAt() uses upperLookupRecent, which returns the last checkpoint with a key lower than or equal to the requested timestamp. Consequently, the following normal transaction sequence is sufficient. The critical condition is block.timestamp == T == vault.currentPeriodStart() for the redemption; a withdrawal after the boundary does not rewrite the lookup for T.
During period
P-1, a valid order for1,000USD of cover for periodPis authored.At the exact start timestamp
TofP, the honest allocator commits the order first. The vault has1,000FXRP of active assets, so the matcher commits1,000USD of capacity (within the total capacity, which also includes any configured FLB). This is the normal period-start matcher transaction; it does not exceed allocator permissions.In the same block timestamp, the staker redeems all shares. The redemption is assigned to
withdrawAssets[P+1], so the FXRP remains in the vault and remains exposed to payout. If no checkpoint already exists atT, the redemption appends one; if a checkpoint atTalready exists,Trace256.pushoverwrites it. In either case, the same-timestamp trace entry records zero active assets and changes the value returned bytotalAssetsAt(T).After the ordinary settlement grace period, the order settles successfully using the already stored commitment.
A legitimate incident captured during
Pis assessed and approved.payout()maps the capture back toT, reads the changed zero checkpoint, and caps the vault-funded leg at zero. A funded FLB leg, if present, remains a separate waterfall leg; the supplied PoC funds only1FLB so it can assert that the999FXRP vault remainder is capped at zero. The pending withdrawal is not reduced by that call.Once the withdrawal period is claimable, the staker claims the full
1,000FXRP.
This is not a privileged-role bypass, stale oracle data, or FLB allowance failure. Every role follows the documented order, commit, settlement, incident, assessment, and approval flow; the unprivileged staker's ordinary redemption is the only adversarial action. The redeemer benefits by avoiding the haircut that should apply to its already-exposed collateral, while the covered beneficiary loses the vault-funded leg. The bug is the mutable same-timestamp historical value used as if it were an immutable opening exposure snapshot.
Impact Details
The selected in-scope impact is Critical - Protocol Insolvency. This is not a lack-of-liquidity or temporary bank-run condition: the vault physically retains the full 1,000 FXRP and can transfer it to the withdrawal claimant, but the same collateral simultaneously backs a valid settled cover obligation and a 1,000 FXRP pending withdrawal. Including the separate 1 FLB first-loss leg, the obligations are 1,000 FXRP pending withdrawal plus 999 FXRP vault payout plus 1 FLB, while the backing is only 1,000 FXRP plus 1 FLB. The resulting shortfall is 999 FXRP.
1,000FXRP is used to commit1,000USD of period-Pcapacity;the same
1,000FXRP is moved to a pending withdrawal atT;the valid incident requests a
999FXRP vault leg, butvaultPaidAmount == 0becauseassetsAtCapturePeriod == 0(the separate1FLB leg is paid first);the redeemer subsequently claims all
1,000FXRP.
The affected claimant receives no vault-funded assets even though the cover was honestly committed and settled, while the redeemer claims the full withdrawal entitlement. No privileged role acts maliciously or exceeds its authority; the redeemer uses only a public withdrawal function and benefits by preserving its own exposed claim. The exact-timestamp condition limits reachability, but it does not change the impact classification: when the ordering occurs, the protocol's valid underwritten obligation exceeds the backing available to satisfy both claims.
References
CoverOrderAllocator.sol commitment validation - commitment validation.
CoverOrderAllocator.sol opening capacity - raw opening collateral calculation.
FirelightVault.sol at the audited commit - inclusive
totalAssetsAt()lookup.FirelightVault.sol withdrawal and payout paths and payout - delayed withdrawal and mutable payout cap accounting.
FirelightVault.sol checkpoint logging - withdrawal trace update at the current timestamp.
Checkpoints.sol at the audited commit - same-key replacement and inclusive lookup behavior.
Firelight Phase 2 explanation - protocol context for delayed unstaking and coverage exposure.
Proof of Concept
The following is a complete Foundry test. Place it in a Foundry test directory with the repository's existing forge-std, OpenZeppelin, and firelight/ remappings, then run the test with a London-compatible EVM and Solidity 0.8.28.
Was this helpful?