MyCut

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

Integer division in closePot() leaves undistributed reward dust permanently stuck in Pot

Root + Impact:

closePot() uses integer division when calculating both the manager cut and the claimant redistribution amount.

uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}

Solidity integer division rounds down. Any remainder created by these calculations is never transferred out of the Pot, and the contract has no sweep function for leftover tokens.

Description:

When a pot is closed, the protocol should distribute the remaining pool: 10% to the manager and the rest equally to eligible claimants.

However, both divisions can create rounding dust. First, remainingRewards / managerCutPercent rounds down the manager cut. Then the remaining amount is divided again to calculate each claimant’s share. If either division does not divide evenly, the leftover amount stays in the contract.

Because closePot() does not update remainingRewards after distribution and does not transfer the final remainder, these tokens can remain permanently stuck.

Risk:

This occurs whenever the remaining reward amount is not perfectly divisible by 10 or by the claimant distribution denominator.

The impact is usually low because the stuck amount may be small. However, the issue can become more noticeable with low-decimal tokens, small reward pools, or many contests accumulating undistributed leftovers.

Proof of Concept:

Assume:

remainingRewards = 101
managerCutPercent = 10
number of players = 4

Manager cut calculation:

managerCut = 101 / 10 = 10

Amount left after manager cut:

101 - 10 = 91

Claimant distribution:

claimantCut = 91 / 4 = 22

Total paid to claimants:

22 * 4 = 88

Total distributed:

managerCut + claimant payouts = 10 + 88 = 98

Amount stuck:

101 - 98 = 3

Those 3 tokens remain in the Pot, and there is no function to recover them.

A Foundry-style test:

function testClosePotLeavesRoundingDustStuck() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address dave = makeAddr("dave");
address[] memory players = new address[](4);
players[0] = alice;
players[1] = bob;
players[2] = carol;
players[3] = dave;
uint256[] memory rewards = new uint256[](4);
rewards[0] = 0;
rewards[1] = 0;
rewards[2] = 0;
rewards[3] = 0;
uint256 totalRewards = 101;
address contest = contestManager.createContest(
players,
rewards,
IERC20(weth),
totalRewards
);
ERC20Mock(weth).approve(address(contestManager), totalRewards);
contestManager.fundContest(0);
vm.warp(block.timestamp + 91 days);
contestManager.closeContest(contest);
assertEq(ERC20Mock(weth).balanceOf(contest), 3);
}

Recommended Mitigation:

Track how much was actually distributed and send the final remainder to a defined recipient.

Example:

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
+ uint256 rewardsForClaimants = remainingRewards - managerCut;
+ uint256 claimantCut = rewardsForClaimants / claimants.length;
+ uint256 distributed = managerCut;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
+ distributed += claimantCut;
}
+ uint256 dust = remainingRewards - distributed;
+ if (dust > 0) {
+ i_token.transfer(msg.sender, dust);
+ }
}
}

Also, the denominator should be claimants.length, not i_players.length, as covered in the separate distribution finding.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 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!