88745 sc medium h 1 firelightvault deposit mints unbounded shares against a zero asset pool letting a 1 fxrp deposit capture the entire period s premium from the stakers who underwrote it
Submitted on Aug 14th 2026 at 19:36:27 UTC by @web3pavlou for Audit Comp | Firelight
Report ID: #88745
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
Impacts:
Theft of unclaimed yield
Description
Brief/Intro
After a validated incident consumes the vault in full, FirelightVault is left with totalAssets() == 0 while totalSupply() is unchanged — the slash reduces the asset pool but never burns shares. In that state the share-issuance formula divides by a denominator of 1, so any deposit mints approximately assets × totalSupply shares.
IncidentManager.approveCurrentAssessment clears the deposit gate in the same transaction that executes the payout (IncidentManager.sol:434-436 → :829 → vault.setActiveIncident(period, false)), so deposits reopen immediately.
An attacker back-running that transaction with 1 FXRP acquires 99.99% of all shares. When the period's premium is subsequently returned to stakers through VaultRewardDistributor.distributeRewards (the protocol's only on-chain path for premium) the attacker receives essentially all of it. The stakers whose capital actually underwrote that period's cover receive dust.
In the PoC below the attacker turns 1 FXRP into 50,000.95 FXRP, and the underwriter who staked 1,000,000 FXRP receives 0.05 FXRP of the 50,000 FXRP premium their capital earned.
Vulnerability Details
Root cause
FirelightVault.sol:1058-1065:
_decimalsOffset() is never overridden, so it returns 0. When totAssets == 0 the denominator collapses to 1 and the expression degenerates to:
There is no lower bound on the denominator and no guard on the resulting share count.
Why the existing guard does not fire
deposit() does contain a degenerate-share-price guard, at FirelightVault.sol:667-669:
The comment states the assumption precisely: it addresses the case where the share price rises above 1 and shares truncates downward. In the zero-asset case the share price is zero and shares explodes rather than truncating, so shares == 0 is structurally unreachable. The guard was added as the remediation for Macro A-1 finding L-1 (commit 6806f358); it bounds one end of the degenerate-price range and leaves the other unbounded.
How the state is reached
How the loss is realised
Premium is not credited per-user. VaultRewardDistributor.distributeRewards transfers vault asset into the vault (VaultRewardDistributor.sol:146) and calls checkpointTotalAssets() (:150). This raises balanceOf(vault) and therefore the redemption value of whatever shares exist at that moment. There is no per-period accrual and no record of which cohort earned the premium.
So period P's premium — earned by the capital that backed period P's cover — is distributed according to the share register after the slash, which the attacker now dominates.
Attack path
Net attacker profit: 49,999.95 FXRP on a 1 FXRP outlay (~50,000×), with no privileged role and no capital at risk beyond 1 FXRP.
Impact Details
Theft of unclaimed yield — listed at High on this program's published impacts.
The period's premium is yield earned by the cohort whose staked capital backed that period's cover. It is unclaimed at the time of the attack — still off-chain, awaiting the swap. The attacker captures it in full by holding shares acquired for a nominal sum after that cohort's capital was destroyed.
Victim class: stakers whose capital was active at capturePeriodStart of the slashed period. They lose 100% of the premium their capital earned, on top of the principal loss the slash already imposed.
Secondary: every subsequent inflow to the vault — later premium cycles, incentive distributions via distributeIncentive, and any recovery routed through the vault — accrues to the attacker on the same basis until the share register is corrected. There is no mechanism to correct it.
The attacker cannot induce the precondition, but a near-total loss is the scenario this protocol exists to process, not an exotic one.
Distinction from the accepted risk in Macro A-1
Macro A-1 records under Accepted Risks:
"New deposits can capture a pending reward distribution (accepted) — Because rewards are injected by raising the vault redemption value, a deposit placed just before a distribution shares in yield it did not earn... It is mitigated by the withdrawal delay and by incident risk, since the new capital cannot exit immediately and remains exposed for the period."
This finding is distinct on three counts, and Control A exists specifically to demonstrate the first:
Different magnitude and different root cause. The accepted risk is proportional dilution inherent to ERC-4626 — deposit 1 FXRP into a healthy 1,000,000 FXRP vault and you receive 1/1,000,001 of the yield. Control A reproduces exactly that: the same 1 FXRP earns
49,999units, four decimal orders below the attack. This finding is non-proportional capture caused by a zero denominator in_convertToSharesTotals, not by distribution timing.Both cited mitigations are inoperative in this state. "Remains exposed for the period" is void — the incident has already occurred and been settled. "Cannot exit immediately" is irrelevant when the capital at risk is 1 FXRP against a 50,000 FXRP capture.
The economics invert. Under the accepted risk a late depositor must commit capital proportional to what they hope to capture. Here the required capital is arbitrarily close to zero.
If triage nonetheless concludes this is covered by the accepted risk, I would ask that the zero-denominator issuance be considered on its own as the underlying defect, independent of who captures the yield.
Recommended mitigation
Minimal fix — reject issuance against a zero-asset pool, in deposit() and mint():
Note that this alone leaves the vault permanently closed to deposits after a total loss, so it needs a companion recovery path — an admin-gated reset that writes off the worthless share supply before reopening.
Note on _decimalsOffset(): raising the virtual-share offset does not fix this. The offset appears in the numerator (totSupply + 10 ** _decimalsOffset()), so when totAssets == 0 it increases the minted share count rather than bounding it. It defends against the donation/inflation attack, which is a different problem.
Proper fix — attribute yield to the cohort that earned it. Have distributeRewards / distributeIncentive credit a per-period accrual claimable only by holders with a share checkpoint at or before that period's start, rather than raising balanceOf(vault) and letting the current share register decide. The vault already maintains _traceBalanceOf and _traceTotalSupply for exactly this kind of historical lookup. This also resolves the mirror-image problem where a staker who requests withdrawal retains slash exposure for up to two periods while forfeiting all yield accrued in that window.
References
contracts/core/FirelightVault.sol:1058-1065—_convertToSharesTotals, zero-denominator issuancecontracts/core/FirelightVault.sol:667-669— theshares == 0guard and its one-directional commentcontracts/core/FirelightVault.sol:428—totalAssets()contracts/core/FirelightVault.sol:952— post-payout checkpoint of0contracts/core/FirelightVault.sol:1115—_hasActiveIncident()contracts/core/IncidentManager.sol:434-436— payout closes the incident and decrements the countercontracts/core/IncidentManager.sol:829—setActiveIncident(period, false)reopens depositscontracts/core/VaultRewardDistributor.sol:146,150— premium transfer and checkpoint
Proof of Concept
Foundry. Uses the shared Base.t.sol fixture, a Solidity port of the project's own test/setup/fixtures.js. This PoC calls deployVault() and deployRewardDistributor(); the allocator half of the fixture is unused here and included solely so the file matches the one used across my other submissions.
The PoC calls vault.payout directly under PAYOUT_ROLE rather than driving a full incident through IncidentManager — the resulting vault state is identical, and step 4 of "How the state is reached" above documents that production reopens deposits in the same transaction as the payout.
test/Foundry/Base.t.sol
test/Foundry/mocks/MockFAsset.sol
test/Foundry/PremiumCapture.t.sol
Output
Three runs, one variable, and the same 1 FXRP deposit in every one of them:
Control A
into a healthy vault, no slash at all
49,999 units
Control B
before the slash, so it shared the loss
49,999 units
The finding
after the slash, into a zero-asset pool
50,000,950,000 units
Both controls land on the same figure. Only the deposit that arrives after totalAssets() reaches zero diverges, and it diverges by a factor of one million.
That comparison forecloses the two readings that would otherwise dismiss the finding:
It is not ordinary ERC-4626 dilution. Control A is ordinary dilution — a late deposit into a healthy vault taking its proportional 49,999 units of the premium. The attack is four decimal orders removed from it, so the two are not the same phenomenon at different scales.
It is not "the attacker would have been slashed anyway." Control B slashes the vault just as completely, and the premium still distributes correctly: the underwriter's 1,000,000 FXRP keeps essentially the whole 50,000 FXRP premium while the opportunist's 1 FXRP takes 0.05. The vault handles a total wipe correctly for shares that already exist.
What is broken is narrower than "slashing" or "yield distribution": it is the issuance of new shares against a pool whose denominator has collapsed to 1. That is why a guard in deposit() and mint() is a sufficient fix, and why the defect does not implicate the payout or reward paths themselves.
Was this helpful?