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:
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
Impact Details
Permanent freezing of funds - The frozen dust accumulates with every batch, creating a growing locked balance that no one can reclaim without a contract upgrade.
Misreported payouts - The event logs show the intended amount as distributed, creating a false record that can mislead operators, auditors, and external integrations.
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
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.
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.
});
});