MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: low
Valid

Integer division dust permanently locked with no sweep

Root + Impact

Description

  • Two successive integer divisions truncate remainders with no mechanism to recover them

  • The truncated tokens remain locked in the contract permanently. For low-decimal tokens (e.g., USDC with 6 decimals), the dust per Pot can be non-trivial, and it accumulates across many Pots.

uint256 managerCut = remainingRewards / managerCutPercent; // truncates
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; // truncates again

Risk

Likelihood:

  • Integer division truncation is practically unavoidable unless both remainingRewards and (remainingRewards - managerCut) happen to be exactly divisible by managerCutPercent (10) and the claimant count, respectively.

  • For low-decimal tokens such as USDC (6 decimals) and USDT (6 decimals), remainders of 1–9 units (0.000001–0.000001–0.000009) occur on most closings. At scale, hundreds of Pots per contest season, the aggregate locked amount becomes non-trivial. With 18-decimal tokens, the individual dust is negligible (1–2 wei), but the mechanism still exists.

Impact:

Proof of Concept

The following test uses small raw-unit amounts (no ether suffix) to make integer-division dust clearly visible — equivalent to testing with a low-decimal token such as USDC. Three players claim; a fourth does not. closePot then performs two truncating divisions, permanently locking the remainder. Run with forge test --match-test testL01 -vvv:

function testL1_DivisionDustLockedInPot() public {
// Use small raw-unit amounts (no ether suffix) to expose truncation dust.
// Equivalent to a USDC-like 6-decimal token with small balances.
//
// Setup:
// 4 players: rewards = [200, 200, 200, 55], totalRewards = 655
// players 1-3 claim their cuts; player4 (55 units) does not claim
// remainingRewards at closePot = 55
//
// closePot arithmetic (buggy code, i_players.length = 4):
// managerCut = 55 / 10 = 5 (truncated — 0.5 units dust, manager gets 5 not 5.5)
// claimantCut = (55 - 5) / 4 = 12 (remainder 2)
// total out = 5 + 3×12 = 41
// locked dust = 55 - 41 = 14 (2 units pure L1 dust + 12 units H-01 shortfall)
//
// Even with H0 fixed (claimants.length = 3):
// claimantCut = 50 / 3 = 16 (remainder 2) → 2 units dust still locked from L-01
uint256 customTotal = 655;
vm.startPrank(owner);
weth.mint(owner, customTotal);
weth.approve(address(conMan), customTotal);
address[] memory players = new address[](4);
players[0] = player1;
players[1] = player2;
players[2] = player3;
address player4 = makeAddr("player4");
players[3] = player4;
uint256[] memory rewards = new uint256[](4);
rewards[0] = 200;
rewards[1] = 200;
rewards[2] = 200;
rewards[3] = 55; // player4 will not claim
address contestAddr = conMan.createContest(players, rewards, IERC20(address(weth)), customTotal);
conMan.fundContest(0);
vm.stopPrank();
vm.prank(player1); Pot(contestAddr).claimCut();
vm.prank(player2); Pot(contestAddr).claimCut();
vm.prank(player3); Pot(contestAddr).claimCut();
// player4 does not claim
assertEq(Pot(contestAddr).getRemainingRewards(), 55, "55 units unclaimed");
vm.warp(block.timestamp + 91 days);
uint256 conManBefore = weth.balanceOf(address(conMan));
vm.prank(owner);
conMan.closeContest(contestAddr);
uint256 managerReceived = weth.balanceOf(address(conMan)) - conManBefore;
uint256 potAfter = weth.balanceOf(contestAddr);
// managerCut = 55 / 10 = 5 (truncated from 5.5 — 0.5 units dust)
assertEq(managerReceived, 5, "manager receives 5 (not 5.5 — truncated)");
// Pot retains 14 units: 12 from H0 wrong denominator + 2 pure L1 division dust
// Even with H0 fixed, 2 units are still locked due to 50 / 3 = 16 remainder 2
assertGt(potAfter, 0, "dust permanently locked in pot");
console.log("--- L1 Results ---");
console.log("Manager received :", managerReceived, "(expected 5.5 — truncated)");
console.log("Locked in Pot :", potAfter, "units (includes L-01 dust)");
}

Recommended Mitigation

After distributing all claimant cuts, sweep the remaining contract balance to the owner. Using balanceOf rather than a calculated remainder, catches all dust sources, manager-cut truncation, claimant-cut truncation, and any direct token sends to the Pot:

uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ // Sweep any remaining dust (from integer-division truncation) to the owner
+ uint256 dust = i_token.balanceOf(address(this));
+ if (dust > 0) i_token.safeTransfer(msg.sender, dust);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-03] [H-03] Precision loss can lead to rewards getting stuck in the pot forever

### \[H-03] Precision loss can lead to rewards getting stuck in the pot forever **Description:** When contest manager closes the pot by calling `Pot::closePot`, 10 percent of the remaining rewards are transferred to the contest manager and the rest are distributed equally among the claimants. It does this by dividing the rewards by the manager's cut percentage which is 10. Then the remaining rewards are divided by the number of players to distribute equally among claimants. Since solidity allows only integer division this will lead to precision loss which will cause a portion of funds to be left in the pot forever. Each pot follows the same method, so as number of pots grow, the loss of funds is very significant. **Impact:** Reward tokens get stuck in the pot forever which causes loss of funds. **Proof of code:** Add the below test to `test/TestMyCut.t.sol` ```javascript function testPrecisionLoss() public mintAndApproveTokens { ContestManager cm = ContestManager(conMan); uint playersLength = 3; address[] memory p = new address[](playersLength); uint256[] memory r = new uint256[](playersLength); uint tr = 86; p[0] = makeAddr("_player1"); p[1] = makeAddr("_player2"); p[2] = makeAddr("_player3"); r[0] = 20; r[1] = 23; r[2] = 43; vm.startPrank(user); address pot = cm.createContest(p, r, weth, tr); cm.fundContest(0); vm.stopPrank(); console.log("\n\ntoken balance in pot before: ", weth.balanceOf(pot)); vm.prank(p[1]); // player 2 Pot(pot).claimCut(); vm.prank(p[0]); // player 1 Pot(pot).claimCut(); vm.prank(user); vm.warp(block.timestamp + 90 days + 1); cm.closeContest(pot); console.log( "\n\ntoken balance in pot after closing pot: ", weth.balanceOf(pot) ); assert(weth.balanceOf(pot) != 0); } ``` Run the below test command in terminal ```Solidity forge test --mt testPrecisionLoss -vv ``` Which results in the below output ```Solidity [⠒] Compiling... [⠆] Compiling 1 files with 0.8.20 [⠰] Solc 0.8.20 finished in 2.57s Compiler run successful! Ran 1 test for test/TestMyCut.t.sol:TestMyCut [PASS] testPrecisionLoss() (gas: 936926) Logs: token balance in pot before: 86 token balance in pot after closing pot: 1 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.75ms (654.60µs CPU time) Ran 1 test suite in 261.16ms (1.75ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` If you observe the output you can see the pot still has rewards despite distributing them to claimants. **Recommended Mitigations:** Fixed-Point Arithmetic: Utilize a fixed-point arithmetic library or implement a custom solution to handle fee calculations with greater precision.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!