MyCut

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

Compound integer truncation in closePot silently locks dust tokens in every pot after every close

Root + Impact

Description

  • closePot() computes the manager cut and then the per-claimant distribution using sequential integer division. Each division independently truncates any fractional remainder, and the aggregate of both truncations is never distributed or recovered.

  • The compound truncation leaves a residual token balance in every Pot contract after every closePot call. The contract has no sweep function and no automatic dust recovery, so this residual is permanently locked.

// first truncation: remainingRewards not divisible by managerCutPercent
uint256 managerCut = remainingRewards / managerCutPercent;
// ...
// second truncation: remainder after managerCut not divisible by player count
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;

Risk

Likelihood:

  • Integer truncation occurs on virtually every closePot call — any remainingRewards value not perfectly divisible by both managerCutPercent and the player/claimant count produces locked dust.

  • Tokens with 0 or 6 decimals (e.g., USDC) make the per-pot locked amount more material, since the truncation is a larger fraction of the minimum unit.

Impact:

  • Tokens are permanently locked in every Pot contract with no recovery path. The amount per pot may be small in isolation, but grows in aggregate across all contests over the protocol's lifetime.

  • For high-value or low-decimal tokens, a single pot can lock a material amount — e.g., with remainingRewards = 9 USDC and 5 players: managerCut = 0, claimantCut = 1, distributed = 5 USDC, locked = 4 USDC.

Proof of Concept

Place this test in test/ and run forge test --match-test testDustLockedAfterClose. The test demonstrates that integer division truncation in closePot() leaves a small dust amount permanently locked in the contract after the distribution loop completes.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Pot} from "src/Pot.sol";
import {ERC20Mock} from "test/ERC20Mock.sol";
contract M6Test is Test {
ERC20Mock token;
Pot pot;
address manager = address(0x1);
address p1 = address(0x2);
address p2 = address(0x3);
address p3 = address(0x4);
address p4 = address(0x5);
address p5 = address(0x6);
function setUp() public {
// Deploy with 18 decimals; mint 9e18 directly to manager
token = new ERC20Mock("Mock USDC", "mUSDC", manager, 9e18);
address[] memory players = new address[](5);
players[0] = p1; players[1] = p2; players[2] = p3;
players[3] = p4; players[4] = p5;
uint256[] memory rewards = new uint256[](5);
// Allocate 1e18 each; totalRewards = 9e18 (overfunded by 4e18,
// creating remainingRewards = 9e18 — not divisible by 5, so claimantCut
// truncates and dust is locked)
for (uint i = 0; i < 5; i++) rewards[i] = 1e18;
vm.prank(manager);
pot = new Pot(players, rewards, token, 9e18);
vm.prank(manager);
token.transfer(address(pot), 9e18);
}
function testDustLockedAfterClose() public {
vm.warp(block.timestamp + 91 days);
vm.prank(manager);
pot.closePot();
// Manager cut: 9e18 / 10 = 0.9e18, truncated to 0. Remaining: 9e18. Claimant cut: 9e18 / 5 = 1.8e18, truncated to 1e18 per player. Distributed = 5e18. Locked dust = 4e18 (permanently stranded).
assertGt(token.balanceOf(address(pot)), 0, "Dust locked in pot");
}
}

Recommended Mitigation

After the distribution loop, transfer any remaining token balance (i_token.balanceOf(address(this))) to the owner as a dust sweep so no funds are left stranded in the contract.

for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+
+ // Sweep truncation dust to owner
+ uint256 dustAfterDistribution = i_token.balanceOf(address(this));
+ if (dustAfterDistribution > 0) {
+ i_token.transfer(owner(), dustAfterDistribution);
+ }
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!