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

88984 sc low missing zero value check in redeem silently burns shares for zero assets after totalassets reaches zero

Submitted on Aug 16th 2026 at 08:08:34 UTC by @bytescannet for Audit Comp | Firelight

  • Report ID: #88984

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

FirelightVault.redeem() has no guard against a zero-value conversion result. deposit() explicitly reverts if the computed shares round to zero, but redeem() has no equivalent check for assets. Once totalAssets() reaches 0, a reachable state after a legitimate incident payout via payout(), any subsequent redeem() call burns the caller's full share balance and returns 0 assets, with no revert and no recovery path.

Vulnerability Details

In FirelightVault.sol, deposit() enforces:

if (shares == 0) revert InvalidAmount();

redeem() has no matching check:

_convertToAssetsTotals rounds down:

When totalAssets() == 0, this floors to exactly 0 for every valid share amount, since shares can never exceed totalSupply.

totalAssets() == 0 is reachable through normal, correctly-authorized protocol operation: payout() can transfer the vault's entire active balance to an approved incident claimant. This does not require any bug in payout() itself; it is the intended loss-absorption mechanism, and the impact list for this program includes "Protocol insolvency" as a recognized reachable state.

Once totalAssets() == 0, any depositor calling redeem() on their own shares:

  • Has their full share balance burned (standard ERC20 burn via _update)

  • Receives assetsReturned == 0

  • Has a withdrawal request filed with assets: 0 (visible in the emitted WithdrawRequest event)

  • Cannot recover anything later: claimWithdraw() on that request reverts NoWithdrawalAmount, since the recorded claimable amount is 0

The shares are gone permanently. If the vault later recovers value (new deposits, reward forwarding via VaultRewardDistributor), the affected user has no claim on that recovery, since their shares no longer exist.

This is not an attacker-vs-victim bug. redeem() requires either msg.sender == owner or a prior ERC20 allowance from owner (via _spendAllowance), so no unprivileged third party can force this against another user's shares. The bug fires against whoever calls redeem() on their own position during the zero-asset window, including a user acting in complete good faith with no attacker involved.

Impact

A user who redeems during a totalAssets() == 0 window loses their entire remaining claim on the vault for zero returned value, with no revert to warn them and no way to recover afterward. This is a real, non-contrived code path: it is reached by the protocol's own designed loss-absorption mechanism (payout()), not by any external manipulation.

Recommendation

Add a symmetric zero-value guard to redeem(), mirroring the existing check in deposit():

Place it immediately after the _previewTotals() call and before _requestWithdraw() is invoked.

Proof of Concept

Environment

  • Foundry (forge 1.7.1), solc 0.8.28, via-ir

  • Repo: audit-comp-firelight, branch v1_audit_ready

  • OpenZeppelin @5.2.0 (contracts) / @5.4.0 (contracts-upgradeable), matching the versions pinned in package.json

Step-by-step

1

Deploy and initialize the vault

Deploy FirelightVault behind an ERC1967Proxy and initialize it with a mock ERC20 asset, admin address holding all init roles.

2

Configure payout permissions

Grant PAYOUT_ROLE and PAYOUT_ALLOWLIST_ROLE to admin, and allowlist a payout receiver.

This isolates the vault-side accounting bug from IncidentManager's approval workflow; the bug lives entirely in redeem()'s accounting after a payout lands, not in how that payout was authorized upstream.

3

Deposit assets

depositor deposits 1000e18 of the asset and receives 1000e18 shares (1:1 first deposit).

totalAssets() == 1000e18.

4

Confirm normal redemption behavior

previewRedeem(1000e18) returns 1000e18, confirming correct pre-incident behavior.

5

Drain the active balance through a legitimate payout

Admin calls vault.payout(receiver, 1000e18, captureTimestamp), draining the vault's full active balance.

This is a legitimate, correctly-authorized call representing an approved incident claim, not a bug in payout() itself, and is the vault's designed loss-absorption mechanism.

6

Verify zero assets while shares remain

totalAssets() is now 0. depositor still holds the full 1000e18 shares, since payout() moves assets only, not shares.

7

Observe the zero-value conversion

previewRedeem(1000e18) now returns 0.

The bug is already visible here, in a read-only call, before any state change.

8

Redeem shares for zero assets

depositor calls redeem(1000e18, depositor, depositor).

  • Transfer(depositor -> 0x0, 1000e18) fires: the full share balance is burned.

  • WithdrawRequest(..., period: 1, assets: 0, shares: 1000e18) fires: the request itself is logged with assets: 0.

  • redeem() returns 0.

9

Confirm the shares are gone

balanceOf(depositor) is now 0. Shares are gone.

10

Confirm there is no recovery path

Time is warped past that request's claim window. claimWithdraw(1) is called and reverts NoWithdrawalAmount(1), confirming there is no later recovery path for the burned position.

Result (actual forge test run, not a manual trace)

Key trace excerpt:

Full test file

Foundry, self-contained. Run with:

Was this helpful?