For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

1

A validated incident is approved

IncidentManager._executePayout calls vault.payout(receiver, amount, captureTimestamp).

2

The payout depletes the vault

In payout, paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount)). When the assessed loss meets or exceeds the vault, paidFromActive == currentActiveAssets and FirelightVault.sol:952 pushes a post-payout checkpoint of 0.

3

Assets reach zero while shares remain

totalAssets() (FirelightVault.sol:428) now returns 0. totalSupply() is untouched.

4

The incident is closed and deposits reopen

Still inside approveCurrentAssessment, IncidentManager.sol:436 calls _decreaseIncident(incident.period), which at IncidentManager.sol:829 calls vault.setActiveIncident(period, false).

5

The deposit gate is clear

_hasActiveIncident() (FirelightVault.sol:1115) checks hasActiveIncident[currentPeriod] || hasActiveIncident[currentPeriod - 1]. Both are now false, so deposit() is open in the very next transaction.

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

1

Underwriter deposits in period P

Underwriter deposits 1,000,000 FXRP in period P. Receives 1e12 shares.

totalAssets() == totalSupply() == 1e12.

2

Premium is earned

Period P closes; cover sold against that capital earned 50,000 FXRP of premium, sitting at premiumCollector awaiting the off-chain swap.

3

An incident depletes the vault

A period-P incident is assessed at or above the vault's value and approved. payout transfers 1e12 out; totalAssets() == 0, totalSupply() == 1e12. The deposit gate clears in the same transaction.

4

Attacker deposits 1 FXRP

Attacker deposits 1 FXRP (1e6). Share maths:

The attacker holds 9,999 bps of supply.

5

Premium returns to the vault

The Distributor performs its routine premium return: distributeRewards(50_000e6, ...).

Vault balance becomes 50,001,000,000.

6

Shares redeem against the premium

Redemption values:

  • Attacker: 50,000,950,000 = 50,000.95 FXRP

  • Underwriter: 50,000 = 0.05 FXRP

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,999 units, 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.


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 issuance

  • contracts/core/FirelightVault.sol:667-669 — the shares == 0 guard and its one-directional comment

  • contracts/core/FirelightVault.sol:428 — totalAssets()

  • contracts/core/FirelightVault.sol:952 — post-payout checkpoint of 0

  • contracts/core/FirelightVault.sol:1115 — _hasActiveIncident()

  • contracts/core/IncidentManager.sol:434-436 — payout closes the incident and decrements the counter

  • contracts/core/IncidentManager.sol:829 — setActiveIncident(period, false) reopens deposits

  • contracts/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:

Run
When the 1 FXRP was deposited
What it received

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?