Validating markers

We should be able to mark a square on the board with an "X" or "O" symbol, but not any other. We'd like our markSpace function to throw an error if it's called with an invalid symbol.

There are several expressions for error handling in Solidity. The most common are the require function with a string error message and using the revert statement with a custom error. Let's take a quick look at both.

Using require

The require function takes a condition to check and an error message to return if the condition is false. Think of it as checking some assertion about the state of our code that must always be true. For example:

require(cats >= 100, "Must have more than 100 cats");
require(block.timestamp < expiration, "Current time is past expiration date");
require(token.ownerOf(id) == msg.sender, "Caller must be token owner");
require(a + b + c == 11, "Sum must equal eleven");

If the condition in a require function is false, it reverts and returns an error that includes the provided error message. The require function is nice and concise, and it remains the most widely used idiom for throwing errors in Solidity.

Using revert with custom errors

Solidity versions since v0.8.4 support custom errors, which are more verbose than require but have a few new capabilities.

Here are the same errors as above defined as custom errors:

error NotEnoughCats(uint256 numCats);
if (cats < 100) {
    revert NotEnoughCats(cats);
}

error Expired();
if (block.timestamp >= expiration) {
    revert Expired();
}

error UnauthorizedCaller();
if (msg.sender !== token.ownerOf(id)) {
    revert UnauthorizedCaller();
}

error SumIsNotEleven(uint256 a, uint256 b, uint256 c);
if (a + b + c !== 11) {
    revert SumIsNotEleven(a, b, c);
}

First, we define a custom error using the error keyword, then use the revert keyword to throw an exception under certain conditions. Note that we've reversed the logic in each case: require is used to ensure some condition is true, while custom errors are usually thrown when some condition is false.

One advantage of custom errors over require strings is that they can take parameters, like the uint256 parameters to NotEnoughCats and SumIsNotEleven above. This is a good way to return structured data with your error that can be more descriptive than a simple require string.

What does it mean to revert?

Ethereum transactions are atomic, like database transactions. When a transaction reverts, execution stops, and the EVM rolls back any state changes and side effects of the reverted call, including changes in external contracts.

Testing errors

To test errors, we'll use a new Forge feature: the vm.expectRevert cheatcode. To access cheatcodes, we need to import the Vm.sol helper from forge-std and set it up in our tests:

// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.10;

import "ds-test/test.sol";
import "forge-std/Vm.sol";

import "../TicTacToken.sol";

contract TicTacTokenTest is DSTest {
    Vm internal vm = Vm(HEVM_ADDRESS);
    TicTacToken internal ttt;

    function setUp() public {
        ttt = new TicTacToken();
    }
    
    function test_cannot_mark_space_with_Z() public {
        vm.expectRevert("Invalid symbol");
        ttt.markSpace(0, "Z");
    }
}

The vm.expectRevert cheatcode expects the error string for require statements as its argument.

Forge Standard Library

The Forge Standard Library, or forge-std is a collection of helper contracts for use with Foundry. Among these is Vm.sol, a helper interface to Foundry cheatcodes.

To install forge-std, run forge install brockelmore/forge-std

Let's give this test a try:

$ forge test
[⠊] Compiling...
[⠒] Compiling 2 files with 0.8.10
[⠢] Solc finished in 9.84ms
Error: 
   0: Compiler run failed
      TypeError: Operator == not compatible with types string calldata and literal_string "X"
        --> /Users/ecm/Projects/ttt-book-code/src/TicTacToken.sol:12:17:
         |
      12 |         require(symbol == "X" || symbol == "O");
         |                 ^^^^^^^^^^^^^

      

      TypeError: Operator == not compatible with types string calldata and literal_string "O"
        --> /Users/ecm/Projects/ttt-book-code/src/TicTacToken.sol:12:34:
         |
      12 |         require(symbol == "X" || symbol == "O");
         |                                  ^^^^^^^^^^^^^

Hmm, we can't simply use the == operator to compare two strings. We'll need to do it some other way.

One common idiom to compare strings is to use a hash comparison, by converting the strings to bytes and using the built in keccak256 function. Let's create a _compareStrings helper and use it to do the comparison:

pragma solidity 0.8.10;

contract TicTacToken {
    string[9] public board;

    function getBoard() public view returns (string[9] memory) {
        return board;
    }

    function markSpace(uint256 space, string calldata symbol) public {
        require(_compareStrings(symbol, "X") || _compareStrings(symbol, "O"), "Invalid symbol");
        board[space] = symbol;
    }

    function _compareStrings(string memory a, string memory b) internal pure returns (bool) {
        return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b));
    }
}

Note that this function is internal since it's an internal helper. It's pure since it doesn't read or write any state. And we've prefixed it with an underscore, which is the preferred style for internal and private functions.

Let's see if our vm.expectRevert assertion passes now:

$ forge test
[⠊] Compiling...
[⠆] Compiling 2 files with 0.8.10
[⠰] Solc finished in 179.19ms
Compiler run successful

Running 5 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 33252)
[PASS] test_can_mark_space_with_X() (gas: 32280)
[PASS] test_cannot_mark_space_with_Z() (gas: 12720)
[PASS] test_get_board() (gas: 44689)
[PASS] test_has_empty_board() (gas: 47219)
Test result: ok. 5 passed; 0 failed; finished in 2.44ms

It works! Before we move on, let's refactor to make this a little more readable by extracting another helper method:

// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.10;

contract TicTacToken {
    string[9] public board;

    function getBoard() public view returns (string[9] memory) {
        return board;
    }

    function markSpace(uint256 space, string calldata symbol) public {
        require(_validSymbol(symbol), "Invalid symbol");
        board[space] = symbol;
    }

    function _validSymbol(string calldata symbol) internal pure returns (bool) {
        return _compareStrings(symbol, "X") || _compareStrings(symbol, "O");
    }

    function _compareStrings(string memory a, string memory b) internal pure returns (bool) {
        return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b));
    }
}