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

89549 sc high rescue leaves a sticky claimed flag that permanently freezes a later same period withdrawal

Submitted on Aug 19th 2026 at 20:19:06 UTC by @Kuzomo for Audit Comp | Firelight

  • Report ID: #89549

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

FirelightVault.rescueWithdrawFromBlocklisted moves a blocklisted account's pending withdrawal to a safe address and permanently marks the source account as having claimed that period. The flag is never cleared: both assignments to it in FirelightVault set it to true, and no function in the repository clears the mapping. If that account is later removed from the blocklist and makes an ordinary new withdrawal request while the originating period is still current, the request is accepted and credited, but claimWithdraw reverts AlreadyClaimedPeriod and a second rescue reverts on the same flag. The entitlement is unreachable by every path the contract offers. In the run below the account ends with 0 shares, 0 tokens, and 500.000000 FXRP that withdrawalsOf() still reports as owed; pendingWithdrawAssets retains the same amount forever, which also permanently understates totalAssets() for every other staker. No role misbehaves at any point: blocklist, rescue and un-blocklist are the documented compliance workflow performed correctly, and the step that traps the funds is an ordinary redeem by the now-cleared account.

Vulnerability Details

isWithdrawClaimed has exactly two writers in FirelightVault and both set it to true. No function in any in-scope contract clears the mapping, and there is no setter or admin override:

FirelightVaultStorage.sol:71   mapping(uint256 period => mapping(address account => bool value)) public isWithdrawClaimed;
FirelightVault.sol:794         if (isWithdrawClaimed[period][sender]) revert AlreadyClaimedPeriod(period);
FirelightVault.sol:806         isWithdrawClaimed[period][sender] = true;
FirelightVault.sol:864         if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);
FirelightVault.sol:865         if (isWithdrawClaimed[periods[i]][to]) revert AlreadyClaimedPeriod(periods[i]);
FirelightVault.sol:870         isWithdrawClaimed[periods[i]][from] = true;

The rescue empties the source account and then marks it claimed:

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

true here means "this account has been settled for this period", but the account was emptied, not paid. The write path never consults the flag. _requestWithdraw targets currentPeriod() + 1, so a second request made before the originating period rolls lands in the same period and is credited normally:

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

Both release paths then reject it. claimWithdraw reverts at line 794 before it ever reaches the conversion, and a second rescueWithdrawFromBlocklisted reverts at line 864 on the identical flag. rescueSharesFromBlocklisted (line 818) moves share balances, not withdrawal entitlements, and the account's balanceOf is already zero, so it cannot help either. Recovery requires a contract upgrade.

withdrawalsOf performs the same conversion as claimWithdraw but does not consult the flag, so the view surface keeps reporting the amount as owed while both release paths revert: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L466-L474

Impact Details

Permanent freezing of user funds. A staker who is blocklisted, rescued and subsequently cleared loses the entire value of any new withdrawal request they make before that period ends, with no on-chain recovery. In the run below the amount is 500.000000 FXRP, but the mechanism scales with the position: it strands whatever the second request is worth, not a rounding residue. The loss is silent, because withdrawalsOf() continues to report the funds as claimable, and it is triggered by an ordinary user action that the contract accepts without warning. pendingWithdrawAssets is decremented only inside the now-unreachable claimWithdraw and inside payout, so the stranded amount stays subtracted from totalAssets() (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L427-L429) permanently, understating the vault for every remaining staker. FirelightVault is the V2 implementation for the live predeposit vault at 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3, which returns totalAssets() = 56,601,078,263,347 (56.6M FXRP at 6 decimals) on a read taken 2026-08-19, so this lands on a vault holding real staker capital at upgrade time.

Feasibility Limitations check. No trusted role misbehaves, exceeds its privilege or is misconfigured anywhere in this sequence. Blocklisting, rescuing and un-blocklisting are fixed documented operations, not configuration values or discretionary parameters, and each is performed correctly here; nothing requires a trusted party to act maliciously or incorrectly, and no privilege is exceeded. The protocol breaks while every party behaves exactly as intended. The action that loses the funds is a permissionless redeem by an account that is not blocklisted at that moment. The precondition is a blocklisting that is later reversed, which is the ordinary lifecycle of a screening false positive. That path is provisioned and live today, not hypothetical: on the same vault hasRole(BLOCKLIST_ROLE, 0x189993f3B53284c7e5C02b058EFC6618F78D0cEA) and hasRole(RESCUER_ROLE, 0xC115757266b902d4b1284eA24DBbE557C15E963E) both return true. Attack investment: none. Chain rollbacks and pre-impact monitoring: not cited; the state transition is committed in the user's own transaction and there is no pause or guardian that inspects it. An informed user could avoid it by waiting for the next period, but nothing on-chain or in the view surface tells them to.

Recommendation

Clear the flag when the rescue empties the source account, since the account has not been paid:

The flag's purpose is to prevent double-claiming a balance, and after the rescue the source balance is zero, so claimWithdraw's own assets == 0 check at line 803 already provides that protection. If the marker is needed for accounting, track rescued periods in a separate mapping that _requestWithdraw resets on a fresh credit, and make withdrawalsOf return zero whenever isWithdrawClaimed is set so the view cannot report unreachable funds.

References

  • rescue sets the terminal flag: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L845-L876

  • claimWithdraw rejects on it: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L788-L811

  • _requestWithdraw credits without consulting it: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L994-L1028

  • storage declaration, no other writer: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVaultStorage.sol#L71

Proof of Concept

Proof of Concept

Local Hardhat only; no mainnet or public-testnet transaction was sent. Clone the in-scope repository at the audited revision, install, save the file below as test/poc_sticky_claim.js, and run it. It uses only the repository's own fixtures.

Observed output:

The rescued leg is deliberately excluded from the loss: the safe address claims it in full, and the assertion safeGot == safeOwed proves it. The 500.000000 FXRP attributed as lost is only the second request, which the account made after being cleared, and the final assertions confirm the account holds 0 shares and 0 tokens while pendingWithdrawAssets still carries the full amount.

Was this helpful?