#55046 [SC-Insight] claimed rewards paid in legacy wnat after an upgrade are silently ignored by the balance delta fix

Submitted on Sep 21st 2025 at 17:02:08 UTC by @Disqualified-User for Mitigation Audit | Flare | FAssets

  • Report ID: #55046

  • Report Type: Smart Contract

  • Report severity: Insight

  • Target: https://github.com/flare-foundation/fassets/commit/92e1e2bdc6e8f75f61cfd9f10ddb05df4a7c8c6b

  • Impacts: Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

The mitigation for #45893 changed CollateralPool.claimDelegationRewards and CollateralPool.claimAirdropDistribution to ignore the external call’s return value and instead compute a balance delta on the pool’s configured wNat token:

  • read balanceBefore = wNat.balanceOf(address(this))

  • call the external claim(...)

  • read balanceAfter = wNat.balanceOf(address(this))

  • compute claimed = balanceAfter - balanceBefore

  • update totalCollateral += claimed and emit ClaimedReward

That closes the original vulnerability. However, it introduces a new, low-severity failure mode whenever the protocol upgrades wNat (via upgradeWNatContract) and any reward source (a distribution or reward manager) continues paying out in the legacy wNat contract for a period of time after the upgrade. In that situation the pool does receive tokens (the old wNat), but the pool’s configured wNat balance does not change, so the measured delta is zero. totalCollateral remains unchanged and the ClaimedReward event reports 0. No value is lost, but the economic benefit of the claim is not delivered to users or reflected in protocol-level accounting until an out-of-band cleanup happens.

Severity

Low – Contract fails to deliver promised returns, but doesn’t lose value. The pool holds the tokens (legacy wNat), yet totalCollateral is not credited and the system behaves as though nothing was received.

Vulnerability Details

The new claim logic credits only by the configured, current wNat balance delta. This is correct when the reward source transfers the same token instance. The code path looks like this (post-fix for #45893):

The contract also supports upgrading the pool’s wNat instance:

After an upgrade, if a distribution or reward manager remains configured to send tokens from the old wNat, the pool’s balance of that old token increases while the configured wNat.balanceOf(this) does not change. The fix therefore computes claimed == 0, and the credit is skipped. The funds sit on the pool contract under the legacy token address, but they are invisible to pool math and to protocol-level accounting, and exits won’t reflect them. This is exactly “returns not delivered” with no value loss.

Impact Details

Users rightly expect that a successful claim increases the pool’s collateral and immediately benefits them in exits and health metrics. After a wNat upgrade, if any reward source keeps paying in the legacy wNat, claims appear to work on-chain (the external claim succeeds and tokens move), but totalCollateral remains unchanged and ClaimedReward logs 0. Holders temporarily get none of the expected economic benefit. The value is not stolen; it is stranded and untracked until an operator notices and takes corrective action.

Recommendation

Make this failure mode explicit and impossible to miss. The cleanest approach is:

  1. Track the previous wNat as legacyWNat when upgradeWNatContract executes.

  2. In each claim function, record legacyBefore = legacyWNat.balanceOf(this) before the external call and legacyAfter after it.

  3. If legacyAfter > legacyBefore, revert the claim with a clear error (for example, claim-paid-in-legacy-wnat). That makes misconfiguration loud, so operators fix the reward source to the new wNat.

Optionally, emit a specific event if you prefer not to revert, but don’t silently accept and ignore a nonzero legacy delta.

This preserves the security property of the fix while preventing “we claimed but nothing changed” confusion following a wNat upgrade.

References

  • Smart Contract - Fix of Report - 45893: https://github.com/flare-foundation/fassets/commit/92e1e2bdc6e8f75f61cfd9f10ddb05df4a7c8c6b

  • Flare Mitigation Audit: https://immunefi.com/audit-competition/flare-fassets--mitigation-audit/scope/#top

Proof of Concept

The PoC demonstrates that when a distribution pays out in legacy wNat (wNat v1) while the pool is configured to use wNat v2, the pool receives tokens but totalCollateral remains unchanged because the balance-delta is measured against the configured wNat only.

The PoC uses the repository’s test harness and includes two ERC-20 mocks to represent wNat v1 (legacy) and wNat v2 (current). The pool is configured to use wNat v2. A distribution intentionally transfers wNat v1 to the pool during claimAirdropDistribution. The test shows the pool’s balance of legacy wNat increases by 10e18, but totalCollateral and the current wNat balance remain unchanged and the claim credits zero.

Files (as used in the PoC):

contracts/test/AssetManagerProbe.sol

contracts/test/FakeDistributionLegacyWnat.sol

test/unit/fasset/implementation/CollateralPool.LegacyWNatClaim.spec.ts

Step-by-step reproduction:

1

1. Clone and install

Clone the repository and install dependencies.

2

2. Add PoC files

Save the two contracts under contracts/test/ and the test file under test/unit/fasset/implementation/.

3

3. Compile

Run:

4

4. Run the test

Run:

5

5. Observe

The pool’s balance of legacy wNat increases by 10e18, while the configured wNat balance and totalCollateral remain unchanged (00). This demonstrates “returns not delivered” even though the contract holds the tokens.

Patch

The submitted patch adds a legacyWNat slot set during upgradeWNatContract and makes both claim functions revert if a legacy wNat balance increases during the claim. This prevents silent under-crediting and forces misconfigured reward sources to be updated after a wNat upgrade.

Key diff excerpt (conceptual):

The patch keeps the security benefits of the balance-delta fix, adds zero gas cost on the common path (no legacy configured), and turns a long-tail integration hazard into an explicit, debuggable error.

Last updated

Was this helpful?