52787 sc high batched yield distribution rounding in arctoken permanently freezes unclaimed funds and misreports payouts

Submitted on Aug 13th 2025 at 07:21:42 UTC by @manvi for Attackathon | Plume Network

  • Report ID: #52787

  • 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

While testing ArcToken.distributeYieldWithLimit, I found that every batched yield distribution leaves behind a small amount of the yield token (“dust”) stuck in the contract due to integer division. This dust is never distributed to any holder and cannot be withdrawn, permanently freezing it. The function’s event emission also misreports payouts by claiming the full amount was distributed, even though part of it remains stuck. If this continues in production with frequent distributions, it will lead to a growing amount of inaccessible funds and misleading accounting data.

Vulnerability Details

In the smart contract, distributeYieldWithLimit processes yield payments in batches. For each holder in the batch, the share is calculated as:

share = (totalAmount * holderBalance) / effectiveTotalSupply;

Because integer division floors the result, any remainder for each holder is left undistributed in that batch. Unlike distributeYield, which tops off the final holder, this batched version never reconciles those remainders across batches.

Flow observed:

1

First batch: funds pulled

On the first batch (startIndex == 0), the entire totalAmount is transferred from the yield token into the ArcToken contract.

2

Per-holder distribution

Each batch distributes floored amounts to holders, leaving small “dust” remainders.

3

Dust remains in contract

The dust remains in the ArcToken contract with no function to sweep or recover it.

4

Misreported event

The function still emits YieldDistributed(totalAmount, token) when nextIndex == 0, misrepresenting the total payout.

Code excerpt demonstrating the issue

uint256 share = (totalAmount * balanceOf(holder)) / effectiveTotalSupply;
yToken.safeTransfer(holder, share);

Impact Details

Even a remainder of a few tokens per batch can add up over hundreds of distributions per year. At this scale, this could mean thousands of USDC locked away annually.

References

  • Smart contract - ArcToken.sol

  • Function call - distributeYieldWithLimit

Proof of Concept

Show PoC (Hardhat test showing 1 unit stuck when splitting a 100-unit distribution across 2 batches)

I used Hardhat for this PoC. File: test/ArcToken.batched-dust.poc.js

const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");

describe("ArcToken – batched distribution dust & misreporting (no mocks)", function () {
  let deployer, dist, h1, h2, h3;
  let router, arc, usdc;

  // I’m using OZ’s ERC20PresetMinterPauser which is 18 decimals by default.
  // Dust shows up regardless of decimals, so I keep it simple and consistent.
  const USDC_DEC = 18;

  before(async () => {
    [deployer, dist, h1, h2, h3] = await ethers.getSigners();

    // 1) I deploy the real RestrictionsRouter from the repo.
    const Router = await ethers.getContractFactory("RestrictionsRouter");
    router = await Router.deploy();
    await router.waitForDeployment();

    // 2) I deploy a standard ERC20 (OpenZeppelin preset) to act as the yield token.
    //    This isn’t a mock; it’s a real ERC20 with mint built in.
    const Mintable = await ethers.getContractFactory("ERC20PresetMinterPauser");
    usdc = await Mintable.deploy("USD Coin", "USDC");
    await usdc.waitForDeployment();

    // 3) I deploy ArcToken as a UUPS proxy (the actual contract from the repo).
    const ArcToken = await ethers.getContractFactory("ArcToken");
    arc = await upgrades.deployProxy(
      ArcToken,
      [
        "Arc Token",
        "ARC",
        ethers.parseEther("3"),     // I mint 3 ARC to myself first…
        await usdc.getAddress(),    // …set my yield token to the USDC I just deployed
        deployer.address,           // …send initial ARC to me
        18,                         // …keep decimals at 18
        await router.getAddress(),  // …and wire the real RestrictionsRouter
      ],
      { kind: "uups", initializer: "initialize" }
    );
    await arc.waitForDeployment();

    // 4) I split the initial 3 ARC so I have exactly 3 equal holders (1 ARC each).
    await (await arc.transfer(h1.address, ethers.parseEther("1"))).wait();
    await (await arc.transfer(h2.address, ethers.parseEther("1"))).wait();
    await (await arc.transfer(h3.address, ethers.parseEther("1"))).wait();

    // Sanity: each of the three holders should have 1 ARC.
    expect(await arc.balanceOf(h1.address)).to.equal(ethers.parseEther("1"));
    expect(await arc.balanceOf(h2.address)).to.equal(ethers.parseEther("1"));
    expect(await arc.balanceOf(h3.address)).to.equal(ethers.parseEther("1"));

    // 5) I mint 100 “USDC” to the distributor address and approve ArcToken to pull it.
    await (await usdc.mint(dist.address, ethers.parseUnits("100", USDC_DEC))).wait();
    await (await usdc.connect(dist).approve(await arc.getAddress(), ethers.parseUnits("100", USDC_DEC))).wait();
  });

  it("I split distribution into two batches → 1 unit dust stays stuck, but the event still claims 100 sent", async () => {
    // I’m distributing totalAmount = 100 to 3 equal holders.
    // Ideal split is 33.333... each. With integer division:
    //   Batch1 (start=0, max=2): 33 to h1, 33 to h2 -> 66 sent, 34 retained by ArcToken so far
    //   Batch2 (start=2, max=2): 33 to h3 -> 99 total sent, **1 unit left stuck** in ArcToken
    const totalAmount = ethers.parseUnits("100", USDC_DEC);

    // I use this helper to read the stuck balance (lives on ArcToken itself).
    const arcUsdc = async () => await usdc.balanceOf(await arc.getAddress());

    // --- Batch 1 ---
    await (await arc.connect(dist).distributeYieldWithLimit(totalAmount, 0, 2)).wait();

    // After batch 1, I expect ArcToken to hold 34 units (100 pulled once, 66 sent to h1+h2).
    expect(await arcUsdc()).to.equal(ethers.parseUnits("34", USDC_DEC));

    // h1 and h2 should have 33 each.
    expect(await usdc.balanceOf(h1.address)).to.equal(ethers.parseUnits("33", USDC_DEC));
    expect(await usdc.balanceOf(h2.address)).to.equal(ethers.parseUnits("33", USDC_DEC));

    // --- Batch 2 ---
    await (await arc.connect(dist).distributeYieldWithLimit(totalAmount, 2, 2)).wait();

    // Now h3 should get 33. The ArcToken contract should still hold **1** unit (frozen dust).
    expect(await usdc.balanceOf(h3.address)).to.equal(ethers.parseUnits("33", USDC_DEC));
    expect(await arcUsdc()).to.equal(ethers.parseUnits("1", USDC_DEC));

    // I also sanity-check the distributor really paid 100 total (pulled on the first batch).
    expect(await usdc.balanceOf(dist.address)).to.equal(ethers.parseUnits("0", USDC_DEC));

    // There’s no sweep/withdraw in ArcToken for this yield token, so this 1 unit is stuck for good.
    // Meanwhile, the contract emits YieldDistributed(totalAmount, token) on the last batch,
    // which says “100” even though only 99 actually left the contract. That’s the misreporting part.
  });
});

I ran it with: npx hardhat test test/ArcToken.batched-dust.poc.js

Observed result:

  • h1, h2, h3 each end up with 33.

  • ArcToken retains 1 unit of the yield token.

  • Distributor’s USDC goes from 100 -> 0 (pulled once).

  • The leftover 1 is stuck on the contract with no way to pull it out.


If you want, I can:

  • Suggest minimal code fixes to avoid dust (e.g., carry remainders forward or top off last recipient of the entire distribution), or

  • Draft a suggested patch/PR compatible with the project style that reconciles leftover remainders and corrects the event emission.

Was this helpful?