52890 sc low no recipient yield distribution locks yield tokens on arctoken efftotal 0
Submitted on Aug 14th 2025 at 02:11:17 UTC by @nitinaimshigh for Attackathon | Plume Network
Report ID: #52890
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/arc/src/ArcToken.sol
Impacts: Temporary freezing of funds for at least 24 hours
Description
Brief / Intro
ArcToken.distributeYield pulls the yield token from the distributor (treasury) before it computes the “eligible supply.” If every holder is ineligible (e.g., all blacklisted/sanctioned so effTotal == 0), no recipients are credited, yet the transferred amount remains stranded on the ArcToken contract address with no withdrawal mechanism. In production, anyone with YIELD_DISTRIBUTOR_ROLE (or a compromised treasury) could permanently lock arbitrary amounts of the yield token on ArcToken by calling distributeYield during an all-ineligible state, resulting in irreversible loss of unclaimed yield and potential depletion of the protocol’s yield funds.
Relevant function excerpt:
function distributeYield(
uint256 amount
) external onlyRole(YIELD_DISTRIBUTOR_ROLE) nonReentrant {
ArcTokenStorage storage $ = _getArcTokenStorage();
if (amount == 0) {
revert ZeroAmount();
}
uint256 supply = totalSupply();
if (supply == 0) {
revert NoTokensInCirculation();
}
address yieldTokenAddr = $.yieldToken;
if (yieldTokenAddr == address(0)) {
revert YieldTokenNotSet();
}
ERC20Upgradeable yToken = ERC20Upgradeable(yieldTokenAddr);
yToken.safeTransferFrom(msg.sender, address(this), amount);
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);
}
}
if (effectiveTotalSupply == 0) {
emit YieldDistributed(0, yieldTokenAddr);
return;
}
uint256 lastProcessedIndex = holderCount > 0 ? holderCount - 1 : 0;
for (uint256 i = 0; i < lastProcessedIndex; i++) {
address holder = $.holders.at(i);
if (!_isYieldAllowed(holder)) {
continue;
}
uint256 holderBalance = balanceOf(holder);
if (holderBalance > 0) {
uint256 share = (amount * holderBalance) / effectiveTotalSupply;
if (share > 0) {
yToken.safeTransfer(holder, share);
distributedSum += share;
}
}
}
if (holderCount > 0) {
address lastHolder = $.holders.at(lastProcessedIndex);
if (_isYieldAllowed(lastHolder)) {
uint256 lastShare = amount - distributedSum;
if (lastShare > 0) {
yToken.safeTransfer(lastHolder, lastShare);
distributedSum += lastShare;
}
}
}
emit YieldDistributed(distributedSum, yieldTokenAddr);
}Vulnerability Details
Root cause: In ArcToken.distributeYield, the contract pulls the yield token from the distributor (treasury) before validating that there is any eligible supply to receive it.
When all holders are ineligible (e.g., every account fails either YieldBlacklistRestrictions or the global isYieldAllowed check), the function proceeds to compute an effective total of 0, distributes to no one, and then returns — leaving the transferred amount stranded on the ArcToken contract address. There is no refund path so the funds become permanently locked.
What the execution shows:
The caller approves and distributeYield(amount) is invoked.
The first action is pulling funds:
ERC20Mock::transferFrom(treasury, ArcToken, 5e20) -> true
All recipients are ineligible (isYieldAllowed(...) -> false for owner, alice, bob, charlie).
The function emits:
YieldDistributed(amount: 0, token: ERC20Mock)
Post-state: the yield token balance of ArcToken equals the whole amount, e.g.:
ERC20Mock::balanceOf(ArcToken) -> 500000000000000000000
This results in a retained balance on the ArcToken contract with no mechanism to retrieve it.
Impact Details
What can be lost? The entire yield-asset amount passed to distributeYield(amount) when the effective eligible supply is 0. Because the function pulls tokens from the distributor (e.g., treasury) before checking eligibility and credits no one when effTotal == 0, the full amount becomes permanently stranded at ArcToken.
If the treasury holds 5,000,000 units of the yield token and has unlimited approval to ArcToken, then a single call with amount = 5,000,000 while everyone is ineligible locks the full 5,000,000 on ArcToken.
References
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/arc/src/ArcToken.sol#L388-L460
Proof of Concept
The following Forge test demonstrates the issue. The second test makes every account ineligible, calls distributeYield from the treasury and asserts that no one received funds while the ArcToken contract retained the tokens (unexpected behavior).
Run the test: forge test --match-test test_DistributeYield_NoEligibleSupplyCreditsNoOne -vvvv
Was this helpful?