50195 sc low unfair yield distribution due to remainder allocation to last holder

Submitted on Jul 22nd 2025 at 13:05:53 UTC by @AasifUsmani for Attackathon | Plume Network

  • Report ID: #50195

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

    • Theft of unclaimed yield

Description

Brief/Intro

The distributeYield() function in ArcToken contains a systematic flaw where any rounding remainder from yield distribution calculations is allocated to the last holder in the iteration order, rather than being distributed proportionally. Token owners can set any ERC20 token as the yield token, so token decimals can be 0, 6, 8, 18, etc., magnifying the unfair advantage. Yield token decimals used by RWA token owners can amplify this error significantly.

Vulnerability Details

Root Cause Analysis

The vulnerability exists specifically in the distributeYield() function where holders are processed sequentially, and any remainder from rounding errors is implicitly given to the last processed holder:

function distributeYield(uint256 amount) external onlyRole(YIELD_DISTRIBUTOR_ROLE) nonReentrant {
    // ... validation logic ...
    
    uint256 distributedSum = 0;
    uint256 lastProcessedIndex = holderCount > 0 ? holderCount - 1 : 0;
    
    // Process all holders except the last one
    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; // ❌ Rounds down
            if (share > 0) {
                yToken.safeTransfer(holder, share);
                distributedSum += share; // ❌ Accumulates rounding losses
            }
        }
    }
    
    // ❌ CRITICAL FLAW: Last holder gets remainder instead of proportional share
    if (holderCount > 0) {
        address lastHolder = $.holders.at(lastProcessedIndex);
        if (_isYieldAllowed(lastHolder)) {
            uint256 lastShare = amount - distributedSum; // ❌ Gets ALL remainder
            if (lastShare > 0) {
                yToken.safeTransfer(lastHolder, lastShare);
            }
        }
    }
}

Here, the distributed sum is calculated based on rounded-down shares. The last user gets the remainder, which can be substantially larger than their rightful proportional share.

Impact Details

Critical Impact: Token Owner Controls Yield Token Precision

ArcToken owners can set any ERC20 as the yield token:

Because decimals are not validated, yield tokens can have:

  • 0 decimals (whole units only)

  • 1-6 decimals (limited precision)

  • 18+ decimals (high precision)

Consequences:

  • If yield token has 0 decimals, rounding losses are severe.

  • With low decimal tokens (0–6), distribution rounding causes significant unfairness, especially if distributions are in whole tokens or small units relative to supply.

References

https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/arc/src/ArcToken.sol#L388

Proof of Concept

Note: Add the tests into ArcToken.t.sol file and run test-specific commands to run the PoC.

In tests that use LowDecimalToken, include this contract at the top of ArcToken.t.sol:

1

Scenario: Yield token with 18 decimals, distributing token units

Run command: forge test --mt test_RealUnfairDistribution -vvvv --via-ir

Test:

2

Scenario: Yield token with 6 decimals (eg. USDC), distributing tokens

Run command: forge test --mt test_ActualRoundingErrorsWithMessyBalances -vvvv --via-ir

Test:

3

Scenario: Yield token with 6 decimals, distributing in units (accumulated unfairness)

Run command: forge test --mt test_AccumulatedUnfairnessOverMultipleDistributions -vvvv --via-ir

Test:

Was this helpful?