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

89500 sc medium a deposit placed right after a payout drains the vault mints shares against a near zero totalassets the premium income pre incident lps underwrote for goes to whoever deposi

Submitted on Aug 19th 2026 at 16:42:56 UTC by @Web3Vinay for Audit Comp | Firelight

  • Report ID: #89500

  • 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

FirelightVault prices new deposits against the live totalSupply()/totalAssets() ratio. A single legitimate incident payout() can drive totalAssets() to exactly 0 while totalSupply() stays at its full pre-incident value, because payout() moves assets but never burns shares. Once that state exists, the very next deposit() — however small — is priced against a near-zero denominator and mints shares that dwarf the entire pre-incident supply. VaultRewardDistributor.distributeRewards() — which specifically forwards premium-sale proceeds collected against capacity the pre-incident LPs' capital was backing — then credits the vault with new assets but mints no shares at all, so that income is split strictly by current share ownership, not by who actually earned it (distributeIncentive(), a separate general-purpose incentive channel, shares the same no-share-mint mechanism). Net effect: the LPs who funded the vault before the incident and bore its loss receive almost none of the reward income that follows — nearly all of it goes to whichever deposit happened to land first after the drain.

Vulnerability Details

payout() drains active assets but never touches totalSupply():

// contracts/core/FirelightVault.sol:892-966
function payout(address to, uint256 amount, uint48 captureTimestamp)
    external onlyRole(PAYOUT_ROLE) nonReentrant returns (uint256 paidAmount)
{
    ...
    paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount));
    ...
@>  IERC20(asset()).safeTransfer(to, paidAmount);   // :962 — the only state-changing external call; no _burn anywhere in this function
}

The next deposit's share math (totalSupply and totalAssets are the live values at call time):

_decimalsOffset() is unoverridden anywhere in the contract (OZ default 0, so 10 ** _decimalsOffset() == 1). At totAssets == 0 this collapses to shares = assets * (totSupply + 1) — linear in the deposit size with a multiplier equal to the entire pre-incident supply. deposit()'s only guard is shares == 0 (:667-669), which protects the opposite regime (price too high, dust deposit rounds to zero) and does nothing here, since shares is enormous, not zero.

The gate that blocks deposits while an incident is open clears in the same transaction as the payout that drains the vault — no waiting period is needed. approveCurrentAssessment executes the payout and then immediately closes the incident:

The reward/incentive channel that follows credits the vault with no share issuance at all, by design:

Its own NatSpec confirms this is intentional for the distributor's role: "The contract does not perform swaps or mint vault shares. It only pulls vault assets from authorized distributors, transfers them directly to the vault..." — but nothing anywhere accounts for what that combination does when totalAssets() has just been driven to zero and a fresh deposit has just captured most of totalSupply().

Impact Details

  • distributeRewards only ever fires because cover buyers paid premium against capacity that the pre-incident LPs' capital was backing at the time (VaultRewardDistributor.sol:121-122, "Forwards premium-sale proceeds into the vault"; premium is pulled from the buyer at CoverOrderAllocator.sol:535). That premium income is what the old LPs took on the underwriting risk for — it never carries any share issuance, so once it lands it is split purely by whoever holds shares at that moment, not by who actually earned it. The incident is what let a brand-new depositor, who backed none of the cover that generated it, collect nearly all of it instead.

  • A $10 deposit that lands right after the drain ends up owning 99.99%+ of the vault (measured in the PoC below), while the old LP's shares — still the entire pre-incident supply — are diluted to under 0.01% ownership, with no manipulation on either side.

  • No malicious input anywhere in the chain: the payout is a normal, correctly-capped payout() call, the deposit is an ordinary deposit(), and the reward distribution is VaultRewardDistributor operating exactly as documented. Exact dollar figures for a $1,000,000 pre-incident LP and a $50,000 reward are in the PoC output below.

Mitigation

  • deposit()/mint() should revert once the vault is in a "crashed" state — i.e. totalSupply() > 0 but totalAssets() has fallen far below what's proportionate to it (e.g. totalAssets() < totalSupply() / K for some chosen K) — instead of silently pricing new shares off a near-zero denominator.

  • Recovery from that state should be an explicit, governance-gated action, not a side effect of the next ordinary deposit — e.g. CONFIG_ADMIN_ROLE/DAO either re-seeds totalAssets() back to a healthy ratio, or proportionally burns totalSupply() to match the real post-loss value, before normal deposits are re-enabled.

References

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1058-L1064

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L892-L966

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/VaultRewardDistributor.sol#L132-L153

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/IncidentManager.sol#L819-L830

Proof of Concept

Proof of Concept

One LP, one ordinary later depositor, no malicious actor. Save as test/PostLossShareHijack.t.sol and run forge test --match-contract PostLossShareHijackReportPoC -vv.

Result (actually run — forge test --match-contract PostLossShareHijackReportPoC -vv):

The LP who supplied the vault's entire $1,000,000 and bore the incident's loss receives $0.005 of the following $50,000 reward distribution — a $10 deposit that landed first receives $50,009.995.

Was this helpful?