88676 sc low firelightvault redeem burns a staker s shares for zero assets records no withdrawal credit and returns success
Submitted on Aug 14th 2026 at 13:35:30 UTC by @whitehorse for Audit Comp | Firelight
Report ID: #88676
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
Severity
Low. In scope impact: "Contract fails to deliver promised returns, but doesn't lose value."
Brief/Intro
redeem validates the caller supplied shares, converts it to assets with floor rounding, and never checks what the conversion returned. Once the vault has paid a claim and the share price falls below 1, redeeming a small share count burns the shares, adds zero to every withdrawal ledger entry, and returns success. The holder's balance is destroyed and nothing is recorded in exchange for it. The project already ruled this a bug on the entry side: 0xMacro's Firelight A-1 audit reported L-1, that deposit could mint zero shares while still transferring assets, because the deposit amount was guarded and the resulting share count was not. It was fixed in commit 6806f35, "fix: [L-1] prevent zero-share deposits", whose diff to FirelightVault.sol is the three lines now at :667 to :669 and touches no other function. The exit path kept the same shape and is still unguarded at the in-scope commit 42f9ea5e.
Vulnerability Details
The conversion floors, at _convertToAssetsTotals:
_decimalsOffset() is not overridden anywhere in the tree and has no setter, so the cushion is fixed at 10 ** 0 = 1 and the zero output condition is exact, with A the total assets and S the total supply:
While A >= S the right hand side is at most 1, so only shares == 0 qualifies and :731 already covers it. Once A < S the band widens to every share count strictly below (S + 1) / (A + 1). Nothing rechecks the value:
In a later period claimWithdraw reads withdrawSharesOf[period][account], finds zero, and reverts NoWithdrawalAmount(period) at :803, with the shares already gone.
A < S is the state the vault is built to enter. payout() at :892 transfers the approved claim out at :962 and pushes the reduced total at :952 without touching totalSupply, and contains no _mint, _burn or _update. Absorbing claim losses into the share price is the product's core function, so a vault that has paid a claim sits at A < S until fresh yield restores the gap. payout is new in this phase. The legacy predeposit vault has no such function, and in FirelightVaultPredeposits.sol totalAssets() at :382 nets out pendingWithdrawAssets, so the only site that lowers it is the withdrawal request at :811, which burns the matching shares at :817; the claimWithdraw transfer at :708 is neutral, since :705 decrements pendingWithdrawAssets by the same amount. Assets never fell without shares, so the price could not drop below 1 and the band this defect needs did not exist.
redeem is the only entry point that rounds a derived quantity down and does not check it. deposit also floors, at :665, and rejects the zero at :669. withdraw and mint round up, at :771 and :701, so their derived quantity cannot reach zero. The two sides are the same trade reversed: in deposit assets enter and no shares come back, in redeem shares are destroyed and no assets come back, and either way the value accrues to the other holders. redeem additionally splits the failure across two transactions, since the burn settles immediately while the empty claim reverts only a period later. redeem at :717 carries whenNotPaused, three notBlocklisted modifiers and nonReentrant, and no role, allowlist, price test or timing gate sits between the external call and the burn at :1025.
Impact Details
After a claim payout, any staker who calls redeem with a share count inside the band loses those shares outright and receives nothing. The call does not revert. It emits WithdrawRequest with assets zero and leaves a withdrawal request carrying no claim.
On the live vault, totalAssets() 57,360,574.888550 FXRP against totalSupply() 57,356,448.484049 stXRP, the band opens once a claim exceeds the current surplus of 4,126.404501 FXRP and widens with the claim: 1 share unit at a claim of 10 percent of the vault, 99 share units at 99 percent. The assets the burned shares were entitled to stay in the vault and accrue pro rata to the remaining holders, and that entitlement is strictly below one raw asset unit per call, below 0.000001 FXRP, because the floor identity that zeroes the output also bounds it. The vault's asset balance is unchanged by the burn.
Recommended fix
Give the exit the guard the entry already has, immediately after the conversion at :737:
Proof of Concept
poc_zero_asset_redeem.js runs on the repository's own Hardhat suite at commit 42f9ea5e with no contract modified. Three tests pass at live mainnet parameters: test 1 is the negative control, showing the defect does not fire at the live price, test 2 is the defect after a claim of 10 percent of the vault, test 3 pins the band boundary and the value ceiling.
Adding the guard above after :737 turns tests 2 and 3 into InvalidAmount reverts and both zero credit assertions fail.
References
contracts/core/FirelightVault.sol:717to:739,redeem, and:731, the input only checkcontracts/core/FirelightVault.sol:1064,:1067,:1073, the floor conversion and the never overridden_decimalsOffsetcontracts/core/FirelightVault.sol:1015to:1019,:1025, the zero ledger writes and the unconditional burncontracts/core/FirelightVault.sol:803, the laterNoWithdrawalAmountrevertcontracts/core/FirelightVault.sol:665,:667to:669, the guarded floor conversion indepositcontracts/core/FirelightVault.sol:892,:952,:962,payoutlowering assets without touching supply0xMacro Firelight A-1, finding L-1, "prevent zero-share deposits", commit
6806f35
Proof of Concept
Was this helpful?