The RockPaperScissors contract suffers from precision loss when calculating refunds during a tie scenario. The issue occurs when the total pot is not evenly divisible by 2 after the protocol fee is taken, causing small amounts of ETH to remain permanently locked in the contract. While each instance results in minimal ETH loss (typically 1 wei), the cumulative impact could be significant over many games, especially with higher bet amounts that result in odd-number calculations
The vulnerability occurs in the _handleTie() function, specifically in the following calculation:
When the value (totalPot - fee) is odd, integer division by 2 results in a truncated value. The lost wei (from truncation) remains in the contract without any mechanism to recover it.
function testPrecisionLossInTieScenario() public {
uint256 betAmount = 0.01 ether + 5 wei;
vm.prank(playerA);
uint256 id = game.createGameWithEth{value: betAmount}(
TOTAL_TURNS,
TIMEOUT
);
vm.prank(playerB);
game.joinGameWithEth{value: betAmount}(id);
uint256 contractBalanceBefore = address(game).balance;
uint256 playerABalanceBefore = playerA.balance;
uint256 playerBBalanceBefore = playerB.balance;
uint256 feesBefore = game.accumulatedFees();
playTurn(id, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
playTurn(id, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
playTurn(id, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Rock);
uint256 totalPot = betAmount * 2;
uint256 expectedFee = (totalPot * 10) / 100;
uint256 expectedRefundPerPlayer = (totalPot - expectedFee) / 2;
uint256 playerARefund = playerA.balance - playerABalanceBefore;
uint256 playerBRefund = playerB.balance - playerBBalanceBefore;
uint256 feeCollected = game.accumulatedFees() - feesBefore;
uint256 totalRefunded = playerARefund + playerBRefund + feeCollected;
uint256 contractBalanceAfter = address(game).balance;
uint256 remainingDust = contractBalanceAfter -
(contractBalanceBefore - totalRefunded);
console.log("--- Testing tie scenario with bet amount:", betAmount, "wei ---");
console.log("Total pot:", totalPot);
console.log("Expected fee:", expectedFee);
console.log("Expected refund per player:", expectedRefundPerPlayer);
console.log("Actual player A refund:", playerARefund);
console.log("Actual player B refund:", playerBRefund);
console.log("Actual fee collected:", feeCollected);
console.log("Total refunded:", totalRefunded);
if (totalRefunded != totalPot) {
console.log("PRECISION LOSS DETECTED!");
console.log("Missing wei:", totalPot - totalRefunded);
console.log("Dust remaining in contract:", remainingDust);
}
}
--- Testing tie scenario with bet amount: 10000000000000005 wei ---
Total pot: 20000000000000010
Expected fee: 2000000000000001
Expected refund per player: 9000000000000004
Actual player A refund: 9000000000000004
Actual player B refund: 9000000000000004
Actual fee collected: 2000000000000001
Total refunded: 20000000000000009
PRECISION LOSS DETECTED!
Missing wei: 1
Dust remaining in contract: 2000000000000001
Traces:
[530033] RockPaperScissorsTest::testPrecisionLossInTieScenario()
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [184325] RockPaperScissors::createGameWithEth{value: 10000000000000005}(3, 600)
│ ├─ emit GameCreated(gameId: 0, creator: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], bet: 10000000000000005 [1e16], totalTurns: 3)
│ └─ ← [Return] 0
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [24919] RockPaperScissors::joinGameWithEth{value: 10000000000000005}(0)
│ ├─ emit PlayerJoined(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [2455] RockPaperScissors::accumulatedFees() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [47751] RockPaperScissors::commitMove(0, 0x2ce9e0c6cee52fff8defaf803e3d42570ca0a102e22ce2de7049458b2e57f4bd)
│ ├─ emit MoveCommitted(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [46119] RockPaperScissors::commitMove(0, 0xc9b1d04a9cb0262bf4cc638482b594250b9877d0c0d9aa13fd38efca1df6a449)
│ ├─ emit MoveCommitted(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [4375] RockPaperScissors::revealMove(0, 1, 0xb744b7af2ebddcdb6fdac9e865330c27a0287b13a1c9d7fa8098cde1928d587c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], move: 1, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [7470] RockPaperScissors::revealMove(0, 1, 0x65620014c3c7775136e33ca26bd4fe9e6301884391a12f181a6c7c133b1f8e69)
│ ├─ emit MoveRevealed(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], move: 1, currentTurn: 1)
│ ├─ emit TurnCompleted(gameId: 0, winner: 0x0000000000000000000000000000000000000000, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [23566] RockPaperScissors::commitMove(0, 0x2ce9e0c6cee52fff8defaf803e3d42570ca0a102e22ce2de7049458b2e57f4bd)
│ ├─ emit MoveCommitted(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], currentTurn: 2)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [23993] RockPaperScissors::commitMove(0, 0xc9b1d04a9cb0262bf4cc638482b594250b9877d0c0d9aa13fd38efca1df6a449)
│ ├─ emit MoveCommitted(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], currentTurn: 2)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [4375] RockPaperScissors::revealMove(0, 1, 0xb744b7af2ebddcdb6fdac9e865330c27a0287b13a1c9d7fa8098cde1928d587c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], move: 1, currentTurn: 2)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [7470] RockPaperScissors::revealMove(0, 1, 0x65620014c3c7775136e33ca26bd4fe9e6301884391a12f181a6c7c133b1f8e69)
│ ├─ emit MoveRevealed(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], move: 1, currentTurn: 2)
│ ├─ emit TurnCompleted(gameId: 0, winner: 0x0000000000000000000000000000000000000000, currentTurn: 2)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [23566] RockPaperScissors::commitMove(0, 0x2ce9e0c6cee52fff8defaf803e3d42570ca0a102e22ce2de7049458b2e57f4bd)
│ ├─ emit MoveCommitted(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], currentTurn: 3)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [23993] RockPaperScissors::commitMove(0, 0xc9b1d04a9cb0262bf4cc638482b594250b9877d0c0d9aa13fd38efca1df6a449)
│ ├─ emit MoveCommitted(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], currentTurn: 3)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [4375] RockPaperScissors::revealMove(0, 1, 0xb744b7af2ebddcdb6fdac9e865330c27a0287b13a1c9d7fa8098cde1928d587c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], move: 1, currentTurn: 3)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [44933] RockPaperScissors::revealMove(0, 1, 0x65620014c3c7775136e33ca26bd4fe9e6301884391a12f181a6c7c133b1f8e69)
│ ├─ emit MoveRevealed(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], move: 1, currentTurn: 3)
│ ├─ emit TurnCompleted(gameId: 0, winner: 0x0000000000000000000000000000000000000000, currentTurn: 3)
│ ├─ emit FeeCollected(gameId: 0, feeAmount: 2000000000000001 [2e15])
│ ├─ [0] playerA::fallback{value: 9000000000000004}()
│ │ └─ ← [Stop]
│ ├─ [0] playerB::fallback{value: 9000000000000004}()
│ │ └─ ← [Stop]
│ ├─ emit GameFinished(gameId: 0, winner: 0x0000000000000000000000000000000000000000, prize: 0)
│ └─ ← [Return]
├─ [455] RockPaperScissors::accumulatedFees() [staticcall]
│ └─ ← [Return] 2000000000000001 [2e15]
├─ [0] console::log("--- Testing tie scenario with bet amount:", 10000000000000005 [1e16], "wei ---") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Total pot:", 20000000000000010 [2e16]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Expected fee:", 2000000000000001 [2e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Expected refund per player:", 9000000000000004 [9e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Actual player A refund:", 9000000000000004 [9e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Actual player B refund:", 9000000000000004 [9e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Actual fee collected:", 2000000000000001 [2e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Total refunded:", 20000000000000009 [2e16]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("PRECISION LOSS DETECTED!") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Missing wei:", 1) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Dust remaining in contract:", 2000000000000001 [2e15]) [staticcall]
│ └─ ← [Stop]
└─ ← [Return]
The test case demonstrates this issue by creating a game with a bet of 0.01 ether + 5 wei, resulting in a total pot of 20000000000000010 wei. After the 10% fee of 2000000000000001 wei is deducted, the remaining amount 18000000000000009 wei is divided by 2, leaving 9000000000000004 wei for each player. This calculation loses 1 wei to truncation, which remains trapped in the contract with no mechanism to extract it.