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
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_readynpm installSave the file below as
test/poc_exposure_cap_boundary.jsnpx 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.
A user deposits
A = 10,000.periodStartis read ascurrentPeriodEnd(), the start of the next period. This is the capture period we pay out for.setNextBlockTimestamp(periodStart + offset)places the withdrawal request ofW = 4,000either exactly on the boundary (offset = 0) or one second later (offset = 1).payout(receiver, A * 10, periodStart)asks for far more than the vault holds, so the paid amount is bound purely by the caps.The test measures the receiver's token balance delta.
Result
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?