Divergence is not impact: a closed finding and the bar we file to
We filed a smart-contract finding in the Firelight audit competition. The proof of concept passed, the analysis held, and Immunefi closed it. Here is the exact mechanism, why proving two accounting values disagree is not the same as proving a loss, and the pre-file checklist we built from the miss.
By doom2quake, a human builder collective.
We filed a finding in the Firelight audit competition on Immunefi. It had a runnable proof of concept, three passing tests, and an analysis we still stand behind. Immunefi closed it. That gap, between an analysis being correct and a finding being real, taught us more than most of the reports that paid. With the competition now concluded, here is the whole thing: the mechanism, why it did not clear the bar, and the checklist we file by now.
The short version, up front: proving that two internal accounting values disagree is not the same as proving that someone loses money. Review closes the first and pays the second. We knew that in the abstract. We filed anyway, because the divergence was large and clean and it felt like a result. The lesson is not that we forgot the rule. It is how a good-looking divergence walks you straight past it.
The target
Firelight is an on-chain cover protocol: an ERC-4626 vault holds staked collateral, and an off-chain matcher sells cover (protection) against that collateral, bounded by an on-chain capacity ceiling so the system cannot sell more protection than it can back. The interesting seam is exactly there, at the boundary between what the vault holds and what the ceiling thinks it holds.
Two readings of the vault’s assets exist:
totalAssets(), the live balance right now, computed net of pending withdrawals.totalAssetsAt(periodStart), a historical checkpoint taken at the start of a coverage period.
CoverOrderAllocator._computeAvailableCapacity sizes the maximum cover for a period from the second one, the frozen period-start snapshot. That snapshot is the on-chain ceiling that bounds how much cover the matcher may sell and every per-protocol concentration cap enforced at settlement.
What we found
The snapshot could be inflated far above the assets that actually back the period, permissionlessly, by anyone, in a timing sequence:
- Deposit a large amount so it lands as the last checkpoint at or before
periodStart. - Let the period roll over so that snapshot is the one the ceiling reads.
- Redeem the flash position one second after
periodStart. Because the vault uses delayed withdrawals, this does not move any tokens. It moves the amount intopendingWithdrawAssets, so livetotalAssets(), which subtracts pending, drops back to the genuine backing, while the inflated period-start checkpoint is preserved. A new, lower checkpoint is appended at the later timestamp rather than overwriting the boundary one.
Our Hardhat proof of concept showed it cleanly. Deposit a genuine 100, flash 500,000, roll the period, redeem one second later: totalAssetsAt(periodStart) reads 500,100 while live totalAssets() reads 100. The snapshot sat at more than a thousand times the real backing. A control test confirmed it was timing-specific: redeeming at exactly periodStart overwrites the snapshot back down, and redeeming one second later defeats that overwrite.
There was a quieter second defect underneath it. The documented off-chain matcher computes capacity from live totalAssets(), but the on-chain validator uses the frozen totalAssetsAt(periodStart). Those two diverge under ordinary intra-period growth, no attacker required. A spec-conformant matcher would compute a capacity the on-chain ceiling then rejects. So the on-chain safety bound does not measure the collateral the protocol actually reasons about.
It looked like a finding. It reproduced. We rated it Medium and filed it.
Why it was closed
The instinct is: the snapshot is wrong, the system trusts the snapshot, therefore the system is broken. But “wrong” here means “disagrees with the live value,” and disagreement is not harm. To turn it into a finding we had to answer one concrete question with the proof of concept, not with prose:
Who loses what, how much, in which transaction sequence, against which in-scope asset?
Every honest path we traced died before the loss step, and our own report said so:
- The honest matcher self-limits to live assets. Because of the second defect, a spec-conformant matcher sizes its sales from live
totalAssets(), not from the inflated ceiling. So the inflation alone never causes an over-sale. It only removes a defense-in-depth backstop that the honest actor was not leaning on. - The escalation to insolvency needs a role the attacker cannot get. For the inflated ceiling to actually cause over-sale, something with
ALLOCATOR_ROLEhas to submit a matching capacity that trusts it. A permissionless attacker does not have that. We ran a dedicated pass trying to escalate it to a permissionless Critical, and two independent reviews converged on the same negative: it stays capped. - Delayed withdrawal keeps the flashed tokens in the vault during coverage. This is the one that really settles it. The flash-deposited tokens are still physically in the contract, still backing obligations, right up until settlement. The snapshot excludes them, but the vault has not lost them. There is no unbacked gap during the coverage window. If a gap can exist at all, it lives at the settlement boundary, not during coverage.
Immunefi closed it for exactly this: divergence shown, impact not. They noted the flashed tokens remain in the vault and available during coverage, so the inflated snapshot did not correspond to a real hole. When your own limitations section quietly refutes your impact section, you do not have a finding. You have a correct observation. It cost us a submission slot and some credibility. The analysis was right; the decision to file it as a paid Medium was the error. It was insight-tier.

The rule we now file by
Before anything gets filed, it has to survive one sentence, answered by a runnable proof of concept:
Actor X performs sequence S and, as a direct result, party Y is worse off by amount Z in in-scope asset A, at rubric tier T.
If any variable in that sentence is a hand-wave, it is not fileable yet. “This could matter if some future consumer trusted the ceiling” fails on X, Y, and Z at once. The transition we spotted was a genuine candidate. It was not a genuine loss.
Three things the miss made concrete
Pending funds still back the system during their window. Delayed-withdrawal and queued-exit designs are everywhere in this space. It is tempting to treat requested-but-unsettled funds as already gone and to reason about a gap the moment the request lands. But those tokens sit in the contract, available to pay claims, until settlement. A snapshot that nets them out does not equal an unbacked hole during coverage. If there is a real problem, it lives at the settlement boundary: an incident lands, a payout is computed, and a specific claimant is actually underpaid because the funds left at exactly the wrong instant. You have to reach that terminal step. We stopped one move too early.
Rate against the rubric before you rate in your head. We assigned Medium by feel and then went looking for support. The correct order is the reverse: open the program’s severity table, confirm which asset category you are even in (a blockchain or DLT table, a smart-contract table, and a web-app table are three different documents with three different tiers), pick the concrete impact your proof of concept actually demonstrates, and let that set the severity. Feel-first, backfill-second is how a divergence becomes a Medium in your notes before anyone has shown a loss. Worth noting the counter-case: some competitions unlock the full pool on a single valid Low, so a rubric-anchored Low can be worth filing where a rubric-unanchored Medium is not.
The lens that finds bugs is not the filter that files them. We hunt at transitions and seams: snapshot versus live, cached versus fresh, requested versus settled, path A versus path B. That lens is good, and it found this. But a transition finding that ends at “the two states disagree” is a candidate. One that ends at “and therefore this actor loses these funds in this sequence” is a finding. The lens generates; the loss requirement filters. We had treated a strong output of the generator as if it had already passed the filter.
What changed
The miss became a checklist that now sits in front of every submission. Nothing gets filed until:
- A local proof of concept executes the full sequence, including the step where money actually moves the wrong way.
- The loss maps to one named, in-scope impact in the program’s own severity table, for the correct asset category.
- No step in the path depends on a role, origin, or permission the attacker cannot realistically get.
- The report’s own limitations section does not contradict its impact section.
None of these are clever. All of them would have stopped us from filing a correct, closed report. We would rather write ten honest negatives that never leave the workbench than one confident Medium that review closes in a paragraph. Passing on your own work is uncomfortable in the moment, and it is the entire discipline. A closed report is not a failure of analysis. Ours was a failure of the decision to file. We fixed the decision.