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 == 0Has a withdrawal request filed with
assets: 0(visible in the emittedWithdrawRequestevent)Cannot recover anything later:
claimWithdraw()on that request revertsNoWithdrawalAmount, 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_readyOpenZeppelin @5.2.0 (contracts) / @5.4.0 (contracts-upgradeable), matching the versions pinned in
package.json
Step-by-step
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.
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.
Result (actual forge test run, not a manual trace)
Key trace excerpt:
Full test file
Foundry, self-contained. Run with:
Was this helpful?