89424 sc high a withdrawal at the exact period boundary permanently collapses the period s exposure snapshot voiding paid for cover and denying capacity
Submitted on Aug 19th 2026 at 08:38:18 UTC by @deltasec for Audit Comp | Firelight
Report ID: #89424
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:
Theft of unclaimed yield
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
When the block timestamp coincides with a period start T, every withdrawal landing in that block permanently slashes the amount that can be paid for any incident captured in the period. Three facts make this periodic vulnerability possible:
totalAssetsAt(T)returns whatever was last pushed at keyT—Trace256.pushappends on a new key and overwrites in place on an equal one (Checkpoints.sol:207-209) — and trace keys are monotonic, so once the value at keyTis written down it can never be raised again.The vault answers two questions inconsistently about the same withdrawal:
_requestWithdrawbooks the withdrawn shares into bucketcurrentPeriod() + 1— the next, still-exposed period (FirelightVault.sol:1008) — while_logTracewrites the reduced total-assets value onto keyTitself (:1055).Key
Tis precisely the key the rest of the protocol reads as the period's opening capital:payoutreads it ascapturePeriodStart(:911), and the allocator reads it in_computeAvailableCapacity(CoverOrderAllocator.sol:711), which gates every commitment through_validateMatchingCapacity(:400-402).
So the assets are simultaneously exposed (booked into the still-covered bucket, and added back by activePayableAmount at :916 so exposed assets cannot escape by withdrawing first) and absent (erased from the snapshot that caps the claim). The cap is the smaller of the two, so the cap wins.
The consequence is permanent: every incident ever captured in that period is capped at the collapsed value — at the extreme, zero — even though the vault physically holds the assets and its own activePayableAmount proves it could have paid. The same collapsed key also drives capacity, so commitments for the period can be rejected outright.
Vulnerability Details
Root cause — bucket and key disagree at the boundary. contracts/core/FirelightVault.sol:
At t == T the period that opens at T is already currentPeriod(), so the withdrawal books into C + 1 and stays slashable for incidents captured in period C. The matching trace write, however, lands on key T — the key that period C is measured by.
The window is exactly one second. Measured by the PoC below (11,000 in the vault, whale withdraws 10,000, full 11,000 claimed against an incident captured in the period):
Offset -1 is self-consistent and correct: the underwriter left before the period opened, so its assets are excluded from the cap and from the payable set. Offset +1 is self-consistent the other way: the assets are in both. Only at offset 0 do the two disagree, and the smaller one — the cap — decides the claim. 10,000 of paid-for cover is voided while the vault holds every token.
Consumers of the collapsed key
payout (FirelightVault.sol:911)
the claim cap collapses, and the collapsed key is permanent — trace keys are monotonic, so totalAssetsAt(T) can never be raised again, and every incident ever captured in that period is capped at the collapsed value
cover buyers
_computeAvailableCapacity (CoverOrderAllocator.sol:711)
the period's opening capital is understated for the rest of the period, lowering sellable capacity
every buyer in the tree
_validateMatchingCapacity (CoverOrderAllocator.sol:400-402)
reverts with MatchingCapacityOverflow on a commitment that the vault's actual collateral supports
protocol revenue
Trigger path
Any share holder calls
withdraw/redeemin the block whose timestamp equals a period startT._requestWithdrawbooks the shares into bucketC+1— the period that is still exposed (:1008)._logTracepushes_totalAssets - assetsat keyT(:1055,Checkpoints.sol:207-209).totalAssetsAt(T)is now permanently understated. Every incident withcapturePeriodStart == Tis capped at the understated figure for the rest of time, and the allocator's capacity basis for the period is understated for the rest of the period.
Impact Details
Permanent, irreparable voiding of paid-for cover. Once key
Tis collapsed it can never be raised — trace keys are monotonic — so every incident ever captured in that period is capped at the collapsed value. The PoC shows a 50,000 refill inside the same period leavingtotalAssetsAt(T)at 1,000 and a 51,000 claim still paying 1,000. If the whole vault exits atTthe cap is zero: buyers who paid full premium for the period can never be paid anything, while the vault demonstrably holds the collateral andactivePayableAmountreports it as present.Understated capacity for the remainder of the period.
_computeAvailableCapacityderives the period's sellable capacity from the same collapsed key, so commitments the real collateral supports revert withMatchingCapacityOverflow— lost underwriting revenue for the period, and at the extreme no capacity at all.Cost to trigger: one ordinary withdrawal in one specific block. The loss falls on cover buyers and the protocol; no privileged role is involved.
The scope exclusion for "collateral drawdowns reducing coverage capacity" does not apply: this is not a drawdown. The collateral never left the vault, and activePayableAmount proves the vault could have paid.
References
contracts/core/FirelightVault.sol:1008— withdrawal booked into the still-exposed bucketcontracts/core/FirelightVault.sol:1055— trace push at the boundary keycontracts/core/FirelightVault.sol:911,:916— payout cap vsactivePayableAmountcontracts/core/CoverOrderAllocator.sol:711,:400-402— capacity basis and the commitment gateCheckpoints.sol:207-209—Trace256.push: append on a new key, in-place overwrite on an equal keyTested against commit
42f9ea5, branchv1_audit_ready
Proof of Concept
Proof of Concept
Save as test/poc.js and run npx hardhat test test/poc.js. It uses only the project's own test/setup/fixtures.js; no harness or contract modifications are needed. Both tests pass against the unmodified code.
Was this helpful?