52165 sc high user can t claim reward erc20 tokens since rewards transfer will revert

Submitted on Aug 8th 2025 at 12:24:06 UTC by @WinSec for Attackathon | Plume Network

  • Report ID: #52165

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/RewardsFacet.sol

  • Impacts:

    • Theft of unclaimed yield

Description

Brief/Intro

When users call claim it calls _finalizeRewardClaim which further calls _transferRewardFromTreasury which finally calls distributeReward on the PlumeStakingRewardTreasury contract. That call can revert due to this check in the treasury:

if (!_isRewardToken[token]) {
    revert TokenNotRegistered(token);
}

Vulnerability Details

Reward tokens are added/removed in the RewardFacet contract via addRewardToken / removal functions and that facet maintains a mapping:

$.isRewardToken[token] = true;

When tokens are removed the mapping is set to false. However, the treasury contract uses a different mapping (_isRewardToken) and enforces the check above before distributing ERC20 rewards. If a token was removed in the RewardFacet but not registered in the treasury's _isRewardToken mapping (or vice versa), attempting to claim rewards will revert.

Relevant code paths:

  • RewardFacet ultimately calls:

function _transferRewardFromTreasury(address token, uint256 amount, address recipient) internal {
    address treasury = getTreasuryAddress();
    if (treasury == address(0)) {
        revert TreasuryNotSet();
    }

    // Make the treasury send the rewards directly to the user
    IPlumeStakingRewardTreasury(treasury).distributeReward(token, amount, recipient);
}
  • PlumeStakingRewardTreasury's ERC20 distribution branch:

} else {
    // ERC20 token distribution
    if (!_isRewardToken[token]) {
        revert TokenNotRegistered(token);
    }

    uint256 balance = IERC20(token).balanceOf(address(this));
    if (balance < amount) {
        revert InsufficientBalance(token, balance, amount);
    }

    // Use SafeERC20 to safely transfer tokens
    SafeERC20.safeTransfer(IERC20(token), recipient, amount);
}

Because the treasury checks its own _isRewardToken mapping, tokens added/removed only in RewardFacet may not be reflected in the treasury, causing a revert during claim.

The intended design appears to add reward tokens via the RewardFacet; therefore the treasury-side token existence check is unnecessary for ERC20 distribution (this is supported by the fact the native token branch doesn't perform an equivalent check).

Impact Details

Users won't be able to claim ERC20 reward tokens if the treasury's _isRewardToken mapping does not mark the token as registered — resulting in denial of reward claims and potential loss/theft of unclaimed yield.

References

https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/PlumeStakingRewardTreasury.sol#L185-L187

Proof of Concept

1

Add reward token in RewardFacet

RewardToken is added in the RewardFacet:

$.rewardTokens.push(token);
$.isRewardToken[token] = true;
2

User accrues rewards

A user accrues some rewards over time for that token.

3

RewardToken removed in RewardFacet

The token is removed in the RewardFacet:

$.rewardTokens.pop();
// Update the mapping
$.isRewardToken[token] = false;
4

User calls claim()

User calls claim():

if (reward > 0) {
    _finalizeRewardClaim(token, reward, msg.sender);
}

This calls:

_transferRewardFromTreasury(token, totalAmount, recipient);

which calls the treasury:

IPlumeStakingRewardTreasury(treasury).distributeReward(token, amount, recipient);

The treasury will revert because the token isn't registered in its _isRewardToken mapping:

if (!_isRewardToken[token]) {
    revert TokenNotRegistered(token);
}

Was this helpful?