From c3a0eee19b8941a25c88e56b51b88247b6839526 Mon Sep 17 00:00:00 2001 From: Evgeny Tsvigun Date: Fri, 16 Sep 2022 19:55:43 +0300 Subject: [PATCH] #8 Arbiter.sol: finishGame --- contracts/Arbiter.sol | 65 ++++++++++++- contracts/tic-tac-toe/TicTacToeRules.sol | 58 +++++++++++- interfaces/IGameJutsuArbiter.sol | 2 +- interfaces/IGameJutsuRules.sol | 4 + scripts/deploy.py | 11 +++ tests/arbiter_test.py | 116 ++++++++++++++++++++++- tests/conftest.py | 13 ++- tests/test_tic_tac_toe_rules.py | 42 ++++++-- 8 files changed, 291 insertions(+), 20 deletions(-) diff --git a/contracts/Arbiter.sol b/contracts/Arbiter.sol index 62a30eb..07ca9df 100644 --- a/contracts/Arbiter.sol +++ b/contracts/Arbiter.sol @@ -83,8 +83,30 @@ contract Arbiter is IGameJutsuArbiter { emit GamesStarted(gameId, games[gameId].stake, games[gameId].playersArray); } - function finishGame(SignedGameMove calldata signedMove) external { - //TODO + function finishGame(SignedGameMove[] calldata signedMoves) external returns (address winner){ + require(_isSignedByAllPlayers(signedMoves[0]), "Arbiter: first move not signed by all players"); + address signer = recoverAddress(signedMoves[1].gameMove, signedMoves[1].signatures[0]); + require(signer == signedMoves[1].gameMove.player, "Arbiter: first signature must belong to the player making the move"); + + uint256 gameId = signedMoves[0].gameMove.gameId; + require(_isGameOn(gameId), "Arbiter: game not active"); + require(signedMoves[1].gameMove.gameId == gameId, "Arbiter: game ids mismatch"); + require(_isValidGameMove(signedMoves[1].gameMove), "Arbiter: invalid game move"); + require(keccak256(signedMoves[0].gameMove.newState) == keccak256(signedMoves[1].gameMove.oldState), "Arbiter: game state mismatch"); + + IGameJutsuRules.GameState memory newState = IGameJutsuRules.GameState(gameId, signedMoves[1].gameMove.nonce + 1, signedMoves[1].gameMove.newState); + IGameJutsuRules rules = games[gameId].rules; + require(rules.isFinal(newState), "Arbiter: game state not final"); + for (uint8 i = 0; i < NUM_PLAYERS; i++) { + if (rules.isWin(newState, i)) { + address winner = games[gameId].playersArray[i]; + address loser = games[gameId].playersArray[1 - i]; + _finishGame(gameId, winner, loser, false); + return winner; + } + } + _finishGame(gameId, address(0), address(0), true); + return address(0); } //TODO add dispute move version based on comparison to previously signed moves @@ -220,12 +242,45 @@ contract Arbiter is IGameJutsuArbiter { return a.gameId == b.gameId && a.nonce == b.nonce && keccak256(a.state) == keccak256(b.state); } - function getSigners(SignedGameMove calldata signedMove) private pure returns (address[] memory) {//TODO lib + function publicGetSigners(SignedGameMove calldata signedMove) external view returns (address[] memory) { + return getSigners(signedMove); + } + + function getSigners(SignedGameMove calldata signedMove) private view returns (address[] memory) {//TODO lib address[] memory signers = new address[](signedMove.signatures.length); for (uint256 i = 0; i < signedMove.signatures.length; i++) { - bytes32 signedHash = ECDSA.toEthSignedMessageHash(abi.encode(signedMove.gameMove.oldState, signedMove.gameMove.newState, signedMove.gameMove.move)); - signers[i] = ECDSA.recover(signedHash, signedMove.signatures[i]); + signers[i] = recoverAddress(signedMove.gameMove, signedMove.signatures[i]); } return signers; } + + function _isGameOn(uint256 gameId) private view returns (bool) { + return games[gameId].started && !games[gameId].finished; + } + + function _isSignedByAllPlayers(SignedGameMove calldata signedMove) private view returns (bool) { + address[] memory signers = getSigners(signedMove); + if (signers.length != NUM_PLAYERS) { + return false; + } + for (uint256 i = 0; i < signers.length; i++) { + if (games[signedMove.gameMove.gameId].players[signers[i]] == 0) { + return false; + } + } + return true; + } + + function _finishGame(uint256 gameId, address winner, address loser, bool draw) private { + games[gameId].finished = true; + if (draw) { + uint256 half = games[gameId].stake / 2; + uint256 theOtherHalf = games[gameId].stake - half; + payable(games[gameId].playersArray[0]).transfer(half); + payable(games[gameId].playersArray[1]).transfer(theOtherHalf); + } else { + payable(winner).transfer(games[gameId].stake); + } + emit GameFinished(gameId, winner, loser, draw); + } } diff --git a/contracts/tic-tac-toe/TicTacToeRules.sol b/contracts/tic-tac-toe/TicTacToeRules.sol index 933a6e0..d7887f7 100644 --- a/contracts/tic-tac-toe/TicTacToeRules.sol +++ b/contracts/tic-tac-toe/TicTacToeRules.sol @@ -20,11 +20,12 @@ import "../../interfaces/IGameJutsuRules.sol"; @author Gene A. Tsvigun @dev The state encodes the board as a 3x3 array of uint8s with 0 for empty, 1 for X, and 2 for O @dev explicitly keeping wins as `bool crossesWin` and `bool noughtsWin` + @dev yes we know the board can be packed more efficiently but we want to keep it simple */ contract TicTacToeRules is IGameJutsuRules { struct Board { - uint8[9] cells; //TODO pack it into a single uint16 + uint8[9] cells; bool crossesWin; bool naughtsWin; } @@ -36,13 +37,21 @@ type Move is uint8; uint8 _m = abi.decode(_move, (uint8)); Move m = Move.wrap(_m); bool playerIdMatchesTurn = _gameState.nonce % 2 == playerId; - return playerIdMatchesTurn && !b.crossesWin && !b.naughtsWin && isMoveWithinRange(m) && isCellEmpty(b, m); + return playerIdMatchesTurn && !b.crossesWin && !b.naughtsWin && _isMoveWithinRange(m) && _isCellEmpty(b, m); } function transition(GameState calldata _gameState, uint8 playerId, bytes calldata _move) external pure override returns (GameState memory) { Board memory b = abi.decode(_gameState.state, (Board)); uint8 _m = abi.decode(_move, (uint8)); b.cells[_m] = uint8(1 + _gameState.nonce % 2); + Move move = Move.wrap(_m); + if (_isWinningMove(b, move)) { + if (playerId == 0) { + b.crossesWin = true; + } else { + b.naughtsWin = true; + } + } return GameState(_gameState.gameId, _gameState.nonce + 1, abi.encode(b)); } @@ -50,12 +59,51 @@ type Move is uint8; return abi.encode(Board([0, 0, 0, 0, 0, 0, 0, 0, 0], false, false)); } - function isCellEmpty(Board memory b, Move move) private pure returns (bool) { - return b.cells[Move.unwrap(move)] == 0; + function isFinal(GameState calldata state) external pure returns (bool){ + Board memory b = abi.decode(state.state, (Board)); + return b.crossesWin || b.naughtsWin || _isBoardFull(b); + } + + function isWin(GameState calldata state, uint8 playerId) external pure returns (bool){ + Board memory b = abi.decode(state.state, (Board)); + return playerId == 0 ? b.crossesWin : b.naughtsWin; } + function _isCellEmpty(Board memory b, Move move) private pure returns (bool) { + return b.cells[Move.unwrap(move)] == 0; + } - function isMoveWithinRange(Move move) private pure returns (bool){ + function _isMoveWithinRange(Move move) private pure returns (bool){ return Move.unwrap(move) < 9; } + + function _isBoardFull(Board memory b) private pure returns (bool) { + for (uint8 i = 0; i < 9; i++) { + if (b.cells[i] == 0) { + return false; + } + } + return true; + } + + function _isWinningMove(Board memory b, Move move) private pure returns (bool) { + uint8 _m = Move.unwrap(move); + uint8 row = _m / 3; + uint8 col = _m % 3; + uint8 player = b.cells[_m]; + return _isRowWin(b, row, player) || _isColWin(b, col, player) || _isDiagonalWin(b, player); + } + + function _isRowWin(Board memory b, uint8 row, uint8 player) private pure returns (bool) { + return b.cells[row * 3] == player && b.cells[row * 3 + 1] == player && b.cells[row * 3 + 2] == player; + } + + function _isColWin(Board memory b, uint8 col, uint8 player) private pure returns (bool) { + return b.cells[col] == player && b.cells[col + 3] == player && b.cells[col + 6] == player; + } + + function _isDiagonalWin(Board memory b, uint8 player) private pure returns (bool) { + return (b.cells[0] == player && b.cells[4] == player && b.cells[8] == player) || + (b.cells[2] == player && b.cells[4] == player && b.cells[6] == player); + } } \ No newline at end of file diff --git a/interfaces/IGameJutsuArbiter.sol b/interfaces/IGameJutsuArbiter.sol index 330dfec..f7c325e 100644 --- a/interfaces/IGameJutsuArbiter.sol +++ b/interfaces/IGameJutsuArbiter.sol @@ -52,7 +52,7 @@ interface IGameJutsuArbiter { function disputeMoveWithHistory(SignedGameMove[2] calldata signedMoves) external; - function finishGame(SignedGameMove calldata signedMove) external; + function finishGame(SignedGameMove[] calldata signedMoves) external returns (address winner); // // function initMoveTimeout(SignedMove calldata signedMove) payable external; // diff --git a/interfaces/IGameJutsuRules.sol b/interfaces/IGameJutsuRules.sol index af68ebd..9edcae0 100644 --- a/interfaces/IGameJutsuRules.sol +++ b/interfaces/IGameJutsuRules.sol @@ -28,4 +28,8 @@ interface IGameJutsuRules { function transition(GameState calldata state, uint8 playerId, bytes calldata move) external pure returns (GameState memory); function defaultInitialGameState() external pure returns (bytes memory); + + function isFinal(GameState calldata state) external pure returns (bool); + + function isWin(GameState calldata state, uint8 playerId) external pure returns (bool); } diff --git a/scripts/deploy.py b/scripts/deploy.py index ef79b70..ed3a8ff 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -1,3 +1,14 @@ +# ________ ____. __ +# / _____/_____ _____ ____ | |__ ___/ |_ ________ __ +# / \ ___\__ \ / \_/ __ \ | | | \ __\/ ___/ | \ +# \ \_\ \/ __ \| Y Y \ ___//\__| | | /| | \___ \| | / +# \______ (____ /__|_| /\___ >________|____/ |__| /____ >____/ +# \/ \/ \/ \/ \/ +# https://gamejutsu.app +# ETHOnline2022 submission by ChainHackers +__author__ = ["Gene A. Tsvigun"] +__license__ = "MIT" + from brownie import Arbiter, TicTacToeRules, accounts diff --git a/tests/arbiter_test.py b/tests/arbiter_test.py index 5e61c7d..3b21c7b 100644 --- a/tests/arbiter_test.py +++ b/tests/arbiter_test.py @@ -1,6 +1,17 @@ +# ________ ____. __ +# / _____/_____ _____ ____ | |__ ___/ |_ ________ __ +# / \ ___\__ \ / \_/ __ \ | | | \ __\/ ___/ | \ +# \ \_\ \/ __ \| Y Y \ ___//\__| | | /| | \___ \| | / +# \______ (____ /__|_| /\___ >________|____/ |__| /____ >____/ +# \/ \/ \/ \/ \/ +# https://gamejutsu.app +# ETHOnline2022 submission by ChainHackers +__authors__ = ["Gene A. Tsvigun", "Vic G. Larson"] +__license__ = "MIT" + import pytest from brownie import reverts, interface, Wei -from eth_abi import encode_abi, decode_abi +from eth_abi import encode_abi from eth_account.messages import SignableMessage from brownie.convert import to_bytes @@ -419,3 +430,106 @@ def test_is_valid_signed_players_moves_in_right_sequence(arbiter, rules, start_g with reverts(): arbiter.disputeMove(valid_signed_game_move2, {'from': player_a.address}) + + +def test_finish_game(arbiter, rules, start_game, player_a, player_b): + stake = Wei('0.1 ether') + tx = arbiter.proposeGame(rules, {'value': stake, 'from': player_a.address}) + game_id = tx.return_value + assert 'GameProposed' in tx.events + assert tx.events['GameProposed']['gameId'] == game_id + + o_about_to_play_in_the_center_board = encode_abi(STATE_TYPES, [[1, 1, 0, 2, 0, 0, 0, 0, 0], False, False]) + o_center_move_data = to_bytes("0x04") + + # ╭───┬───┬───╮ + # │ X │ X │ . │ + # ├───┼───┼───┤ + # │ 0 │ 0 │ │ + # ├───┼───┼───┤ + # │ │ │ │ + # ╰───┴───┴───╯ + + x_almost_won_board = encode_abi(STATE_TYPES, [[1, 1, 0, 2, 2, 0, 0, 0, 0], False, False]) + nonce = 4 + x_won_board = encode_abi(STATE_TYPES, [[1, 1, 1, 2, 2, 0, 0, 0, 0], True, False]) + x_winning_move_data = to_bytes("0x02") + x_non_winning_move_data = to_bytes("0x08") + x_not_won_board = encode_abi(STATE_TYPES, [[1, 1, 0, 2, 2, 0, 0, 0, 1], False, False]) + + o_center_move = [ + game_id, + 3, + player_b.address, + o_about_to_play_in_the_center_board, + x_almost_won_board, + o_center_move_data + ] + x_winning_move = [ + game_id, + nonce, + player_a.address, + x_almost_won_board, + x_won_board, + x_winning_move_data + ] + x_non_winning_move = [ + game_id, + nonce, + player_a.address, + x_almost_won_board, + x_not_won_board, + x_non_winning_move_data + ] + + encoded_o_center_move = encode_move(*o_center_move) + signature_a = player_a.sign_message(encoded_o_center_move).signature + signature_b = player_b.sign_message(encoded_o_center_move).signature + signed_by_both_players_move = [ + o_center_move, + [signature_a, signature_b] + ] + print(f"player_a: {player_a.address}") + print(f"player_b: {player_b.address}") + print(arbiter.publicGetSigners(signed_by_both_players_move)) + + signed_x_winning_move = [ + x_winning_move, + [player_a.sign_message(encode_move(*x_winning_move)).signature] + ] + signed_x_non_winning_move = [ + x_non_winning_move, + [player_a.sign_message(encode_move(*x_non_winning_move)).signature] + ] + + with reverts(): + arbiter.finishGame( + [signed_by_both_players_move, signed_x_winning_move], + {'from': player_a.address} + ) + + arbiter.acceptGame(game_id, {'value': stake, 'from': player_b.address}) + rules, stake, started, finished = arbiter.games(game_id) + assert started + assert not finished + + with reverts(): + arbiter.finishGame( + [signed_by_both_players_move, signed_x_non_winning_move], + {'from': player_a.address} + ) + + tx = arbiter.finishGame( + [signed_by_both_players_move, signed_x_winning_move], + {'from': player_a.address} + ) + assert 'GameFinished' in tx.events + e = tx.events['GameFinished'] + assert e['gameId'] == game_id + assert e['winner'] == player_a.address + assert e['loser'] == player_b.address + assert not e['isDraw'] + assert 'PlayerDisqualified' not in tx.events + + rules, stake, started, finished = arbiter.games(game_id) + assert finished diff --git a/tests/conftest.py b/tests/conftest.py index 24e961d..4d30aad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,18 @@ +# ________ ____. __ +# / _____/_____ _____ ____ | |__ ___/ |_ ________ __ +# / \ ___\__ \ / \_/ __ \ | | | \ __\/ ___/ | \ +# \ \_\ \/ __ \| Y Y \ ___//\__| | | /| | \___ \| | / +# \______ (____ /__|_| /\___ >________|____/ |__| /____ >____/ +# \/ \/ \/ \/ \/ +# https://gamejutsu.app +# ETHOnline2022 submission by ChainHackers +__author__ = ["Gene A. Tsvigun"] +__license__ = "MIT" + import pytest from eth_account import Account -from brownie.network.gas.strategies import GasNowScalingStrategy + @pytest.fixture(scope="module") def dev(accounts): diff --git a/tests/test_tic_tac_toe_rules.py b/tests/test_tic_tac_toe_rules.py index 289abfb..2591da2 100644 --- a/tests/test_tic_tac_toe_rules.py +++ b/tests/test_tic_tac_toe_rules.py @@ -1,5 +1,17 @@ +# ________ ____. __ +# / _____/_____ _____ ____ | |__ ___/ |_ ________ __ +# / \ ___\__ \ / \_/ __ \ | | | \ __\/ ___/ | \ +# \ \_\ \/ __ \| Y Y \ ___//\__| | | /| | \___ \| | / +# \______ (____ /__|_| /\___ >________|____/ |__| /____ >____/ +# \/ \/ \/ \/ \/ +# https://gamejutsu.app +# ETHOnline2022 submission by ChainHackers +__author__ = ["Gene A. Tsvigun" ] +__license__ = "MIT" + import pytest -from brownie import reverts, interface, convert +from brownie import reverts, interface +from brownie.convert import to_bytes from eth_abi import encode_abi, decode_abi from random import randbytes @@ -25,7 +37,7 @@ def test_is_valid_move(rules, game_id): # every cross move is valid on an empty board, naught can't move first for i in range(9): - move_to_cell_i = convert.to_bytes(i) + move_to_cell_i = to_bytes(i) assert rules.isValidMove(game_state, X, move_to_cell_i) is True assert rules.isValidMove(game_state, O, move_to_cell_i) is False @@ -33,14 +45,14 @@ def test_is_valid_move(rules, game_id): cross_wins = encode_abi(STATE_TYPES, [[0, 0, 0, 0, 0, 0, 0, 0, 0], True, False]) game_state = [game_id, nonce, cross_wins] for i in range(9): - move_to_cell_i = convert.to_bytes(i) + move_to_cell_i = to_bytes(i) assert rules.isValidMove(game_state, X, move_to_cell_i) is False assert rules.isValidMove(game_state, O, move_to_cell_i) is False nought_wins = encode_abi(STATE_TYPES, [[0, 0, 0, 0, 0, 0, 0, 0, 0], False, True]) game_state = [game_id, nonce, nought_wins] for i in range(9): - move_to_cell_i = convert.to_bytes(i) + move_to_cell_i = to_bytes(i) assert rules.isValidMove(game_state, X, move_to_cell_i) is False assert rules.isValidMove(game_state, O, move_to_cell_i) is False @@ -54,7 +66,7 @@ def test_is_valid_move(rules, game_id): game_state = [game_id, nonce, board] def is_valid(player_id: int, cell_id: int) -> bool: - return rules.isValidMove(game_state, player_id, convert.to_bytes(cell_id)) + return rules.isValidMove(game_state, player_id, to_bytes(cell_id)) assert is_valid(X, 0) is False assert is_valid(O, 0) is False @@ -86,7 +98,7 @@ def test_transition(rules, game_id): nonce = 0 game_state = [game_id, nonce, empty_board] for i in range(9): - next_game_id, next_nonce, next_state = rules.transition(game_state, X, convert.to_bytes(i)) + next_game_id, next_nonce, next_state = rules.transition(game_state, X, to_bytes(i)) assert next_game_id == game_id assert next_nonce == 1 next_board = decode_abi(STATE_TYPES, next_state) @@ -99,7 +111,7 @@ def test_transition(rules, game_id): nonce = 1 game_state = [game_id, nonce, empty_board] for i in range(9): - next_game_id, next_nonce, next_state = rules.transition(game_state, O, convert.to_bytes(i)) + next_game_id, next_nonce, next_state = rules.transition(game_state, O, to_bytes(i)) assert next_game_id == game_id assert next_nonce == 2 next_board = decode_abi(STATE_TYPES, next_state) @@ -108,3 +120,19 @@ def test_transition(rules, game_id): expected_next_board[i] = 2 expected_next_board = (tuple(expected_next_board), False, False) assert next_board == expected_next_board + + # ╭───┬───┬───╮ + # │ X │ X │ . │ + # ├───┼───┼───┤ + # │ 0 │ 0 │ │ + # ├───┼───┼───┤ + # │ │ │ │ + # ╰───┴───┴───╯ + + x_almost_won_board = encode_abi(STATE_TYPES, [[1, 1, 0, 2, 2, 0, 0, 0, 0], False, False]) + nonce = 4 + x_almost_won_state = [game_id, nonce, x_almost_won_board] + x_winning_move_data = to_bytes(2) + next_game_id, next_nonce, next_state = rules.transition(x_almost_won_state, X, x_winning_move_data) + x_won_board = encode_abi(STATE_TYPES, [[1, 1, 1, 2, 2, 0, 0, 0, 0], True, False]) + assert next_state.hex() == x_won_board.hex()