On-chain component

For the on-chain component we need to implement Perun’s Ethereum App interface. We create the Solidity smart contract contracts/TicTacToeApp.sol. Valid game moves and winning conditions are defined here.

Warning

The contract defines what transition is considered valid or not. All disputes regarding this are being solved here. Any possible exploit in this method breaks the app channel’s security guarantees and put’s funds at risk.

pragma solidity ^0.7.0;
pragma experimental ABIEncoderV2;

import "./perun-eth-contracts/contracts/App.sol";

/**
 * @notice TicTacToeApp is a channel app for playing tic tac toe.
 * The data is encoded as follows:
 * - data[0]: The index of the next actor.
 * - data[i], i in [1,10]: The value of field i. 0 means no tick, 1 means tick by player 1, 2 means tick by player 2.
 */
contract TicTacToeApp is App {

We set a few self-explanatory constants.

Note

Some of these constants might seem unnecessary for our simple data structure, but they become handy in more complex cases, especially for more extensive data structures.

    uint8 constant actorDataIndex = 0;
    uint8 constant actorDataLength = 1;
    uint8 constant gridDataIndex = actorDataIndex + actorDataLength;
    uint8 constant gridDataLength = 9;
    uint8 constant appDataLength = gridDataIndex + gridDataLength; // Actor index + grid.
    uint8 constant numParts = 2;
    uint8 constant notSet = 0;
    uint8 constant firstPlayer = 1;
    uint8 constant secondPlayer = 2;

Valid Transition

We define validTransition to evaluate if a move is considered valid or not. Channel.Params params holds general app channel parameters like Channel.Params.challengeDuration and Channel.Params.participants. Channel.State from and to hold the game state as Channel.State.appData and Channel.State.isFinal.

    /**
     * @notice ValidTransition checks if there was a valid transition between two states.
     * @param params The parameters of the channel.
     * @param from The current state.
     * @param to The potential next state.
     * @param signerIdx Index of the participant who signed this transition.
     */
    function validTransition(
        Channel.Params calldata params,
        Channel.State calldata from,
        Channel.State calldata to,
        uint256 signerIdx)
    external pure override
    {

Check basic requirements. Is the number of participants as expected? Is the provided appData of the expected length? Is the callee also the actor of the last state? Is the next actor indeed the opposite party?

        require(params.participants.length == numParts, "number of participants");

        uint8 actorIndex = uint8(from.appData[actorDataIndex]);
        require(to.appData.length == appDataLength, "data length");
        require(actorIndex == signerIdx, "actor not signer");
        require((actorIndex + 1) % numParts == uint8(to.appData[actorDataIndex]), "next actor");

Check the grid. Like before, we require that exactly one field which was notSet in from now is in to and that no previously set field is overwritten. If no field or more than one field is changed, we detect an invalid transition.

        // Test valid action.
        bool changed = false;
        for (uint i = gridDataIndex; i < gridDataIndex + gridDataLength; i++) {
            require(uint8(to.appData[i]) <= 2, "grid value");
            if (to.appData[i] != from.appData[i]) {
                require(uint8(from.appData[i]) == notSet, "overwrite");
                require(!changed, "two actions");
                changed = true;
            }
        }

Check win condition and balance. Then we come to the win condition and its effect on the subsequent balances of the parties. The primary evaluation is handled by checkFinal, which we define later. Our evaluation must match with the proposed isFinal flag.

Additionally, we check that there is no change regarding the assets and the total locked balance between the old and new state. If hasWinner is true, we expect a balance change. Otherwise, we make sure that the balances remain unchanged. In our logic, the winner takes it all. In that sense, we calculate the expected balances and compare them to the proposed ones via requireEqualUint256ArrayArray.

        // Test final state.
        (bool isFinal, bool hasWinner, uint8 winner) = checkFinal(to.appData);
        require(to.isFinal == isFinal, "final flag");
        Array.requireEqualAddressArray(to.outcome.assets, from.outcome.assets);
        Channel.requireEqualSubAllocArray(to.outcome.locked, from.outcome.locked);
        uint256[][] memory expectedBalances = from.outcome.balances;
        if (hasWinner) {
            uint8 loser = 1 - winner;
            expectedBalances = new uint256[][](expectedBalances.length);
            for (uint i = 0; i < expectedBalances.length; i++) {
                expectedBalances[i] = new uint256[](numParts);
                expectedBalances[i][winner] = from.outcome.balances[i][0] + from.outcome.balances[i][1];
                expectedBalances[i][loser] = 0;
            }
        }
        requireEqualUint256ArrayArray(to.outcome.balances, expectedBalances);
    }

Check Final

We implement checkFinal. Like in the local app, we hard-code all possible win combinations in winningRows. We simply iterate over these and check via sameValue if one symbol ticks all three fields, hence winning the game. If this is the case, we return the respective winner and isFinal and hasWinner as true.

    function checkFinal(bytes memory d) internal pure returns (bool isFinal, bool hasWinner, uint8 winner) {
        // 0 1 2
        // 3 4 5
        // 6 7 8

        // Check winner.
        uint8[3][8] memory winningRows = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8], // horizontal
        [0, 3, 6], [1, 4, 7], [2, 5, 8], // vertical
        [0, 4, 8], [2, 4, 6]             // diagonal
        ];
        for (uint i = 0; i < winningRows.length; i++) {
            (bool ok, uint8 v) = sameValue(d, winningRows[i]);
            if (ok) {
                if (v == firstPlayer) {
                    return (true, true, 0);
                } else if (v == secondPlayer) {
                    return (true, true, 1);
                }
            }
        }

If no winning party is found, we iterate over all fields on the grid. If all happen to be ticked, we have a draw. If not, the game is still ongoing. We return the respective flags for both cases.

        // Check all set.
        for (uint i = 0; i < d.length; i++) {
            if (uint8(d[i]) != notSet) {
                return (false, false, 0);
            }
        }
        return (true, false, 0);
    }

Helpers

Finally we implement the two helper sameValue and requireEqualUint256ArrayArray.

sameValue takes the state’s data d for accessing the grid and three fields as indices gridIndices. If these indices refer to ticked grid fields by the same player, we return true and the player’s id.

    function sameValue(bytes memory d, uint8[3] memory gridIndices) internal pure returns (bool ok, uint8 v) {
        bytes1 first = d[gridDataIndex + gridIndices[0]];
        for (uint i = 1; i < gridIndices.length; i++) {
            if (d[gridDataIndex + gridIndices[i]] != first) {
                return (false, 0);
            }
        }
        return (true, uint8(first));
    }

requireEqualUint256ArrayArray simply wraps Array.requireEqualUint256Array to compare two-dimensional arrays a and b. We do not return anything here because a require is used to compare, therefore aborting the call if inequality is detected.

    function requireEqualUint256ArrayArray(
        uint256[][] memory a,
        uint256[][] memory b
    )
    internal pure
    {
        require(a.length == b.length, "uint256[][]: unequal length");
        for (uint i = 0; i < a.length; i++) {
            Array.requireEqualUint256Array(a[i], b[i]);
        }
    }
}

Compiling

The app’s contract must be compiled, and go bindings must be generated to enable interaction from within golang. For simplicity, contracts/generated/ticTacToeApp/ticTacToeApp.go is given, so you don’t have to compile it for yourself.

Note

If you want to make changes to the contract, the shell script contracts/generate.sh streamlines the compilation and binding generation. For using the script, additional dependencies are required: The Solidity compiler solc and geth.