89085 sc high payout s exposure cap snapshot can be corrupted by an ordinary user s withdrawal request landing at a period boundary causing incident claims to be permanently underpaid
Submitted on Aug 17th 2026 at 03:03:18 UTC by @Bobai for Audit Comp | Firelight
Report ID: #89085
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
payout() caps the amount it can pay an incident's beneficiary using a historical snapshot of vault assets taken at the start of the incident's capture period:
// Cap by the active assets committed at the start of the covered period.
// This is an exposure cap, not a segregated asset bucket.
uint256 assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart);
uint256 currentActiveAssets = totalAssets();
...
uint256 activePayableAmount = currentActiveAssets + capturePeriodWithdrawals + nextPeriodWithdrawals;
paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount));totalAssetsAt(capturePeriodStart) reads from a Checkpoints.Trace256 (_traceTotalAssets) that is keyed by block.timestamp at second-level granularity. That trace is pushed to by redeem()/withdraw() via _logTrace(...), which logs _totalAssets - assets — i.e. the vault's active balance after the requested amount has already been moved into pendingWithdrawAssets:
capturePeriodStart is a deterministic, publicly computable value. It is derived purely from the vault's on-chain periodConfigurations (_periodAndStartAt), with no dependency on the specific incident or captureTimestamp chosen by PAYOUT_ROLE (any captureTimestamp within period P resolves to the same capturePeriodStart).
If any user submits a redeem()/withdraw() transaction that lands in a block whose block.timestamp is exactly equal to capturePeriodStart, the checkpoint pushed by that transaction (post-withdrawal-request balance) becomes the value returned by totalAssetsAt(capturePeriodStart) for every future payout() call against that period, permanently understating assetsAtCapturePeriod relative to the true balance that existed at the instant the period began. No privileged role is required; this is triggerable by any ordinary depositor and requires no hasActiveIncident misconfiguration.
Because _periodAndStartAt resolves the same capturePeriodStart for the entire period, this single, well-timed transaction suppresses the payout cap for every incident captured anywhere in that period, not just one.
Root Cause
totalAssetsAt(capturePeriodStart) is intended to represent "assets in the vault at the instant the period began," but the underlying Checkpoints.Trace256 has only second-level timestamp granularity and no mechanism to distinguish "state at the exact period boundary, before any same-second activity" from "state after a same-second transaction that also happens to touch the trace." Any state-mutating call that pushes to _traceTotalAssets in the same block/second as a period boundary can become the canonical snapshot for that boundary.
Vulnerability Condition
Withdrawal at the boundary
A user submits redeem()/withdraw() for $1000 in a block where block.timestamp == T.
totalAssets()drops to $1000 (super.totalAssets() - pendingWithdrawAssets).withdrawAssets[P+1]becomes $1000.The
_logTracecall in that same transaction pushes_traceTotalAssets[T] = 1000, overwriting/establishing the checkpoint at exactly the period-start key.
Impact
Payout recipient receives less than the approved incident amount despite sufficient real backing existing in the vault.
The shortfall stays split across active vault shares and the withdrawal-pending pool for period capturePeriod+1, inaccessible to the intended payout recipient.
Proof of Concept
Was this helpful?