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 returns0instead of reverting, breaking that documented contract.WithdrawRequestfires 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 atclaimWithdraw, which revertsNoWithdrawalAmountfor 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?