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

90085 sc low the withdraw side of the share asset conversion has no zero output guard so redeem can burn a holder s shares while crediting nothing claimable

Submitted on Aug 22nd 2026 at 22:03:45 UTC by @MangalJup for Audit Comp | Firelight

  • Report ID: #90085

  • 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 guards exactly one direction of its share/asset rounding: deposit() refuses an input that would floor to zero shares, and the code says so in a comment. redeem() has no matching guard on the value it computes going the other way. Once the share price sits below 1 — the state every incident payout() leaves behind — a redeem() call for a small enough (but still nonzero) shares amount floors its computed assets to 0. The call does not revert: the full shares are burned from the caller, WithdrawRequest fires as if a real withdrawal request was created, and only later, at claimWithdraw, does NoWithdrawalAmount reveal that nothing was ever credited.

Vulnerability Details

The deposit path checks its own rounding output and explains why:

// contracts/core/FirelightVault.sol:662-669
(uint256 shares, uint256 _totalSupply, uint256 _totalAssets) = _previewTotals(
    assets,
    true,
    Math.Rounding.Floor
);
// A dust deposit can floor to zero shares once the share price exceeds 1;
// reject it instead of pulling assets in exchange for nothing.
   if (shares == 0) revert InvalidAmount();

redeem() checks only its input, never the value the same conversion produces:

_requestWithdraw then burns exactly shares, regardless of what assets turned out to be:

The rounding that opens this window is _convertToAssetsTotals (:1067-1074): shares.mulDiv(totalAssets()+1, totalSupply()+1, Floor) with _decimalsOffset() == 0. That floors to 0 for any shares < (totalSupply()+1)/(totalAssets()+1) — i.e. for a growing range of nonzero shares values every time totalAssets() < totalSupply(), which is exactly the state left behind by any payout() (:892-966) that isn't a 0%-loss no-op.

Impact Details

  • The exact scenario deposit()'s own comment says the contract refuses to produce — "shares/assets in exchange for nothing" — happens anyway from the redeem side, with no revert to stop it.

  • redeem()'s NatSpec (:715) promises its return value is "Amount of assets that will be received in the next period." It silently returns 0 instead of reverting, breaking that documented contract.

  • WithdrawRequest fires on a request that credits nothing claimable. Anything reading that event as "a withdrawal is now pending" — an indexer, a subgraph, an ops dashboard — is wrong until the mismatch surfaces at claimWithdraw, which reverts NoWithdrawalAmount for a period that outwardly looked fine.

  • No privileged role is needed to trigger it — any holder, or any spender the holder approved, can hit this the moment the share price is below 1, which is the vault's normal post-incident state.

  • By construction of the flooring condition, the fair value actually destroyed per occurrence is under one asset-unit wei — this does not move meaningful value, which is why the impact category here is "fails to deliver promised returns," not fund loss.

References

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

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

Proof of Concept

Proof of Concept

One ordinary deposit, one ordinary incident payout to move the share price below 1, one redeem() call sized right at the dust boundary. No privileged trigger, no malicious actor. Save as test/RedeemFloorToZero.t.sol and run forge test --match-contract RedeemFloorToZeroPoC -vv.

Was this helpful?