51802 sc low temporary freeze of rewards is possible if efficientsupply 0
Submitted on Aug 5th 2025 at 21:33:32 UTC by @Santi for Attackathon | Plume Network
Report ID: #51802
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/arc/src/ArcToken.sol
Impacts: Permanent freezing rewards
Description
Brief/Intro
Rewards tokens may be stuck on the contract. This can happen if the effectiveSupply for distribution is equal to 0.
Vulnerability Details
For yield token distribution, the user with role YIELD_DISTRIBUTOR_ROLE must call ArcToken.distributeYield() or ArcToken.distributeYieldWithLimit().
The function ArcToken.distributeYield() transfers rewards token before the effectiveTotalSupply == 0 check, which returns from the function if it is true.
Code snippet:
function distributeYield(
uint256 amount
) external onlyRole(YIELD_DISTRIBUTOR_ROLE) nonReentrant {
ArcTokenStorage storage $ = _getArcTokenStorage();
....
ERC20Upgradeable yToken = ERC20Upgradeable(yieldTokenAddr);
yToken.safeTransferFrom(msg.sender, address(this), amount); // <- transfer yield tokens to contract
uint256 distributedSum = 0;
uint256 holderCount = $.holders.length();
if (holderCount == 0) {
emit YieldDistributed(0, yieldTokenAddr);
return;
}
uint256 effectiveTotalSupply = 0;
for (uint256 i = 0; i < holderCount; i++) {
address holder = $.holders.at(i);
if (_isYieldAllowed(holder)) {
effectiveTotalSupply += balanceOf(holder); // <- calculate effectiveTotalSupply
}
}
if (effectiveTotalSupply == 0) {
emit YieldDistributed(0, yieldTokenAddr);
return; // <- return from function if effectiveSupply == 0, but contract stores yield tokens.
}
...
}It is incorrect to transfer yield tokens before the effectiveTotalSupply check. If effectiveSupply is equal to zero (for example, if all holders are blacklisted for yield distribution), the yield tokens will be stuck on the contract.
For comparison, ArcToken.distributeYieldWithLimit() checks that effectiveSupply != 0 before tokens transfer. Link:
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/arc/src/ArcToken.sol#L518-L528
Impact Details
It is possible to lose all yield tokens that the YIELD_DISTRIBUTOR_ROLE attempted to distribute. This is a rare case because:
All token holders must be ineligible for yield tokens (e.g., blacklisted).
YIELD_DISTRIBUTOR_ROLEmust call this function when all holders are ineligible.
Recovery is possible via ArcToken.distributeYieldWithLimit(), but with constraints:
A new token holder must appear, and the new token holder must be at index > 0 (for example, it can be admin).
The new token holder must not be blacklisted.
Then YIELD_DISTRIBUTOR_ROLE can call ArcToken.distributeYieldWithLimit() and pass the correct totalAmount, startIndex, maxHolders.
Recovery is possible, but yield tokens will be temporarily frozen and distribution will be incorrect. That is why this finding is rated Low and ArcToken.distributeYield() should handle this case correctly.
References
distributeYield() function: https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/arc/src/ArcToken.sol#L388
Proof of Concept
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import { ArcToken } from "../src/ArcToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import { Test } from "forge-std/Test.sol";
import { console } from "forge-std/console.sol";
import { RestrictionsRouter } from "../src/restrictions/RestrictionsRouter.sol";
import { WhitelistRestrictions } from "../src/restrictions/WhitelistRestrictions.sol";
import { YieldBlacklistRestrictions } from "../src/restrictions/YieldBlacklistRestrictions.sol";
contract ArcTokenRewardsLostTest is Test {
ArcToken public token;
ERC20Mock public yieldToken;
RestrictionsRouter public router;
WhitelistRestrictions public whitelistModule;
YieldBlacklistRestrictions public yieldBlacklistModule;
address public owner;
uint256 public constant INITIAL_SUPPLY = 1000e18;
uint256 public constant YIELD_AMOUNT = 1000e18;
bytes32 public constant TRANSFER_RESTRICTION_TYPE = keccak256("TRANSFER_RESTRICTION");
bytes32 public constant YIELD_RESTRICTION_TYPE = keccak256("YIELD_RESTRICTION");
function setUp() public {
owner = address(this);
// Deploy tokens
yieldToken = new ERC20Mock();
yieldToken.mint(owner, 1_000_000e18);
// Deploy infrastructure
router = new RestrictionsRouter();
router.initialize(owner);
whitelistModule = new WhitelistRestrictions();
whitelistModule.initialize(owner);
yieldBlacklistModule = new YieldBlacklistRestrictions();
yieldBlacklistModule.initialize(owner);
// Deploy ArcToken
token = new ArcToken();
token.initialize(
"Arc Token",
"ARC",
INITIAL_SUPPLY,
address(yieldToken),
owner,
18,
address(router)
);
// Link modules
token.setRestrictionModule(TRANSFER_RESTRICTION_TYPE, address(whitelistModule));
token.setRestrictionModule(YIELD_RESTRICTION_TYPE, address(yieldBlacklistModule));
// Whitelist owner
whitelistModule.addToWhitelist(owner);
}
function test_AllHoldersBlacklisted_100PercentLoss() public {
// Blacklist all holders
yieldBlacklistModule.addToBlacklist(owner);
// Try to distribute rewards
yieldToken.approve(address(token), YIELD_AMOUNT);
uint256 contractBalanceBefore = yieldToken.balanceOf(address(token));
token.distributeYield(YIELD_AMOUNT);
uint256 contractBalanceAfter = yieldToken.balanceOf(address(token));
uint256 lostAmount = contractBalanceAfter - contractBalanceBefore;
console.log("Distributed yield amount:", YIELD_AMOUNT);
console.log("Lost rewards stuck in contract:", lostAmount);
console.log("Loss percentage:", (lostAmount * 100) / YIELD_AMOUNT);
// Assert that ALL rewards are stuck
assertEq(lostAmount, YIELD_AMOUNT, "All rewards should be stuck in contract");
}
}Was this helpful?