50735 sc high some yield tokens will be stuck in contract due to incorrect lastprocessedindex calculation

Submitted on Jul 28th 2025 at 07:04:09 UTC by @maggie for Attackathon | Plume Network

  • Report ID: #50735

  • Report Type: Smart Contract

  • Report severity: High

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

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

Yield tokens are distributed based on proportion. Due to rounding operations in the calculation, there may be some remaining tokens at the end; those remaining tokens are sent to the last holder. In the contract, those remaining tokens are sent to the holder at lastProcessedIndex. If this holder is in the blacklist, the remaining tokens will not be sent and will get stuck in the contract.

Vulnerability Details

In ArcToken.sol#distributeYield(), lastProcessedIndex is the index of the last holder, not the last non-blacklist holder. If the last holder is in the blacklist, remaining yield tokens will be stuck in the contract.

Example scenario (7 holders: Owner, Alice, Bob, Charlie, Lily, Lucy, Tom — Tom is blacklisted). Each holds 1e18 tokens. Distributing 1000 yield tokens results in 166 tokens per eligible holder × 6 = 996 distributed; 4 yield tokens remain. The contract attempts to send the leftover 4 tokens to the holder at lastProcessedIndex — but if that holder (Tom) is blacklisted, those 4 tokens remain in the contract.

Recommended fix (ensure lastProcessedIndex points to the last non-blacklisted holder and use effective supply computed over allowed holders):

Suggested patch: distributeYield(...) in ArcToken.sol
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;
    uint256 lastProcessedIndex = 0;
    for (uint256 i = 0; i < holderCount; i++) {
        address holder = $.holders.at(i);
        if (_isYieldAllowed(holder)) {
            effectiveTotalSupply += balanceOf(holder);
            lastProcessedIndex = i;
        }
    }

    if (effectiveTotalSupply == 0) {
        emit YieldDistributed(0, yieldTokenAddr);
        return;
    }

    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);
}

Impact Details

Remaining yield tokens will be left in the contract. Over time, as more distributions occur, accumulated stuck balances will grow and make the available yield pool less than expected.

References

  • https://github.com/immunefi-team/attackathon-plume-network/blob/main/arc/src/ArcToken.sol#L430

Proof of Concept

Add a new file distribute.t.sol to /test and run the test: forge test --via-ir --match-path test/distribute.t.sol --match-contract DistributeTest --match-test test_YieldDistribution_WithBlacklist_last -vv

Initial yield balance is 7000000000000000000, after distribute, total yield balance is 6999999999999999996.

PoC test code:

Was this helpful?