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

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.

  1. During period P-1, a valid order for 1,000 USD of cover for period P is authored.

  2. At the exact start timestamp T of P, the honest allocator commits the order first. The vault has 1,000 FXRP of active assets, so the matcher commits 1,000 USD 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.

  3. 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 at T, the redemption appends one; if a checkpoint at T already exists, Trace256.push overwrites it. In either case, the same-timestamp trace entry records zero active assets and changes the value returned by totalAssetsAt(T).

  4. After the ordinary settlement grace period, the order settles successfully using the already stored commitment.

  5. A legitimate incident captured during P is assessed and approved. payout() maps the capture back to T, 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 only 1 FLB so it can assert that the 999 FXRP vault remainder is capped at zero. The pending withdrawal is not reduced by that call.

  6. Once the withdrawal period is claimable, the staker claims the full 1,000 FXRP.

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,000 FXRP is used to commit 1,000 USD of period-P capacity;

  • the same 1,000 FXRP is moved to a pending withdrawal at T;

  • the valid incident requests a 999 FXRP vault leg, but vaultPaidAmount == 0 because assetsAtCapturePeriod == 0 (the separate 1 FLB leg is paid first);

  • the redeemer subsequently claims all 1,000 FXRP.

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

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?