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

89847 sc medium unclearable claim flag in rescuewithdrawfromblocklisted causes permanent freezing of staker withdrawals

Submitted on Aug 21st 2026 at 13:45:28 UTC by @Emmy for Audit Comp | Firelight

  • Report ID: #89847

  • Report Type: Smart Contract

  • Report severity: Medium

  • 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 permanently marks the rescued account as having claimed the affected withdrawal period, and no function anywhere in the codebase ever clears that mark. The mark is keyed by (period, account) rather than by the amount that was actually rescued, so it does not merely neutralise the balance that was moved: it disqualifies the account from that period's withdrawal bucket for the lifetime of the contract. If the account is later removed from the blocklist and registers a new withdrawal while the same period is still current, those assets become permanently unclaimable. In production this permanently freezes staker collateral. The tokens remain in the vault, the vault continues to report them as owed to the user through withdrawalsOf, and no role, rescue control, or subsequent operator action can release them. The frozen amount is also excluded from totalAssets() forever, which suppresses share value for every remaining staker and understates the collateral that backs underwriting capacity.

Vulnerability Details

Background: how withdrawals settle

FirelightVault uses a two-phase, period-based withdrawal model. Calling withdraw() or redeem() does not transfer assets. It burns the caller's shares immediately and registers a claim against a future period bucket, computed in _requestWithdraw (FirelightVault.sol:994):

uint256 period = currentPeriod() + 1;
uint256 sharesWithdraw = _convertToSharesTotals(
    assets, withdrawShares[period], withdrawAssets[period], Math.Rounding.Floor
);
withdrawAssets[period] += assets;
withdrawShares[period] += sharesWithdraw;
withdrawSharesOf[period][receiver] += sharesWithdraw;

pendingWithdrawAssets += assets;
...
_update(owner, address(0), shares);

Two properties of this function matter for the vulnerability. First, every withdrawal made during the same period lands in the same bucket, because period is always currentPeriod() + 1, and the credit uses +=, so repeated requests accumulate. Second, the credit is applied to withdrawSharesOf[period][receiver] with no reference to any claim state, so a fresh balance can be written into a bucket the account is already barred from.

The user later collects with claimWithdraw(period) once the period has elapsed (FirelightVault.sol:788):

The check at line 794 is an unconditional veto. It runs before any amount is computed, so once the flag is set the function can never pay out for that period regardless of what the account is owed.

The defect

rescueWithdrawFromBlocklisted (FirelightVault.sol:845) moves a blocklisted account's pending withdrawal to a safe destination:

Line 870 is the defect, for four compounding reasons.

1. The flag is never cleared. isWithdrawClaimed is declared in FirelightVaultStorage.sol:71 and written in exactly two places in the entire in-scope tree, line 806 in claimWithdraw and line 870 here. It is set to false nowhere. removeFromBlocklist (FirelightVault.sol:560) reverses the blocklist designation and touches nothing else:

An account restored to good standing therefore carries the claim flag for every period it was ever rescued from, permanently.

2. The flag is redundant. Line 869 already sets withdrawSharesOf[periods[i]][from] = 0 in the same iteration. That alone makes a double claim impossible: with a zero share balance, claimWithdraw computes assets == 0 and reverts NoWithdrawalAmount(period). Line 870 adds no protection that line 869 does not already provide, and it is the sole cause of the permanent lock. This is demonstrated directly by the negative control below, where deleting line 870 leaves the double-claim guard fully intact while restoring the user's access to their own funds.

3. The flag is keyed by period, not by amount. The rescue moves a specific balance, but the flag disqualifies the account from the entire bucket. Since _requestWithdraw credits withdrawSharesOf[period][receiver] += sharesWithdraw for every withdrawal made during the same period, any balance registered after the rescue lands in a bucket the account can never claim. The vault records the debt and then refuses to honour it: after the second withdrawal, withdrawalsOf(bucket, victim) returns the full owed amount while claimWithdraw reverts.

4. The flag blocks its own remediation. The natural recovery would be a second rescue moving the stranded balance to a fresh address. That path is gated at line 864 on the very flag line 870 set, so it reverts AlreadyClaimedPeriod. The escape hatch is locked by its own key. rescueSharesFromBlocklisted does not help either, because the shares backing the stranded balance were already burned by _requestWithdraw at request time.

Sequence that triggers it

Every step below is a supported operation performed correctly. No role acts outside its documented purpose, and the step that destroys the value is the user's own ordinary call.

  1. A staker calls withdraw(). The request registers in bucket p = currentPeriod() + 1.

  2. BLOCKLIST_ROLE calls addToBlocklist(staker) following a compliance signal.

  3. RESCUER_ROLE calls rescueWithdrawFromBlocklisted(staker, custody, [p]), which is precisely what the function exists to do. Line 870 sets isWithdrawClaimed[p][staker] = true.

  4. The compliance review clears the staker. BLOCKLIST_ROLE calls removeFromBlocklist(staker), the protocol's first-class mechanism for reversing a designation. The claim flag survives.

  5. The staker, now unblocked and with no on-chain indication of a problem, calls withdraw() again. The period has not rolled over, so the credit lands in the same bucket p.

  6. Once p has elapsed, claimWithdraw(p) reverts AlreadyClaimedPeriod(p) and will do so forever.

The window for step 5 is a full period, which the contract enforces to be at least one day (SMALLEST_PERIOD_DURATION) and which the project's own fixtures set to one week.

A second reachable variant exists because _requestWithdraw credits the receiver, not the owner. Once the account is un-blocklisted, any third party calling withdraw(assets, receiver = affectedAccount, owner = self) also strands those assets in the same bucket.

Why this is not covered by the published known issues

The known issues concern the permissioned MVP trust model, operator-authored cover orders, the first-loss buffer's USD peg and its unescrowed wallet, collateral drawdown, uncapped aggregate claims, permissioned assessment and payout, and off-chain distribution. None of them concerns the blocklist or rescue machinery. The 0xMacro A-1 report of 29 July 2026 covered FirelightVault.sol and raised M-1 (leverage), M-2 (batch settle atomicity), L-1 (zero-share deposit), L-2 (concentration cap), L-3 (grace bound), L-4 (CAR floor), G-1 (payout period scan) and I-1 through I-11. None of them is this defect, and none of its accepted-risk entries covers it.

This is also distinct from the historical fix in commit a2def47, "Fix rescuing withdrawal to a user that already claimed them", which introduced the to-side guard now at line 865. The from-side write at line 870 was left untouched by that change.

Why this is an enforcement failure rather than intended behaviour

No privileged role misbehaves at any point. RESCUER_ROLE performs a rescue, which is its exact documented purpose. BLOCKLIST_ROLE reverses a designation using a function that exists solely for that purpose. The value is destroyed by the affected user's own unprivileged withdraw() call. The constraint that fails is the double-claim guard, which over-constrains and freezes funds that no role intended to freeze and no role can release.

The project's own test suite confirms this behaviour was never intended. test/rescue.js contains twenty rescue cases covering role gating, zero addresses, empty amounts, already-claimed periods, successful rescues, and the rescuer claiming rescued balances. removeFromBlocklist does not appear anywhere in that file, so the un-blocklist path following a rescue is untested rather than deliberate.

Impact Details

In-scope impact: permanent freezing of funds.

Direct loss to the affected staker. In the proof of concept, 40,000 fXRP is permanently unclaimable. That is 26.66% of the affected user's 150,000 fXRP position and 0.79% of the 5.05M fXRP vault. The loss scales with the size of the post-rescue withdrawal, so a user who requests their full remaining balance loses all of it.

The freeze is permanent. All three recovery paths were attempted in the test and all three fail:

  • claimWithdraw(period) reverts AlreadyClaimedPeriod at line 794.

  • A second rescueWithdrawFromBlocklisted reverts AlreadyClaimedPeriod at line 864, because the rescue path reads the flag it previously set.

  • rescueSharesFromBlocklisted succeeds against the user's residual share balance and restores nothing, because the shares backing the stranded amount were burned at request time.

No role in the system can release the funds. Recovery requires a contract upgrade.

Repeatable across accounts. The proof of concept runs the sequence against five independent accounts in a single bucket and strands 200,000 fXRP, which is 3.77% of vault TVL. The defect recurs for every account that passes through a rescue followed by an un-blocklisting, and the totals accumulate permanently.

Collateral damage to every other staker. The stranded assets remain physically in the vault but stay counted in pendingWithdrawAssets forever. Because totalAssets() is defined at FirelightVault.sol:427 as:

the frozen amount is excluded from share value indefinitely. The proof of concept shows the vault holding 4,990,000 fXRP while reporting totalAssets() of 4,890,000 fXRP against pendingWithdrawAssets of 100,000 fXRP.

Knock-on effect on underwriting capacity. The permanently suppressed total propagates into totalAssetsAt() (FirelightVault.sol:456), which is the exposure snapshot consumed by CoverOrderAllocator._computeAvailableCapacity. The protocol therefore permanently under-reports the collateral it actually holds and under-sells cover capacity against it.

Precision on permanence. Bucket p remains slashable by payout() for capture period p - 1 while currentPeriod is in {p - 1, p}, so during that window the assets are still usable as payable collateral. Any residue surviving that window is permanently stuck, which is the ordinary outcome since no incident is required for this defect to occur.

Severity rationale. The impact maps to permanent freezing of funds, with every recovery path closed and a loss that is a material proportion of the affected position. It is rated Medium rather than High or Critical because the per-instance loss is bounded to one account's post-rescue withdrawal within a single period, it produces no solvency break or capacity over-sell, and the triggering sequence requires a rescue followed by an un-blocklisting, which is a supported but uncommon operational flow. It is rated above Low because the freeze is permanent and verified, the proportion is well beyond dust scale, and the party who loses is an unprivileged user who does nothing wrong while every operator acts correctly.

References

  • contracts/core/FirelightVault.sol:870, the unclearable write inside rescueWithdrawFromBlocklisted

  • contracts/core/FirelightVault.sol:864-865, the rescue preconditions that read the same flag

  • contracts/core/FirelightVault.sol:869, the share zeroing that already prevents a double claim

  • contracts/core/FirelightVault.sol:788-812, claimWithdraw and its unconditional flag check at 794

  • contracts/core/FirelightVault.sol:560-563, removeFromBlocklist

  • contracts/core/FirelightVault.sol:994-1027, _requestWithdraw and its += bucket credit

  • contracts/core/FirelightVault.sol:427-429, totalAssets

  • contracts/core/FirelightVault.sol:456-458, totalAssetsAt

  • contracts/core/FirelightVaultStorage.sol:71, the isWithdrawClaimed declaration

  • test/rescue.js, the project's rescue test suite, which never exercises removeFromBlocklist

  • Commit a2def47, "Fix rescuing withdrawal to a user that already claimed them", which addressed the to side only

Proof of Concept

Proof of Concept

The test exercises the real FirelightVault implementation behind a real ERC1967Proxy. No Firelight logic is mocked. The only substituted contract is the external ERC-20 collateral, a plain six-decimal OpenZeppelin ERC20 standing in for FXRP. There is no vm.store anywhere: the frozen state is produced entirely by real calls. Trusted roles appear only in setup performing their documented duties, and the call that destroys the value is the affected user's own unprivileged withdraw().

The suite contains three tests. The main test proves the freeze and closes every recovery path. The second proves repeatability across five independent accounts. The third is a negative control that runs the identical sequence against the same contract with line 870 deleted, proving the finding targets that single line.

Setup

The repository ships with Hardhat only, so the following adds a self-contained Foundry harness. src is scoped to contracts/core and libs deliberately avoids the repository's existing ./lib directory, which holds the project's JavaScript helpers. The Hardhat build is unaffected.

The generated control differs from the in-scope file by exactly one line:

Test file

Save as repo/test-foundry/RescuePermanentLock.t.sol:

Command

Output

Reading the output

staker1 is an untouched control account that performs the identical withdrawal into the identical bucket without the rescue step, and claims 60000000000 successfully. That isolates the rescue as the sole cause of the failure.

The affected user is owed 40000000000 according to the vault's own withdrawalsOf accessor, and all three recovery attempts revert AlreadyClaimedPeriod. The vault holds 4990000000000 tokens while reporting totalAssets() of 4890000000000.

The negative control claims exactly 40000000000 against the contract with line 870 removed. With that fix applied to the in-scope contract, the three expectRevert(AlreadyClaimedPeriod) assertions in the main test fail, which confirms the test targets the defect itself rather than incidental behaviour.

Was this helpful?