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

89401 sc high withdrawal requested at the exact period start second lowers the payout cap but stays payable so valid claims are underpaid

Submitted on Aug 19th 2026 at 03:05:55 UTC by @Pelican29685 for Audit Comp | Firelight

  • Report ID: #89401

  • 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:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

payout() checks two things. First, how much the vault can actually pay now: the assets left in the vault, plus the assets waiting to be withdrawn. Second, a cap: how much was in the vault at the start of the period. It pays the smaller of the two.

If you request withdrawal at the exact period start time, the money goes into the assets waiting to be withdrawn, but it is subtracted from the cap at the period start. So claims are underpaid.

How I got here

I first tried flash loan deposit and withdraw inside one block. But it did not work. Because withdrawal is delayed in a later period. While reading that code I noticed period number and timestamp. The withdrawal uses the period number, but the asset log uses the timestamp. It's the same thing, but it uses two different units.

Vulnerability Details

Two different units are used for the same event.

At the exact period start, currentPeriod() is already P. So the bucket is P+1. Then payout() reads that bucket (P+1) so the money is payable.

At the same moment, the key is exactly periodStart(P). TotalAssetsAt uses upperLookupRecent that key takes the newest one less than or equal to periodStart. So it picks the value after the withdrawal (A - W). So one second later the key is periodStart + 1, so it skips it and the cap stays at A.

The code has a comment at L918-920. It says we need to stop people from escaping with withdrawals. This proves they tried to make a safety guard, not a bug. The safety guard worked only halfway. _requestWithdraw put the money in the payable bucket. That part was right. But the cap calculation did not change. So, in the same function, the results of the two calculations disagree.

Impact Details

When the payout is less, that money just stays there. That is a calculation problem, not a liquidity problem. The shortfall stays in the vault. The cover buyer does not receive it. This attack does not need special power or money. An LP can trigger this when the withdrawal is included exactly at the period boundary. If multiple withdrawals are included at the same boundary timestamp, the reduction would accumulate across those withdrawals.

What I did not verify

The PoC forced the exact second. I did not measure how often a block timestamp actually lands on periodStart on mainnet.

Suggested fix

Reading the cap as totalAssetsAt(capturePeriodStart - 1) would stop a checkpoint written on the boundary second from lowering it. This looks like the smallest change.


I used Claude Code to help explore the codebase and build the PoC. The analysis and this writeup are mine.

Proof of Concept

Steps to run

  1. Clone the in-scope repo and check out the audit branch: git clone https://github.com/immunefi-team/audit-comp-firelight && cd audit-comp-firelight && git checkout v1_audit_ready

  2. npm install

  3. Save the file below as test/poc_exposure_cap_boundary.js

  4. npx hardhat test test/poc_exposure_cap_boundary.js

Uses the repo's own fixture (test/setup/fixtures.js), default config: 6 decimals, period duration 604800 (1 week).

What the test does

Two runs that are identical except for a one second shift of the withdrawal request.

  1. A user deposits A = 10,000.

  2. periodStart is read as currentPeriodEnd(), the start of the next period. This is the capture period we pay out for.

  3. setNextBlockTimestamp(periodStart + offset) places the withdrawal request of W = 4,000 either exactly on the boundary (offset = 0) or one second later (offset = 1).

  4. payout(receiver, A * 10, periodStart) asks for far more than the vault holds, so the paid amount is bound purely by the caps.

  5. The test measures the receiver's token balance delta.

Result

withdrawal at
withdrawAssets[P+1]
totalAssets()
totalAssetsAt(periodStart)
paid

periodStart + 1

4,000

6,000

10,000

10,000

periodStart

4,000

6,000

6,000

6,000

Only the cap differs. The payable pool is 10,000 in both runs.

The third test asserts late.paid - boundary.paid == W and also boundary.activeAssets + boundary.bucket == A. The shortfall is not caused by insufficient assets. In both runs, activeAssets + bucket == A.

Test file

Was this helpful?