Introduction

Tic Tac Token App

Over the course of this curriculum, we'll learn the basics of Solidity and Ethereum from the ground up by creating an interactive web3 Tic-Tac-Toe game. We'll learn to write and test Solidity smart contracts using Foundry and Hardhat. We'll explore ERC20 tokens, NFTs, and other Ethereum abstractions. We'll create a React frontend using useDapp and Ethers and deploy it to IPFS, a distributed peer-to-peer filesystem. Finally, we'll deploy our smart contracts to Polygon, an EVM-compatible blockchain with fast, cheap transactions.

The content in this book was originally written as a supplement to a series of hands on workshops. In person, we typically cover one chapter per week, with two one hour sessions dedicated to writing code collaboratively using Live Share.

Special thanks to 8th Light for supporting these workshops.

Prerequisites

You should have some experience programming to get the most out of this content, but you don't have to be an expert. Here are a few suggested prerequisites.

  • You've read chapters 1-6 of Mastering Ethereum to get a basic understanding of Ethereum.
  • 101 level React: You've created a basic React application. You should be familiar with create-react-app, hooks, structuring an app, and basic testing.
  • 102 level automated testing: You've written automated tests before. You should be comfortable writing unit tests and using xUnit style assertions.
  • 102 level UNIX: You're not afraid of the terminal. You should be familiar with Make, bash, and working on the command line.
  • 201 level polyglot programming: You've used both a dynamic language like JS/Python and a typed language like Java/Golang.

đź›  Setup

Getting Started

Week 1 is focused on getting a Solidity programming environment set up and starting to explore the language. We'll introduce Foundry, a toolchain for Ethereum development, and write our first unit tests in Solidity. Then we'll explore some more powerful features, like fuzz tests and symbolic execution.

Goals this week

  • Set up a Solidity programming environment using Foundry
  • Get comfortable writing and running tests using forge
  • Start exploring Solidity syntax by analogy to other languages

Suggested homework

About Solidity

Over the course of these workshops, we're going to be learning Solidity, the main smart contract programming language used on Ethereum, the most popular general purpose programmable blockchain. Solidity works anywhere the Ethereum Virtual Machine (EVM) does, and there are a growing number of EVM-compatible blockchains besides Ethereum that support Solidity contracts.

For the first few weeks we won't spend too much time on Ethereum fundamentals. Instead, we'll treat Solidity like any other programming language without worrying too much about its unusual execution environment. However, those details will start to creep in as we proceed through these workshops. If you want a really good grounding in Ethereum fundamentals (or a review), I recommend Mastering Ethereum, which is available for free online.

Solidity Resources

The official Solidity documentation at https://docs.soliditylang.org/ is an excellent, readable resource for getting started with the language. I recommend reading it end to end.

About Foundry

Throughout these workshops, we'll be using Foundry to develop, test, and deploy our smart contracts. Foundry is a suite of command line tools for interacting with Ethereum and testing smart contracts, written in Rust. It is the faster, friendlier successor of an earlier project called Dapptools.

Dapptools has a well deserved reputation as a powerful tool. The source of its superpowers is HEVM, a Haskell implementation of the Ethereum Virtual Machine that is specifically designed for symbolic execution, debugging, and testing smart contracts. HEVM makes it easy to write property-based tests, write proofs about the behavior of your contracts, and step through the execution of your code at a low level.

Foundry's HEVM equivalent is revm, a Rust implementation of the EVM. It's much faster, although it doesn't yet support symbolic execution and a few other advanced features of HEVM.

Foundry is friendlier than Dapptools, but it still has a learning curve. I think of it as a little bit like the Vim text editor: very powerful, very Unixy, and a little overwhelming at first. But just like Vim, I think the payoff of learning the tool is worth the effort. Starting this project from the ground up with Foundry is a great way to learn it from scratch.

Foundry Resources

The Foundry Book is the definitive resource if you want to read more about Foundry. How to Foundry is an excellent introductory video. If you need help, try the official support Telegram.

Setting up Foundry

First, install foundryup, the Foundry toolchain installer:

$ curl -L https://foundry.paradigm.xyz | bash

Inspired by the rustup installer for Rust, foundryup is a lightweight, one step installation tool. Once it's installed, reload your PATH or open a new terminal window and run:

$ foundryup

You should see output like the following in your terminal:

foundryup: installing foundry (version nightly, tag nightly-a0db055a68733f3046ca772f)
foundryup: downloading latest forge and cast
############################################# 100.0%
############################################# 100.0%
foundryup: downloading manpages
############################################# 100.0%
foundryup: installed - forge 0.2.0 (a0db055 2022-04-03T00:03:53.441110+00:00)
foundryup: installed - cast 0.2.0 (a0db055 2022-04-03T00:03:53.441110+00:00)
foundryup: done

To verify that Foundry is installed, run forge --version:

$ forge --version
forge 0.2.0 (a0db055 2022-04-03T00:03:53.441110+00:00)

And cast --version:

$ cast --version
cast 0.2.0 (a0db055 2022-04-03T00:03:53.441110+00:00)

Foundry is under active development, so it's a good idea to run foundryup regularly to install the latest changes. The Foundry team publishes new builds nightly.

Once Foundry is installed, you should be able to interact with the forge and cast command line tools. Let's try cast, a multitool for interacting with Ethereum. We'll use the --to-ascii command, which converts a hex value to an ASCII string:

$ cast --to-ascii 0x48656c6c6f20776f726c64
Hello world

It works!

Creating a Foundry project

forge is the Foundry command line tool for managing projects, compiling contracts, and running tests.

Let's run forge init to create a new project from a template. (I've created this one with some predefined example contracts that will show us some of the features of forge and Solidity. To create a new project without a template, you can simply use forge init):

$ forge init tic-tac-token --template ecmendenhall/foundry-example

This will create a new tic-tac-token directory. Let's look inside:

$ cd tic-tac-token
$ tree .
.
├── foundry.toml
├── lib
│   └── ds-test
└── src
    ├── Greeter.sol
    └── test
        └── Greeter.t.sol

6 directories, 8 files

From the top, we have:

  • foundry.toml, a project configuration file.
  • A lib/ directory. This is used for project dependencies. Inside, we have one library, the ds-test unit testing framework.
  • A src/ directory. This is used for Solidity contracts, including tests. This includes:
    • Greeter.sol, an example "Hello World" contract.
    • A test/ directory for unit test contracts. It's conventional to give these a .t.sol extension to distinguish them from production code.

The foundry.toml config file

Out of the box, our configuration file looks like this:

[default]
src = 'src'
out = 'out'
libs = ['lib']
verbosity = 2

Config options are namespaced by profiles. Here, we have a single default profile named default, with a few options configured to tell Foundry where to look for source code and libraries, where to write compiled output, and a default verbosity level. (Note how it mirrors the directory structure of the project).

We can create additional profiles that inherit from the default and override some settings. For example, a verbose profile that prints more output by default:

[default]
src = 'src'
out = 'out'
libs = ['lib']
verbosity = 2

[verbose]
verbosity = 4

Defining profiles is a powerful way to configure Foundry for different tasks, contexts, and environments. There are many more configuration options available, but these defaults will do for now.

Running the tests

Running the forge test command will compile the project and run the tests:

$ forge test
[â Š] Compiling...
[â †] Compiling 3 files with 0.8.10
[â ”] Solc finished in 225.61ms
Compiler run successful

Running 5 tests for src/test/Greeter.t.sol:GreeterTest
[PASS] test_custom_greeting() (gas: 10206)
[PASS] test_default_greeting() (gas: 9822)
[PASS] test_get_greeting() (gas: 9813)
[PASS] test_non_owner_cannot_set_greeting() (gas: 12202)
[PASS] test_set_greeting() (gas: 18728)
Test result: ok. 5 passed; 0 failed; finished in 592.04µs

This looks good—everything passed. Let's explore the test file a bit further.

Reading the tests

Foundry uses an xUnit style test framework called ds-test, written in Solidity. Unlike other Solidity development frameworks, this makes it possible to write pure Solidity unit tests.

Test files are created as .sol files in the src/test/ directory. It's a convention to put an extra .t in the test file name, such that Greeter.t.sol is the test file for the Greeter.sol contract. (However, this is just a convention. The test runner will recognize as tests any files that inherit from ds-test).

Let's take a look at our test file, Greeter.t.sol. We haven't covered Solidity syntax in detail yet, but hopefully it will be legible enough to make some observations:

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

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

import "../Greeter.sol";

contract GreeterTest is DSTest {
    Vm public constant vm = Vm(HEVM_ADDRESS);

    Greeter internal greeter;

    function setUp() public {
        greeter = new Greeter("Hello");
    }

    function test_default_greeting() public {
       assertEq(greeter.greet(), "Hello, world!");
    }
    
    function test_custom_greeting() public {
       assertEq(greeter.greet("foundry"), "Hello, foundry!");
    }

    function test_get_greeting() public {
        assertEq(greeter.greeting(), "Hello");
    }
    
    function test_set_greeting() public {
        greeter.setGreeting("Ahoy-hoy");
        assertEq(greeter.greet(), "Ahoy-hoy, world!");
    }
    
    function test_non_owner_cannot_set_greeting() public {
        vm.prank(address(1));
        try greeter.setGreeting("Ahoy-hoy") {
            fail();
        } catch Error(string memory message) {
            assertEq(message, "Ownable: caller is not the owner");
        }
    }
}

Our test suite is defined as a Solidity contract, which looks a lot like a class or module in other languages.

contract GreeterTest is DSTest {
}

We create an instance of the contract we're testing in the setUp function, and access it later in our tests:

    function setUp() public {
        greeter = new Greeter("Hello");
    }

Each test method is a public function prefixed with the word test.

    function test_default_greeting() public { }
    
    function test_custom_greeting() public { }

    function test_get_greeting() public { }
    
    function test_set_greeting() public { }

Inside each of these functions, we have access to assertions like assertEq:

    function test_default_greeting() public {
       assertEq(greeter.greet(), "Hello, world!");
    }

If you've used an xUnit style test framework before, this should all be pretty familiar. In fact, with the exception of a few keywords, this looks a lot like Java or Javascript.

Reading the contract under test

Similarly, let's take a look at Greeter.sol, the contract under test, and see what we can deduce. Here it is in full:

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

import "openzeppelin-contracts/contracts/access/Ownable.sol";

contract Greeter is Ownable {
    string public greeting;

    constructor(string memory _greeting) {
        greeting = _greeting;
    }

    function greet() public view returns (string memory) {
        return _buildGreeting("world");
    }

    function greet(string memory name) public view returns (string memory) {
        return _buildGreeting(name);
    }

    function setGreeting(string memory _greeting) public onlyOwner {
        greeting = _greeting;
    }

    function _buildGreeting(string memory name) internal view returns (string memory) {
        return string(abi.encodePacked(greeting, ", ", name, "!"));
    }
}

We can import code from external files:

import "openzeppelin-contracts/contracts/access/Ownable.sol";

Make use of something like inheritance:

contract Greeter is Ownable {
}

Define variables with visibility:

    string public greeting;

Create functions that take arguments:

    function greet(string memory name) public view returns (string memory) {
        return _buildGreeting(name);
    }

...as well as functions that don't:

    function greet() public view returns (string memory) {
        return _buildGreeting("world");
    }

Update variables:

    function setGreeting(string memory _greeting) public onlyOwner {
        greeting = _greeting;
    }

...and call internal functions:

    function greet(string memory name) public view returns (string memory) {
        return _buildGreeting(name);
    }

    function _buildGreeting(string memory name) internal view returns (string memory) {
        return string(abi.encodePacked(greeting, ", ", name, "!"));
    }

Some of this, like the keyword contract, types like (string memory), and the abi.encodePacked function, may look a little esoteric, but most of this code should be pretty legible. Again, if you squint hard enough, it kind of looks like Javascript.

Writing our first contract

Let's take our first steps into writing Solidity by test driving a Fizzbuzz contract. Our Fizzbuzz function should take an integer argument and:

  • Return "fizz" for numbers divisible by 3
  • Return "buzz" for numbers divisible by 5
  • Return "fizzbuzz" for numbers divisible by both 3 and 5
  • Return the input number as a string for all other cases.

Let's start by setting up a new test contract. Create FizzBuzz.t.sol alongside Greeter.t.sol:

$ touch src/test/FizzBuzz.t.sol

We'll start with a test that intentionally fails in order to make sure everything's connected correctly:

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

import "ds-test/test.sol";
import "../FizzBuzz.sol";

contract FizzBuzzTest is DSTest {
    FizzBuzz internal fizzbuzz;

    function setUp() public {
        fizzbuzz = new FizzBuzz();
    }

    function test_math_is_broken() public {
        uint256 two = 1 + 1;
        assertEq(two, 3);
    }
}

Function names

A brief style note: it's conventional to use mixedCase for Solidity function and variable names, but I like to intentionally break this rule for test functions and use snake_case instead. This helps distinguish them from production code when reading test output and function traces.

Let's give our newly created tests a spin:

$ forge test                   
[â Š] Compiling...
[â ’] Unable to resolve import: "../FizzBuzz.sol" with remappings:
    ds-test/=/Users/ecm/Projects/forge-template/lib/ds-test/src/
    forge-std/=/Users/ecm/Projects/forge-template/lib/forge-std/src/
    openzeppelin-contracts/=/Users/ecm/Projects/forge-template/lib/openzeppelin-contracts/
    src/=/Users/ecm/Projects/forge-template/src/
[â †] Compiling 2 files with 0.8.10
[â °] Solc finished in 9.06ms
Error: 
   0: Compiler run failed
      ParserError: Source "/Users/ecm/Projects/forge-template/src/FizzBuzz.sol" not found: File not found.
       --> /Users/ecm/Projects/forge-template/src/test/FizzBuzz.t.sol:5:1:
        |
      5 | import "../FizzBuzz.sol";
        | ^^^^^^^^^^^^^^^^^^^^^^^^^
   0: 

Location:
   cli/src/cmd/utils.rs:43

Oops, we forgot to create our contract under test! Fortunately, the Solidity compiler outbut is usually very helpful. Here, it points to the location of the missing import, and prints the full path to the file it can't find. Let's create FizzBuzz.sol and try again:

$ touch src/FizzBuzz.sol

For now, we'll just create an empty contract:

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

contract FizzBuzz {}

With our empty production contract in place, let's try again:

forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 32.87ms
Compiler run successful

Running 1 test for src/test/FizzBuzz.t.sol:FizzBuzzTest
[FAIL] test_math_is_broken() (gas: 10940)
Logs:
  Error: a == b not satisfied [uint]
    Expected: 3
      Actual: 2

Test result: FAILED. 0 passed; 1 failed; finished in 1.49ms

Failed tests:
[FAIL] test_math_is_broken() (gas: 10940)

Encountered a total of 1 failing tests, 0 tests succeeded

Success! Fortunately, math is not actually broken, and our test failed as it should. Forge printed the expected and actual values to help us diagnose the failure.

Writing the first test

Let's replace our intentionally failing test with our first real test. Values divisible by 3 should print "fizz":

function test_returns_fizz_when_divisible_by_three() public {
    assertEq(fizzbuzz.fizzbuzz(3), "fizz");
}

Test driving our contract means we define our contract's interface when we write our tests. In this case, we'll expect to add a fizzbuzz function that takes an integer argument and returns a string. Let's give our tests another run:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 1 files with 0.8.10
[â †] Solc finished in 9.24ms
Error: 
   0: Compiler run failed
      TypeError: Member "fizzbuzz" not found or not visible after argument-dependent lookup in contract FizzBuzz.
        --> /Users/ecm/Projects/forge-template/src/test/FizzBuzz.t.sol:15:18:
         |
      15 |         assertEq(fizzbuzz.fizzbuzz(3), "fizz");
         |                  ^^^^^^^^^^^^^^^^^
   0: 

Location:
   cli/src/cmd/utils.rs:43

The Solidity compiler fits nicely into a TDD workflow, since the compiler will usually point towards the next incremental step. In this case, we haven't yet created a fizzbuzz function on our contract under test. Let's keep our workflow disciplined and add the simplest implementation that could possibly pass:

contract FizzBuzz {
    function fizzbuzz(uint n) public returns (string memory) {
        return "fizz";
    }
}

And while we're here, let's take a brief detour to cover a few Solidity concepts...

Some Solidity concepts

Let's stop and cover a few Solidity concepts while we're here: visibility, value types, and reference types. For reference, here's the function we just defined:

contract FizzBuzz {
    function fizzbuzz(uint n) public returns (string memory) {
        return "fizz";
    }
}

Visibility

We've defined fizzbuzz as a public function, which means it can be called both internally by other methods in our contract and externally through message sends. There are a few other function visibility modifiers in Solidity: external functions can be called by other contracts but not internally, internal functions can only be accessed internally, and private functions can only be accessed internally and are not visible to derived contracts. Variables have public, internal, and private visibility, too.

Value types

Our fizzbuzz function takes one parameter n as an argument and returns a string. The input parameter n is a uint, or unsigned 256-bit integer. "Unsigned" means this integer type represents a non-negative integer value. Integers in Solidity are value types, i.e. always copied and passed by value when they are used in arguments and assignments.

256-bit integers are very large: a uint256 can store a value as large as \( 2^{256}-1 \), which is way bigger than the 32-bit and 64-bit integers used by default in most other common programming languages.

To compare the maximum value of the two types, let's jump into a Python shell and look at the difference:

$ python
>>> (2 ** 64) - 1
18446744073709551615
>>> (2 ** 256) - 1
115792089237316195423570985008687907853269984665640564039457584007913129639935

uint vs uint256

The uint type is an implicit alias for uint256, but it's considered good Solidity style to always be explicit and prefer using uint256 to uint. Integers of different sizes can be defined in steps of 8, e.g. uint8, uint128, and uint216.

Reference types

The return value of our function is (string memory), a reference type. Structs, arrays, and mappings are all reference types in Solidity. (Strings are secretly arrays of bytes under the hood, so they are reference types too). Unlike value types, which are copied each time they are used, reference types are passed by reference, so we have to be more careful about how they are used and modified to avoid unexpected mutations.

When we declare a reference type, we must always also declare the "data area" where it will be stored. There are three options: calldata, memory, and storage. In the case of our return value, we're using memory.

calldata is a special, immutable, super-temporary location for function arguments. When you can get away with using it, calldata is a great location because it's immutable, avoids copies, and is cheap to use.

memory is a temporary location analogous to runtime memory. Every function call gets access to a freshly cleared chunk of memory that can expand as necessary. Writing to memory is much cheaper than writing to storage, but it still costs gas to read and write.

storage is a permanent location that is persistent between function calls. It is expensive to read and very expensive to initialize and write. (This is for good reason: any data we write to storage will be replicated on every node in the Ethereum network and stored forever!)

Onward. Let's run our tests again now that we understand our own code.

Running the first test

$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 75.22ms
Compiler run successful (with warnings)
Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
 --> /Users/ecm/Projects/forge-template/src/FizzBuzz.sol:6:23:
  |
6 |     function fizzbuzz(uint n) public returns (string memory) {
  |                       ^^^^^^

Warning: Function state mutability can be restricted to pure
 --> /Users/ecm/Projects/forge-template/src/FizzBuzz.sol:6:5:
  |
6 |     function fizzbuzz(uint n) public returns (string memory) {
  |     ^ (Relevant source part starts here and spans across multiple lines).

Running 1 test for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 6984)
Test result: ok. 1 passed; 0 failed; finished in 249.13µs

Success! Our first passing test. The Solidity compiler has also passed on some helpful warnings:

Warning: Unused function parameter. Remove or comment out the variable name to silence this warning.
 --> /Users/ecm/Projects/forge-template/src/FizzBuzz.sol:6:23:
  |
6 |     function fizzbuzz(uint n) public returns (string memory) {
  |                       ^^^^^^

Warning: Function state mutability can be restricted to pure
 --> /Users/ecm/Projects/forge-template/src/FizzBuzz.sol:6:5:
  |
6 |     function fizzbuzz(uint n) public returns (string memory) {
  |     ^ (Relevant source part starts here and spans across multiple lines).

First, we're not yet using the parameter we've passed to the fizzbuzz function, so we can omit giving it a name. (This looks weird, but fine).

Second, since our function doesn't read or mutate any state, we can add an additional function modifier and declare it pure. It's good to build the habit of fixing any compiler warnings as we go. We'll also use the more explicit uint256 instead of uint.

Here's what our full contract code looks like after following these recommendations:

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

contract Fizzbuzz {
    function fizzbuzz(uint256) public pure returns (string memory) {
        return "fizz";
    }
}

Test driving Fizzbuzz

Let's follow the usual TDD workflow and add a new test to drive us forward. Passing the number 5 should print "buzz":

function test_returns_buzz_when_divisible_by_five() public {
    assertEq(fizzbuzz.fizzbuzz(5), "buzz");
}

Run the test and see it fail:

$ forge test
[â Š] Compiling...
No files changed, compilation skipped

Running 2 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[FAIL] test_returns_buzz_when_divisible_by_five() (gas: 17351)
Logs:
  Error: a == b not satisfied [string]
    Value a: fizz
    Value b: buzz

[PASS] test_returns_fizz_when_divisible_by_three() (gas: 6984)
Test result: FAILED. 1 passed; 1 failed; finished in 1.35ms

Failed tests:
[FAIL] test_returns_buzz_when_divisible_by_five() (gas: 17351)

Encountered a total of 1 failing tests, 1 tests succeeded

...and finally, update our production code. Again, let's write the simplest thing that could possibly work:

contract Fizzbuzz {
    function fizzbuzz(uint256 n) public pure returns (string memory) {
        if (n == 5) {
            return "buzz";
        }
        return "fizz";
    }
}

And watch the tests pass...

$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 77.56ms
Compiler run successful

Running 2 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 7034)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 7024)
Test result: ok. 2 passed; 0 failed; finished in 319.33µs

Looks good. Let's add one more test. Numbers that aren't divisible by either 3 or 5 should return themselves as a string:

function test_returns_number_as_string_otherwise() public {
    assertEq(fizzbuzz.fizzbuzz(7), "7");
}

Here's the failure:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 1 files with 0.8.10
[â †] Solc finished in 84.71ms
Compiler run successful

Running 3 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 7056)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 7046)
[FAIL] test_returns_number_as_string_otherwise() (gas: 17380)
Logs:
  Error: a == b not satisfied [string]
    Value a: fizz
    Value b: 7

Test result: FAILED. 2 passed; 1 failed; finished in 1.58ms

Failed tests:
[FAIL] test_returns_number_as_string_otherwise() (gas: 17380)

So how do we convert this value to a string?

Importing a library

We're in uncharted territory now: we need to convert our uint256 input into a string memory. Maybe we can just try converting it to another type with the string function?

contract FizzBuzz {

    function fizzbuzz(uint256 n) public pure returns (string memory) {
        if (n == 5) {
            return "buzz";
        }
        if (n == 3) {
            return "fizz";
        }
        return string(n);
    }
}

Let's give it a try:

$ forge test
[â Š] Compiling...
[â ’] Compiling 2 files with 0.8.10
[â ˘] Solc finished in 8.57ms
Error: 
   0: Compiler run failed
      TypeError: Explicit type conversion not allowed from "uint256" to "string memory".
        --> /Users/ecm/Projects/forge-template/src/FizzBuzz.sol:13:16:
         |
      13 |         return string(n);
         |                ^^^^^^^^^
   0: 

Location:
   cli/src/cmd/utils.rs:43

Nope. The good news is that the Solidity compiler is pretty helpful in cases like these. It's told us that type conversion from uint256 to string is not allowed.

There's no built in integer-to-string conversion in Solidity, so we have a few options: write it ourselves, copy-paste some other implementation, or import a library.

Let's use a library. OpenZeppelin Contracts includes a string utilities library that does exactly what we need.

We'll first need to install it using forge. Running forge install will install a repository from Github as a git submodule in our project's lib/ directory:

$ forge install OpenZeppelin/openzeppelin-contracts

Once it's installed, here's how to import and use it:

import "openzeppelin-contracts/contracts/utils/Strings.sol";

contract FizzBuzz {

    function fizzbuzz(uint256 n) public pure returns (string memory) {
        if (n == 5) {
            return "buzz";
        }
        if (n == 3) {
            return "fizz";
        }
        return Strings.toString(n);
    }
}

You can read more about libraries in the Solidity docs.

Let's try our tests again:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 3 files with 0.8.10
[â †] Solc finished in 107.12ms
Compiler run successful

Running 3 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 7056)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 7071)
[PASS] test_returns_number_as_string_otherwise() (gas: 7820)
Test result: ok. 3 passed; 0 failed; finished in 1.57ms

Success!

Finishing Fizzbuzz

Although our tests all pass, our code is still pretty naive. We've hardcoded if statements that will only work for the numbers 3 and 5. Let's add a few more test cases to force ourselves to handle more than one value:

    function test_returns_fizz_when_divisible_by_three() public {
        assertEq(fizzbuzz.fizzbuzz(3), "fizz");
        assertEq(fizzbuzz.fizzbuzz(6), "fizz");
        assertEq(fizzbuzz.fizzbuzz(27), "fizz");
    }

    function test_returns_buzz_when_divisible_by_five() public {
        assertEq(fizzbuzz.fizzbuzz(5), "buzz");
        assertEq(fizzbuzz.fizzbuzz(10), "buzz");
        assertEq(fizzbuzz.fizzbuzz(175), "buzz");
    }

With the above tests in place, here's our failing test output:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 105.78ms
Compiler run successful

Running 3 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[FAIL] test_returns_buzz_when_divisible_by_five() (gas: 33029)
Logs:
  Error: a == b not satisfied [string]
    Value a: 10
    Value b: buzz
  Error: a == b not satisfied [string]
    Value a: 175
    Value b: buzz

[FAIL] test_returns_fizz_when_divisible_by_three() (gas: 31897)
Logs:
  Error: a == b not satisfied [string]
    Value a: 6
    Value b: fizz
  Error: a == b not satisfied [string]
    Value a: 27
    Value b: fizz

[PASS] test_returns_number_as_string_otherwise() (gas: 7820)
Test result: FAILED. 1 passed; 2 failed; finished in 1.40ms

Failed tests:
[FAIL] test_returns_buzz_when_divisible_by_five() (gas: 33029)
[FAIL] test_returns_fizz_when_divisible_by_three() (gas: 31897)

Encountered a total of 2 failing tests, 1 tests succeeded

Like many other languages, Solidity includes a % modulo operator that returns the remainder after dividing two numbers. We can use it to check for divisibility by testing whether the remainder is zero:

contract FizzBuzz {

    function fizzbuzz(uint256 n) public pure returns (string memory) {
        if (n % 5 == 0) {
            return "buzz";
        }
        if (n % 3 == 0) {
            return "fizz";
        }
        return Strings.toString(n);
    }
}

Let's try it out:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 105.16ms
Compiler run successful

Running 3 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 11978)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 12178)
[PASS] test_returns_number_as_string_otherwise() (gas: 7916)
Test result: ok. 3 passed; 0 failed; finished in 1.31ms

Success! Tests pass.

The final test

We have one more case to test, when the number is divisible by both 3 and 5. Here's a test:

function test_returns_fizzbuzz_when_divisible_by_three_and_five() public {
    assertEq(fizzbuzz.fizzbuzz(15), "fizzbuzz");
}

Let's see the expected failure:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 1 files with 0.8.10
[â †] Solc finished in 111.56ms
Compiler run successful

Running 4 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 11956)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 12156)
[FAIL] test_returns_fizzbuzz_when_divisible_by_three_and_five() (gas: 37420)
Logs:
  Error: a == b not satisfied [string]
    Value a: buzz
    Value b: fizzbuzz
  Error: a == b not satisfied [string]
    Value a: buzz
    Value b: fizzbuzz
  Error: a == b not satisfied [string]
    Value a: buzz
    Value b: fizzbuzz

[PASS] test_returns_number_as_string_otherwise() (gas: 7939)
Test result: FAILED. 3 passed; 1 failed; finished in 1.36ms

Failed tests:
[FAIL] test_returns_fizzbuzz_when_divisible_by_three_and_five() (gas: 37420)

Encountered a total of 1 failing tests, 3 tests succeeded

And finally, add the laziest solution possible, which completes our Fizzbuzz:

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

import "openzeppelin-contracts/contracts/utils/Strings.sol";
contract FizzBuzz {

    function fizzbuzz(uint256 n) public pure returns (string memory) {
        if (n % 3 == 0 && n % 5 == 0) {
            return "fizzbuzz";
        }
        if (n % 3 == 0) {
            return "fizz";
        }
        if (n % 5 == 0) {
            return "buzz";
        }
        return Strings.toString(n);
    }
}
$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 116.50ms
Compiler run successful

Running 4 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 12478)
[PASS] test_returns_fizz_when_divisible_by_three() (gas: 12429)
[PASS] test_returns_fizzbuzz_when_divisible_by_three_and_five() (gas: 12255)
[PASS] test_returns_number_as_string_otherwise() (gas: 8039)
Test result: ok. 4 passed; 0 failed; finished in 1.13ms

Property based testing

We could leave our fully-functional Fizzbuzz here, but before we wrap up, let's take a look at another powerful feature of the Forge test runner: property-based "fuzz tests."

The four unit tests we wrote to test drive our solution touch only a few points in the enormous domain of our fizzbuzz function. Sure, we picked them because they are specific, special cases, but wouldn't it be nice to have a little more confidence that we've covered all the edge cases by testing a few more? We're in luck, because Forge makes it easy to turn our unit tests into property-based tests.

Forge will interpret any unit test function that takes an argument as a property based test, and run it multiple times with randomly assigned values. Let's just update our first unit test for now, by adding a uint256 argument to the test function and replacing the hardcoded value in the assertion.

Here's our existing unit test:

    function test_returns_fizz_when_divisible_by_three() public {
        assertEq(fizzbuzz.fizzbuzz(3), "fizz");
        assertEq(fizzbuzz.fizzbuzz(6), "fizz");
        assertEq(fizzbuzz.fizzbuzz(27), "fizz");
    }

Here it is as a property based test parameterized by an integer n:

    function test_returns_fizz_when_divisible_by_three(uint256 n) public {
        assertEq(fizzbuzz.fizzbuzz(n), "fizz");
    }

Nice and concise: instead of cherry picking specific examples, we just define the property once. What happens when we run this test?

$ forge test
[â Š] Compiling...
No files changed, compilation skipped

Running 4 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 12478)
[FAIL. Counterexample: calldata=0x3f56f23b4000000000000000000000000000000000000000000000000000000000000000, args=[28948022309329048855892746252171976963317496166410141009864396001978282409984]] test_returns_fizz_when_divisible_by_three(uint256) (runs: 1, ÎĽ: 7363, ~: 7363)
Logs:
  Error: a == b not satisfied [string]
    Value a: 28948022309329048855892746252171976963317496166410141009864396001978282409984
    Value b: fizz

[PASS] test_returns_fizzbuzz_when_divisible_by_three_and_five() (gas: 12210)
[PASS] test_returns_number_as_string_otherwise() (gas: 8039)
Test result: FAILED. 3 passed; 1 failed; finished in 49.08ms

Failed tests:
[FAIL. Counterexample: calldata=0x3f56f23b4000000000000000000000000000000000000000000000000000000000000000, args=[28948022309329048855892746252171976963317496166410141009864396001978282409984]] test_returns_fizz_when_divisible_by_three(uint256) (runs: 1, ÎĽ: 7363, ~: 7363)

Encountered a total of 1 failing tests, 3 tests succeeded

They failed! Can you see why?

It's a little hard to tell thanks to the massive 256-bit integer we got back as our counterexample, but we need to constrain our input parameter n to specific values that are divisible by three.

The pattern for doing this in Forge is to use a special cheatcode, vm.assume(bool). This will filter out any fuzz test inputs that don't match the specified condition. To use this cheatcode, we'll need to set up access to the Vm interface and add a line to our test, like this:

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

contract FizzBuzzTest is DSTest {
    Vm public constant vm = Vm(HEVM_ADDRESS);
    FizzBuzz internal fizzbuzz;

    function setUp() public {
        fizzbuzz = new FizzBuzz();
    }

    function test_returns_fizz_when_divisible_by_three(uint256 n) public {
        vm.assume(n % 3 == 0);
        vm.assume(n % 5 != 0);
        assertEq(fizzbuzz.fizzbuzz(n), "fizz");
    }
}

In this case we're filtering out anything that's not divisible by 3 (it shouldn't return "fizz") and anything that's also divisible by 5 (it should return "fizzbuzz", not "fizz"). Let's try again:

$ forge test
Running 4 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five() (gas: 12478)
[PASS] test_returns_fizz_when_divisible_by_three(uint256) (runs: 256, ÎĽ: 10746, ~: 10746)
[PASS] test_returns_fizzbuzz_when_divisible_by_three_and_five() (gas: 12210)
[PASS] test_returns_number_as_string_otherwise() (gas: 8039)
Test result: ok. 4 passed; 0 failed; finished in 38.66ms

It works, and we've turned our single unit test into 256 assertions!

Here's how we can update all the tests to run as property-based tests:

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

import "ds-test/test.sol";
import "forge-std/Vm.sol";
import "openzeppelin-contracts/contracts/utils/Strings.sol";

import "../FizzBuzz.sol";

contract FizzBuzzTest is DSTest {
    Vm public constant vm = Vm(HEVM_ADDRESS);
    FizzBuzz internal fizzbuzz;

    function setUp() public {
        fizzbuzz = new FizzBuzz();
    }

    function test_returns_fizz_when_divisible_by_three(uint256 n) public {
        vm.assume(n % 3 == 0);
        vm.assume(n % 5 != 0);
        assertEq(fizzbuzz.fizzbuzz(n), "fizz");
    }
    
    function test_returns_buzz_when_divisible_by_five(uint256 n) public {
        vm.assume(n % 3 != 0);
        vm.assume(n % 5 == 0);
        assertEq(fizzbuzz.fizzbuzz(n), "buzz");
    }

    function test_returns_fizzbuzz_when_divisible_by_three_and_five(uint256 n) public {
        vm.assume(n % 3 == 0);
        vm.assume(n % 5 == 0);
        assertEq(fizzbuzz.fizzbuzz(n), "fizzbuzz");
    }

    function test_returns_number_as_string_otherwise(uint256 n) public {
        vm.assume(n % 3 != 0);
        vm.assume(n % 5 != 0);
        assertEq(fizzbuzz.fizzbuzz(n), Strings.toString(n));
    }
}

Note how we imported and used the Strings.sol library just like we did in our production code in order to convert integer values to strings in the last test case. Our tests are just Solidity files, and we can import and use libraries and other contracts here, too.

Forge will run 256 different values through our fuzz tests by default, but we can increase the number of runs with a configuration option in foundry.toml:

[default]
src = 'src'
out = 'out'
libs = ['lib']
verbosity = 2
fuzz_runs = 5000
$ forge test
[â Š] Compiling...
No files changed, compilation skipped

Running 4 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] test_returns_buzz_when_divisible_by_five(uint256) (runs: 5000, ÎĽ: 10755, ~: 10755)
[PASS] test_returns_fizz_when_divisible_by_three(uint256) (runs: 5000, ÎĽ: 10724, ~: 10724)
[PASS] test_returns_fizzbuzz_when_divisible_by_three_and_five(uint256) (runs: 5000, ÎĽ: 10635, ~: 10635)
[PASS] test_returns_number_as_string_otherwise(uint256) (runs: 5000, ÎĽ: 76811, ~: 98075)
Test result: ok. 4 passed; 0 failed; finished in 1.53s

Just like that, we've run the equivalent of 20,000 unit test assertions in under 2 seconds. Pretty cool, right? But can we do better?

Symbolic testing

Property-based tests are like running many randomly generated unit test cases, but we can do even better using symbolic execution. Rather than picking a specific, concrete integer value of n for each test, symbolic execution uses a special VM that leaves the value n as an abstract representation and exhaustively explores every possible execution path. This is more like a proof than a test, since it covers every possible input.

Foundry does not yet support symbolic execution and testing, although it's on the roadmap. Instead we can use its predecessor Dapptools to write a few symbolic execution tests.

Installing Dapptools

If you want to install Dapptools and follow along, see the instructions here. Dapptools is a little less friendly than Foundry, and requires installing the Nix package manager.

To change our property-based tests to dapptools proofs, we can replace test_ with prove_ in the test name. Dapptools does not use the vm.assume cheatcode, but instead uses early returns inside each test function to filter out test cases that don't meet the test's assumptions. (Note that this means we've inverted the logic for each conditional.)

contract FizzBuzzTest is DSTest {
    FizzBuzz internal fizzbuzz;

    function setUp() public {
        fizzbuzz = new FizzBuzz();
    }

    function prove_returns_fizz_when_divisible_by_three(uint256 n) public {
        if(n % 3 != 0) return;
        if(n % 5 == 0) return;
        assertEq(fizzbuzz.fizzbuzz(n), "fizz");
    }
    
    function prove_returns_buzz_when_divisible_by_five(uint256 n) public {
        if(n % 3 == 0) return;
        if(n % 5 != 0) return;
        assertEq(fizzbuzz.fizzbuzz(n), "buzz");
    }

    function prove_returns_fizzbuzz_when_divisible_by_three_and_five(uint256 n) public {
        if(n % 3 != 0) return;
        if(n % 5 != 0) return;
        assertEq(fizzbuzz.fizzbuzz(n), "fizzbuzz");
    }
}

Here's what our test results look like using Dapptools:

$ DAPP_SOLC_VERSION=0.8.10 DAPP_REMAPPINGS=$(cat remappings.txt) dapp test
Temporarily installing solc-0.8.10...
Running 3 tests for src/test/FizzBuzz.t.sol:FizzBuzzTest
[PASS] prove_returns_fizzbuzz_when_divisible_by_three_and_five(uint256)
[PASS] prove_returns_fizz_when_divisible_by_three(uint256)
[PASS] prove_returns_buzz_when_divisible_by_five(uint256)

You'll notice these are significantly slower to run than unit or property-based tests. On my machine, these take several minutes to run. But they give a much stronger guarantee about the correctness of our code. (They are slower because they are doing a lot more computation using an SMT solver under the hood).

You may also notice that we've omitted a test case: proving the case for numbers that are not multiples of 3 or 5 is too complex. Symbolic execution is great for a narrow set of provable properties without too much branching logic, like simple arithmetic functions, but for complex functions, proofs can become infeasible with just a little additional complexity. In this case, the call to Strings.toString, which includes several internal branching statements, adds too much complexity for our proof to complete.

However, it's often possible to rewrite or simplify functions into something more tractable. Imagine if our fizzbuzz function returned a number instead of a string. This is logically equivalent, except we're omitting the string conversion step in the final case:

contract FizzBuzz {

    function fizzbuzz(uint256 n) public pure returns (uint256) {
        if (n % 3 == 0 && n % 5 == 0) {
            return 1;
        }
        else if (n % 3 == 0) {
            return 2;
        }
        else if (n % 5 == 0) {
            return 3;
        } else {
            return 4;
        }
    }
}

Now, we can prove the following test, in about the same time as it takes to run the others:

    function prove_returns_fizzbuzz_otherwise(uint256 n) public {
        if(n % 3 == 0) return;
        if(n % 5 == 0) return;
        assertEq(fizzbuzz.fizzbuzz(n), 4);
    }

This ability to "upgrade" our tests from one specific input case to many randomized cases to a generalized proof is a very cool and powerful feature of Foundry and Dapptools.

🎮 Basic Game

Creating the basic game

Last week we set up Foundry, started exploring Solidity through the test runner, and wrote a really simple Fizzbuzz contract. In Week 2, we'll create a basic Tic Tac Toe game in Solidity.

Goals this week

  • Build out a basic framework for a Tic Tac Toe game: a data structure for the board, functions to mark squares and check for a winner, and basic input validations.
  • Get comfortable with the basics of Solidity syntax.
  • Continue practicing testing using Forge and ds-test.

Suggested homework

Creating the game contract

Just like we did when we set up for Fizzbuzz, let's first create a new test file:

$ touch src/test/TicTacToken.t.sol

For now we'll just import ds-test and create an empty contract:

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

import "ds-test/test.sol";

contract TicTacTokenTest is DSTest {}

We'll do the same for the game contract. Create a new file:

$ touch src/TicTacToken.sol

And we can start with an empty contract here, too:

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

contract TicTacToken {}

Test driving the board

Let's create our first test. To start, let's represent the empty Tic Tac Toe board as an array of strings, and check that each element in the array is the empty string:

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

import "ds-test/test.sol";

import "../TicTacToken.sol";

contract TicTacTokenTest is DSTest {
    TicTacToken internal ttt;

    function setUp() public {
        ttt = new TicTacToken();
    }

    function test_has_empty_board() public {
        for (uint256 i=0; i<9; i++) {
            assertEq(ttt.board(i), "");
        }
    }
}

Automatic getters

Notice the ttt.board(i) function call inside the loop above, which takes an integer index as its argument? Solidity automatically creates getter functions for public state variables.

These getter functions behave differently depending on the type of the variable. If your public state variable is a primitive type, like uint8, address, or bool, its getter returns its value directly. If it's a more complex type like an array or mapping, its getter function takes an argument: the index of an item to retrieve from the array or the key to access in the mapping. In the test above, we retrieve each element in the array by index.

Let's run this test and see where the compiler points us:

$ forge test
[â Š] Compiling...
[â ’] Compiling 1 files with 0.8.10
[â ˘] Solc finished in 8.50ms
Error: 
   0: Compiler run failed
      TypeError: Member "board" not found or not visible after argument-dependent lookup in contract TicTacToken.
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:17:22:
         |
      17 |             assertEq(ttt.board(i), "");
         |                      ^^^^^^^^^

Great—we need to add something named board to our contract in order to call this function.

We know our board has 9 spaces, so we can declare a fixed size array. We'll make it public, which will generate the board(i) getter:

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

contract TicTacToken {
    string[9] public board;
}

Run our tests...

$ forge test
[â Š] Compiling...
[â ˘] Compiling 2 files with 0.8.10
[â †] Solc finished in 82.77ms
Compiler run successful

Running 1 test for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_has_empty_board() (gas: 46121)
Test result: ok. 1 passed; 0 failed; finished in 1.46ms

They pass!

Default values

Wait, what…they pass?! This might come as a surprise, since all we've done is create a state variable that should be an empty array. We never inserted any empty strings in that array, but somehow our test passed and successfully retrieved an empty string from the array! Here's what's going on:

There is no concept of "undefined," or "null" in Solidity. Instead, newly declared values always have a default value that depends on their type. For example, a bool will be false, a uint256 will be 0, and a string will be "". A fixed size array like our string[9] will have each of its elements initialized to the default value of its type. In our case, that's 9 empty strings. See the Solidity docs for more details on this behavior.

OK, on to another test. Working with only our default getter is pretty awkward, since we can only access individual items by index. How about a function that returns the whole board as an array?

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

import "ds-test/test.sol";

import "../TicTacToken.sol";

contract TicTacTokenTest is DSTest {
    TicTacToken internal ttt;

    function setUp() public {
        ttt = new TicTacToken();
    }

    function test_has_empty_board() public {
        for (uint256 i=0; i<9; i++) {
            assertEq(ttt.board(i), "");
        }
    }
    function test_get_board() public {
        string[9] memory expected = ["", "", "", "", "", "", "", "", ""];
        string[9] memory actual = ttt.getBoard();

        for (uint256 i=0; i<9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }
}

We still need to iterate over each element inside test_get_board(), since ds-test doesn't include an array equality matcher. We'll define a new getBoard function that returns the whole string[9] memory representing our board rather than an individual item by index.

Let's run the tests to make sure we see the expected failure:

$ forge test
[â Š] Compiling...
[â ˘] Compiling 1 files with 0.8.10
[â †] Solc finished in 9.15ms
Error: 
   0: Compiler run failed
      TypeError: Member "getBoard" not found or not visible after argument-dependent lookup in contract TicTacToken.
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:22:35:
         |
      22 |         string[9] memory actual = ttt.getBoard();
         |                                   ^^^^^^^^^^^^

And add a getBoard function:

// 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;
    }
}

Views

Notice that we can declare this function as a view because it's read only and doesn't modify any state. See the Solidity docs on state mutability for more about exactly what this means.

Now we can run our tests and see them pass:

$ forge test
[â Š] Compiling...
[â †] Compiling 2 files with 0.8.10
[â °] Solc finished in 130.38ms
Compiler run successful

Running 2 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_get_board() (gas: 44193)
[PASS] test_has_empty_board() (gas: 46814)
Test result: ok. 2 passed; 0 failed; finished in 2.16ms

Great, we can retrieve our full board as an array. On to some more interesting behavior.

Marking a space

Let's put an "X" in the first space on the board, then read it back in our test. We'll pass the index of the space in our board array where we want to place it and a string to represent the "X" or "O" marker:

    function test_can_mark_space_with_X() public {
        ttt.markSpace(0, "X");
        assertEq(ttt.board(0), "X");
    }

We can start with an empty markSpace function in the game contract:

    function markSpace(uint256 space, string calldata symbol) public {}

Calldata

We can use calldata as the data location for symbol since it's a function argument. Calldata is an immutable, ephemeral area where function arguments are stored.

Here's the output from our failing test. We expected an "X" and instead got empty string:

Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL] test_can_mark_space_with_X() (gas: 20298)
Logs:
  Error: a == b not satisfied [string]
    Value a: 
    Value b: X

[PASS] test_get_board() (gas: 44215)
[PASS] test_has_empty_board() (gas: 46836)
Test result: FAILED. 2 passed; 1 failed; finished in 2.06ms

Failed tests:
[FAIL] test_can_mark_space_with_X() (gas: 20298)

This is what we expected, since we haven't implemented it yet. Again, we're getting back an empty string—the default value for a fixed length array of strings. Let's make it pass by setting the space index of the board to the symbol we pass in:

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

All green again:

Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_X() (gas: 31282)
[PASS] test_get_board() (gas: 44215)
[PASS] test_has_empty_board() (gas: 46836)
Test result: ok. 3 passed; 0 failed; finished in 1.90ms

We should now also be able to mark spaces with an "O". Let's try it out:

    function test_can_mark_space_with_O() public {
        ttt.markSpace(0, "O");
        assertEq(ttt.board(0), "O");
    }

Since our function takes any string as an argument, we get this one for free:

Running 4 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 31337)
[PASS] test_can_mark_space_with_X() (gas: 31305)
[PASS] test_get_board() (gas: 44171)
[PASS] test_has_empty_board() (gas: 46859)
Test result: ok. 4 passed; 0 failed; finished in 1.90ms

Of course, we get a lot more for free, too! The caller can mark the board with any arbitrary string: a "B", a "đź’–", a "Solidity rocks!", whatever. Let's add some validation to allow only the markers "X" and "O".

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));
    }
}

Validating moves

Let's add another validation. How about making sure we can't overwrite a space that's already been marked? Here's an additional test:

    function test_cannot_overwrite_marked_space() public {
        ttt.markSpace(0, "X");
        
        vm.expectRevert("Already marked");
        ttt.markSpace(0, "O");
    }

Note that we need to put vm.expectRevert on the line directly before the call in our test that we expect to error.

Here's the result:

$ forge test
[â Š] Compiling...
[â †] Compiling 1 files with 0.8.10
[â °] Solc finished in 177.31ms
Compiler run successful

Running 6 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 33319)
[PASS] test_can_mark_space_with_X() (gas: 32325)
[PASS] test_cannot_mark_space_with_Z() (gas: 12799)
[FAIL. Reason: Call did not revert as expected] test_cannot_overwrite_marked_space() (gas: 40135)
[PASS] test_get_board() (gas: 44622)
[PASS] test_has_empty_board() (gas: 47219)
Test result: FAILED. 5 passed; 1 failed; finished in 1.13ms

Failed tests:
[FAIL. Reason: Call did not revert as expected] test_cannot_overwrite_marked_space() (gas: 40135)

Encountered a total of 1 failing tests, 5 tests succeeded

Let's add another require statement. We can even reuse our string comparison helper function:

// 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");
        require(_compareStrings(board[space], ""), "Already marked");
        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));
    }
}

Tests should pass:

$ forge test
[â Š] Compiling...
[â †] Compiling 2 files with 0.8.10
[â °] Solc finished in 183.60ms
Compiler run successful

Running 6 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 34537)
[PASS] test_can_mark_space_with_X() (gas: 33543)
[PASS] test_cannot_mark_space_with_Z() (gas: 12800)
[PASS] test_cannot_overwrite_marked_space() (gas: 40038)
[PASS] test_get_board() (gas: 44622)
[PASS] test_has_empty_board() (gas: 47219)
Test result: ok. 6 passed; 0 failed; finished in 781.38µs

And just like last time, let's extract and name an internal helper:

// 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");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return _compareStrings(board[i], "");
    }

    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));
    }
}

Note that this helper is a view, rather than a pure function, because it reads from the board state variable. We've got a good start on a basic board.

Validating turns

We're on a roll now. Let's add one final check in our markSpace function: symbols must alternate between X and O.

Here's a test:

    function test_symbols_must_alternate() public {
        ttt.markSpace(0, "X");
        vm.expectRevert("Not your turn");
        ttt.markSpace(1, "X");
    }

And the expected failure:

Failed tests:
[FAIL. Reason: Call did not revert as expected] test_symbols_must_alternate() (gas: 61452)

There are a few clever and efficient ways to track turns, but for now we'll be lazy: let's add an internal _turns counter and increment it on every move. Since "X" always goes first, when this counter is even, we'll know it's X's turn and when it's odd, we'll know it's O's:

contract TicTacToken {
    string[9] public board;
    uint256 internal _turns;

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

    function markSpace(uint256 space, string calldata symbol) public {
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function _validTurn(string calldata symbol) internal view returns (bool) {
        return (_turns % 2 == 0) ? _compareStrings(symbol, "X") : _compareStrings(symbol, "O");
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return _compareStrings(board[i], "");
    }

    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));
    }
}

Our new test, test_symbols_must_alternate passed, but it looks like this additional check caused test_can_mark_space_with_O to fail:

Running 7 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL. Reason: Not your turn] test_can_mark_space_with_O() (gas: 11050)
[PASS] test_can_mark_space_with_X() (gas: 57093)
[PASS] test_cannot_mark_space_with_Z() (gas: 12832)
[PASS] test_cannot_overwrite_marked_space() (gas: 64932)
[PASS] test_get_board() (gas: 44622)
[PASS] test_has_empty_board() (gas: 47219)
[PASS] test_symbols_must_alternate() (gas: 62462)
Test result: FAILED. 6 passed; 1 failed; finished in 1.25ms

Failed tests:
[FAIL. Reason: Not your turn] test_can_mark_space_with_O() (gas: 11050)

Encountered a total of 1 failing tests, 6 tests succeeded

This makes sense: we can no longer have player O move first in this test, since it's X's turn:

    function test_can_mark_space_with_O() public {
        ttt.markSpace(0, "O");
        assertEq(ttt.board(0), "O");
    }

We'll update the test to have X play a move first:

    function test_can_mark_space_with_O() public {
        ttt.markSpace(0, "X");
        ttt.markSpace(1, "O");
        assertEq(ttt.board(1), "O");
    }

And we're back to all green:

Running 7 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 85510)
[PASS] test_can_mark_space_with_X() (gas: 57093)
[PASS] test_cannot_mark_space_with_Z() (gas: 12832)
[PASS] test_cannot_overwrite_marked_space() (gas: 64974)
[PASS] test_get_board() (gas: 44622)
[PASS] test_has_empty_board() (gas: 47219)
[PASS] test_symbols_must_alternate() (gas: 62462)
Test result: ok. 7 passed; 0 failed; finished in 1.12ms

While we're here, it seems helpful to expose the current turn as a public function. Let's refactor and use currentTurn inside our _validTurn check. Since currentTurn has public visibility, we can both call it externally and access it from internal methods:

contract TicTacToken {
    string[9] public board;
    uint256 internal _turns;

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

    function markSpace(uint256 space, string calldata symbol) public {
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function currentTurn() public view returns (string memory) {
        return (_turns % 2 == 0) ? "X" : "O";
    }

    function _validTurn(string calldata symbol) internal view returns (bool) {
        return _compareStrings(symbol, currentTurn());
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return _compareStrings(board[i], "");
    }

    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));
    }
}

Finally, let's add a test that exercises currentTurn:

    function test_tracks_current_turn() public {
        assertEq(ttt.currentTurn(), "X");
        ttt.markSpace(0, "X");
        assertEq(ttt.currentTurn(), "O");
        ttt.markSpace(1, "O");
        assertEq(ttt.currentTurn(), "X");
    }

Looks good:

Running 8 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 85532)
[PASS] test_can_mark_space_with_X() (gas: 57093)
[PASS] test_cannot_mark_space_with_Z() (gas: 12854)
[PASS] test_cannot_overwrite_marked_space() (gas: 64885)
[PASS] test_get_board() (gas: 44644)
[PASS] test_has_empty_board() (gas: 47219)
[PASS] test_symbols_must_alternate() (gas: 62484)
[PASS] test_tracks_current_turn() (gas: 90235)
Test result: ok. 8 passed; 0 failed; finished in 1.19ms

Refactoring from strings

Strings are easy to read, but they'll be increasingly difficult to work with as our game grows. Before this gets out of control, let's refactor to a different representation and use integer values to represent the state of each square instead.

First the tests. Let's add integer constants at the top of our test contract, so we can refer to each by a named value like X, O, or EMPTY instead of a magic number.

Remember that the default value of an empty item in an integer array is 0, so we need to choose nonzero integers to represent X and O in order to distinguish them from an empty square. Let's use 1 for X and 2 for O.

// 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;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;

    function setUp() public {
        ttt = new TicTacToken();
    }
}

Now we can replace the "", "X" and "O" strings in our tests with these values:

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

  uint256 internal constant EMPTY = 0;
  uint256 internal constant X = 1;
  uint256 internal constant O = 2;

  function setUp() public {
    ttt = new TicTacToken();
  }

  function test_has_empty_board() public {
    for (uint256 i = 0; i < 9; i++) {
      assertEq(ttt.board(i), EMPTY);
    }
  }

  function test_get_board() public {
    uint256[9] memory expected = [
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY
    ];
    uint256[9] memory actual = ttt.getBoard();

    for (uint256 i = 0; i < 9; i++) {
      assertEq(actual[i], expected[i]);
    }
  }

  function test_can_mark_space_with_X() public {
    ttt.markSpace(0, X);
    assertEq(ttt.board(0), X);
  }

  function test_can_mark_space_with_O() public {
    ttt.markSpace(0, X);
    ttt.markSpace(1, O);
    assertEq(ttt.board(1), O);
  }

  function test_cannot_mark_space_with_Z() public {
    vm.expectRevert("Invalid symbol");
    ttt.markSpace(0, 3);
  }

  function test_cannot_overwrite_marked_space() public {
    ttt.markSpace(0, X);

    vm.expectRevert("Already marked");
    ttt.markSpace(0, O);
  }

  function test_symbols_must_alternate() public {
    ttt.markSpace(0, X);
    vm.expectRevert("Not your turn");
    ttt.markSpace(1, X);
  }

  function test_tracks_current_turn() public {
    assertEq(ttt.currentTurn(), X);
    ttt.markSpace(0, X);
    assertEq(ttt.currentTurn(), O);
    ttt.markSpace(1, O);
    assertEq(ttt.currentTurn(), X);
  }
}

We can follow the same pattern in the game code: add some constants at the top and remove our string comparison helper:

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

contract TicTacToken {
    uint256[9] public board;
    
    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;
    uint256 internal _turns;

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

    function markSpace(uint256 space, uint256 symbol) public {
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function currentTurn() public view returns (uint256) {
        return (_turns % 2 == 0) ? X : O;
    }

    function _validTurn(uint256 symbol) internal view returns (bool) {
        return symbol == currentTurn();
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return board[i] == EMPTY;
    }

    function _validSymbol(uint256 symbol) internal pure returns (bool) {
        return symbol == X || symbol == O;
    }
}

It's much more concise without string comparisons everywhere. Let's make sure it still works:

Running 8 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74806)
[PASS] test_can_mark_space_with_X() (gas: 51096)
[PASS] test_cannot_mark_space_with_Z() (gas: 10709)
[PASS] test_cannot_overwrite_marked_space() (gas: 56634)
[PASS] test_get_board() (gas: 29128)
[PASS] test_has_empty_board() (gas: 31943)
[PASS] test_symbols_must_alternate() (gas: 56515)
[PASS] test_tracks_current_turn() (gas: 76803)
Test result: ok. 8 passed; 0 failed; finished in 1.12ms

Checking for wins

To wrap up our basic board, let's add the ability to check for a winner. Let's start with a test for a horizontal win:

  function test_checks_for_horizontal_win() public {
    ttt.markSpace(0, X);
    ttt.markSpace(3, O);
    ttt.markSpace(1, X);
    ttt.markSpace(4, O);
    ttt.markSpace(2, X);
    assertEq(ttt.winner(), X);
  }

  function test_checks_for_horizontal_win_row2() public {
    ttt.markSpace(3, X);
    ttt.markSpace(0, O);
    ttt.markSpace(4, X);
    ttt.markSpace(1, O);
    ttt.markSpace(5, X);
    assertEq(ttt.winner(), X);
  }

The tests guide us to our next step: we'll need to add a public winner function.

$ forge test
[â Š] Compiling...
[â ’] Compiling 1 files with 0.8.10
[â ˘] Solc finished in 10.24ms
Error:
   0: Compiler run failed
      TypeError: Member "winner" not found or not visible after argument-dependent lookup in contract TicTacToken.
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:89:14:
         |
      89 |     assertEq(ttt.winner(), X);
         |              ^^^^^^^^^^

There are many ways to check for a winner. As we did with currentTurn, let's not worry just yet about the most efficient one. (We'll get to that later, when we learn about gas optimization). For now, let's add a helper to get each row, a helper to check if a row has a winner, and a loop to iterate over each row.

We can use some arithmetic to check if a row contains a win by multiplying its three values. If a row is all X, its product will be 1 * 1 * 1 = 1. If it's all O, it will be 2 * 2 * 2 = 8.

Here's a helper to get the product for a row:

   function _row(uint256 row) internal view returns (uint256) {
       require(row < 3, "Invalid row");
       uint256 idx = 3 * row;
       return board[idx] * board[idx+1] * board[idx+2];
   }

And one to check if the product is a win:

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

To check for a winner, let's iterate over every combination.

    function winner() public view returns (uint256) {
        uint256[3] memory wins = [
            _row(0),
            _row(1),
            _row(2)
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

Not super elegant, but it works:

Running 9 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74806)
[PASS] test_can_mark_space_with_X() (gas: 51096)
[PASS] test_cannot_mark_space_with_Z() (gas: 10709)
[PASS] test_cannot_overwrite_marked_space() (gas: 56634)
[PASS] test_checks_for_horizontal_win() (gas: 148184)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160329)
[PASS] test_get_board() (gas: 29128)
[PASS] test_has_empty_board() (gas: 31965)
[PASS] test_symbols_must_alternate() (gas: 56537)
[PASS] test_tracks_current_turn() (gas: 76825)
Test result: ok. 9 passed; 0 failed; finished in 1.24ms

While we're at it, let's add columns:

  function test_checks_for_vertical_win() public {
    ttt.markSpace(1, X);
    ttt.markSpace(0, O);
    ttt.markSpace(2, X);
    ttt.markSpace(3, O);
    ttt.markSpace(4, X);
    ttt.markSpace(6, O);
    assertEq(ttt.winner(), O);
  }

We can add another helper and add the columns to our array of winning combinations:

    function winner() public view returns (uint256) {
        uint256[6] memory wins = [
            _row(0),
            _row(1),
            _row(2),
            _col(0),
            _col(1),
            _col(2)
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

    function _col(uint256 col) internal view returns (uint256) {
        require(col < 3, "Invalid column");
        return board[col] * board[col+3] * board[col+6];
    }

Finally, we can add the diagonals:

    function winner() public view returns (uint256) {
        uint256[8] memory wins = [
            _row(0),
            _row(1),
            _row(2),
            _col(0),
            _col(1),
            _col(2),
            _diag(),
            _antiDiag()
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

    function _diag() internal view returns (uint256) {
        return board[0] * board[4] * board[8];
    }

    function _antiDiag() internal view returns (uint256) {
        return board[2] * board[4] * board[6];
    }

Finally, let's cover our edge cases and make sure games in progress and draws return no winner:

  function test_draw_returns_no_winner() public {
    ttt.markSpace(4, X);
    ttt.markSpace(0, O);
    ttt.markSpace(1, X);
    ttt.markSpace(7, O);
    ttt.markSpace(2, X);
    ttt.markSpace(6, O);
    ttt.markSpace(8, X);
    ttt.markSpace(5, O);
    assertEq(ttt.winner(), 0);
  }

  function test_empty_board_returns_no_winner() public {
    assertEq(ttt.winner(), 0);
  }

  function test_game_in_progress_returns_no_winner() public {
    ttt.markSpace(1, X);
    assertEq(ttt.winner(), 0);
  }

Everything looks good!

Running 15 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74788)
[PASS] test_can_mark_space_with_X() (gas: 51123)
[PASS] test_cannot_mark_space_with_Z() (gas: 10687)
[PASS] test_cannot_overwrite_marked_space() (gas: 56749)
[PASS] test_checks_for_antidiagonal_win() (gas: 183587)
[PASS] test_checks_for_diagonal_win() (gas: 161625)
[PASS] test_checks_for_horizontal_win() (gas: 159719)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160329)
[PASS] test_checks_for_vertical_win() (gas: 182374)
[PASS] test_draw_returns_no_winner() (gas: 226962)
[PASS] test_empty_board_returns_no_winner() (gas: 31997)
[PASS] test_game_in_progress_returns_no_winner() (gas: 75509)
[PASS] test_get_board() (gas: 29151)
[PASS] test_has_empty_board() (gas: 31988)
[PASS] test_symbols_must_alternate() (gas: 56519)
[PASS] test_tracks_current_turn() (gas: 76815)
Test result: ok. 15 passed; 0 failed; finished in 1.59ms

Finishing the basic game

Here's the final production code for our basic game:

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

contract TicTacToken {
    uint256[9] public board;
    
    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;
    uint256 internal _turns;

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

    function markSpace(uint256 space, uint256 symbol) public {
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function currentTurn() public view returns (uint256) {
        return (_turns % 2 == 0) ? X : O;
    }

    function winner() public view returns (uint256) {
        uint256[8] memory wins = [
            _row(0),
            _row(1),
            _row(2),
            _col(0),
            _col(1),
            _col(2),
            _diag(),
            _antiDiag()
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        } 
        return 0;
    }

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

    function _row(uint256 row) internal view returns (uint256) {
        require(row < 3, "Invalid row");
        uint256 idx = 3 * row;
        return board[idx] * board[idx+1] * board[idx+2];
    }

    function _col(uint256 col) internal view returns (uint256) {
        require(col < 3, "Invalid column");
        return board[col] * board[col+3] * board[col+6];
    }

    function _diag() internal view returns (uint256) {
        return board[0] * board[4] * board[8];
    }
    
    function _antiDiag() internal view returns (uint256) {
        return board[2] * board[4] * board[6];
    }


    function _validTurn(uint256 symbol) internal view returns (bool) {
        return symbol == currentTurn();
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return board[i] == EMPTY;
    }

    function _validSymbol(uint256 symbol) internal pure returns (bool) {
        return symbol == X || symbol == O;
    }
}

And the full code for our unit 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;

  uint256 internal constant EMPTY = 0;
  uint256 internal constant X = 1;
  uint256 internal constant O = 2;

  function setUp() public {
    ttt = new TicTacToken();
  }

  function test_has_empty_board() public {
    for (uint256 i = 0; i < 9; i++) {
      assertEq(ttt.board(i), EMPTY);
    }
  }

  function test_get_board() public {
    uint256[9] memory expected = [
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY,
      EMPTY
    ];
    uint256[9] memory actual = ttt.getBoard();

    for (uint256 i = 0; i < 9; i++) {
      assertEq(actual[i], expected[i]);
    }
  }

  function test_can_mark_space_with_X() public {
    ttt.markSpace(0, X);
    assertEq(ttt.board(0), X);
  }

  function test_can_mark_space_with_O() public {
    ttt.markSpace(0, X);
    ttt.markSpace(1, O);
    assertEq(ttt.board(1), O);
  }

  function test_cannot_mark_space_with_Z() public {
    vm.expectRevert("Invalid symbol");
    ttt.markSpace(0, 3);
  }

  function test_cannot_overwrite_marked_space() public {
    ttt.markSpace(0, X);

    vm.expectRevert("Already marked");
    ttt.markSpace(0, O);
  }

  function test_symbols_must_alternate() public {
    ttt.markSpace(0, X);
    vm.expectRevert("Not your turn");
    ttt.markSpace(1, X);
  }

  function test_tracks_current_turn() public {
    assertEq(ttt.currentTurn(), X);
    ttt.markSpace(0, X);
    assertEq(ttt.currentTurn(), O);
    ttt.markSpace(1, O);
    assertEq(ttt.currentTurn(), X);
  }

  function test_checks_for_horizontal_win() public {
    ttt.markSpace(0, X);
    ttt.markSpace(3, O);
    ttt.markSpace(1, X);
    ttt.markSpace(4, O);
    ttt.markSpace(2, X);
    assertEq(ttt.winner(), X);
  }
  
  function test_checks_for_horizontal_win_row2() public {
    ttt.markSpace(3, X);
    ttt.markSpace(0, O);
    ttt.markSpace(4, X);
    ttt.markSpace(1, O);
    ttt.markSpace(5, X);
    assertEq(ttt.winner(), X);
  }
  
  function test_checks_for_vertical_win() public {
    ttt.markSpace(1, X);
    ttt.markSpace(0, O);
    ttt.markSpace(2, X);
    ttt.markSpace(3, O);
    ttt.markSpace(4, X);
    ttt.markSpace(6, O);
    assertEq(ttt.winner(), O);
  }
  
  function test_checks_for_diagonal_win() public {
    ttt.markSpace(0, X);
    ttt.markSpace(1, O);
    ttt.markSpace(4, X);
    ttt.markSpace(5, O);
    ttt.markSpace(8, X);
    assertEq(ttt.winner(), X);
  }
  
  function test_checks_for_antidiagonal_win() public {
    ttt.markSpace(1, X);
    ttt.markSpace(2, O);
    ttt.markSpace(3, X);
    ttt.markSpace(4, O);
    ttt.markSpace(5, X);
    ttt.markSpace(6, O);
    assertEq(ttt.winner(), O);
  }
  
  function test_draw_returns_no_winner() public {
    ttt.markSpace(4, X);
    ttt.markSpace(0, O);
    ttt.markSpace(1, X);
    ttt.markSpace(7, O);
    ttt.markSpace(2, X);
    ttt.markSpace(6, O);
    ttt.markSpace(8, X);
    ttt.markSpace(5, O);
    assertEq(ttt.winner(), 0);
  }
  
  function test_empty_board_returns_no_winner() public {
    assertEq(ttt.winner(), 0);
  }
  
  function test_game_in_progress_returns_no_winner() public {
    ttt.markSpace(1, X);
    assertEq(ttt.winner(), 0);
  }
}

This is a great start on our core game logic, but we're missing a few important features. At the moment, anyone interacting with our contract can mark the board, as long as they're making a valid move! In the next chapter, we'll explore this permissionless paradigm in more detail, and look at a few patterns for restricting access to specific user addresses.

👯‍♂️ Multiplayer

Supporting multiple players

Last week we built out the core components of our Tic-tac-toe game: marking the board, validating moves, and checking for wins. This week, we'll turn the basic board framework into a multiplayer game, by restricting access to state changing functions to specific, authorized addresses. But first, we'll talk a little bit about what it means to run code on a public blockchain like Ethereum and take a detour to explore the basic board contract we wrote last week on a live test network.

Goals this week

  • Add authorization to the game, so that moves can only originate from specific authorized addresses.
  • Understand the unique properties of running code on a public blockchain.
  • Learn about function modifiers, addresses, external vs contract accounts, and msg.sender in Solidity.

Suggested homework

The EVM programming paradigm

Running smart contract code on a public blockchain is a very different paradigm from running code in other environments. Programming the EVM means programming a shared "world computer" with a few unusual properties. In particular, the EVM execution environment is public, permissionless, immutable, costly, deterministic, and adversarial. Let's look at each of these properties in turn.

Public

Smart contract code is deployed in public, replicated across thousands of nodes on the Ethereum network. Anyone can see the deployed bytecode of any contract, decompile it, and interact with it. Any legitimate project will open-source and verify their code if they expect anyone to trust it. If you like poking around with “view source” on the frontend, you’ll love this property: it’s like “view source” for smart contract code.

In addition to reading contract code, anyone on the network can view a contract's event logs and read its underlying state data from storage. (This includes internal and private storage variables, by the way!)

Permissionless

In addition to reading code and data, anyone on the network can call the public API of any contract at any time. It’s as if your API is always open to the world, your application logs are out in the open, and your database read replica is public. Web services kind of work this way: usually anyone can find your API endpoints and call them with unexpected arguments. But public blockchains take this property to the extreme.

Immutable

Once a contract is deployed, it can’t be changed. There are patterns for designing upgradeable systems and migrating from one contract to another, but ultimately the underlying code is immutable. Your bugs will be deployed forever and you have to get it right the first time. Hope you wrote some unit tests!

Costly

Every state changing interaction on the network has an associated cost in money, measured in units called "gas." Deploying a new contract costs gas. Calling a function on a contract costs gas. Writing data to a storage variable costs gas. Every computation and storage operation has an associated cost. There is actually a good reason for gas: it’s a defense against malicious programs that might otherwise loop forever, and serves as payment for the computation and storage you consume.

Deterministic

The EVM is a deterministic state machine. Since every node on the network must be able to validate any computation, every computation is deterministic. On one hand, this means common tasks like generating a random number, reading a file from the filesystem, or making an HTTP request are impossible on the EVM. On the other hand, it means we can precisely simulate the outcome of any operation starting at some known state.

Adversarial

Finally, all of the properties above combined with the fact that smart contracts are used to store and send value mean that even the smallest vulnerabilities will be found and exploited. In the EVM programming paradigm, it’s critical to test extensively, simulate the unexpected, and think adversarially at every turn.

Deploying the basic board contract

To explore the EVM paradigm a bit further, let's deploy our game contract to the Rinkeby test network, where we'll be able to interact with it in an environment similar to the real world.

Test Networks

Rinkeby is one of several Ethereum test networks, which are sort of like staging environments for the main Ethereum blockchain. Test networks are used both to test smart contract application code and to test changes to the underlying protocol and client software. Different testnets have slightly different properties, but they mostly work like the real world.

To deploy our contract, we'll need a few things: an account to deploy from, some testnet ether to pay for gas, and an RPC endpoint to submit our transaction. Let's walk through setting up all three.

Generating a deployment address

Foundry's cast command line tool includes a handy utility for generating a one time use wallet under the cast wallet subcommand. We can use it to generate a new account for deployment.

One important note: once a private key has been exposed in public, like the one we're about to create, you should never use it again. If you're following along with these examples, generate your own address and private key.

$ cast wallet new
Successfully created new keypair.
Address: 0xeD43C19583204FB9eFd041a4d9787bbE5c1965C3
Private Key: 61bc97eb39d98d3103ec4d107906575189f1c7dbebbddcb400a6cccb72e65c53

Private Key Space

How can we be sure the wallet we generate is empty? What if we stumble on the same address as someone else? Although this is mathematically possible, it's incredibly unlikely in practice. An Ethereum private key is 256 bits, meaning there are \(2^{256}\) possible private keys. As long as your generator is really random, the odds of generating the same key twice are so low that you can safely assume them away. This is not a special property of Ethereum keys: this is the case for other commonly used public key cryptosystems, like PGP and SSH.

Visit keys.lol for a great visual example of how empty the key space really is.

Hold on to the address and private key we generated because we'll use them in the following steps.

Funding the deployment address

In order to deploy our contract to Rinkeby, we first need to fund our account with testnet ether. This is the equivalent of ETH on mainnet: the asset we need to use to pay for gas. Before we can deploy or interact with a contract, we'll need some Rinkeby ETH.

Test networks typically provide testnet ether for free via a faucet. (In order to prevent abuse, many ask you to solve a CAPTCHA or log in with a Twitter account). We'll use the Paradigm multifaucet to fund our deployer account with some Rinkeby ether.

Log in with a Twitter account, enter the address of our deployer account, 0xeD43C19583204FB9eFd041a4d9787bbE5c1965C3, and click "Claim" to request Rinkeby ether:

Using the Paradigm faucet

You'll see a confirmation notification pop up, and should recieve test ether at the deployer account address within a few minutes.

Setting up an RPC endpoint

Finally, we need to connect to an Ethereum node in order to transmit our contract deployment transaction to the network. Ethereum clients like Geth, Nethermind, and Erigon expose a JSON-RPC API that allows applications to send transactions, read data, and otherwise interact with the network.

Rather than running our own node and connecting to it locally, we'll use Alchemy, an Ethereum-node-as-a-service provider that provides RPC access over the internet.

Once you've signed up for an Alchemy account, create a new app on the Ethereum chain and Rinkeby network:

Creating an Alchemy app

Once it's set up, visit the app from the dashboard, select "View Key," and copy the HTTP URL:

Viewing an Alchemy URL

The URL should looks something like https://eth-rinkeby.alchemyapi.io/v2/<API key>. We'll pass this to Foundry to allow it to send transactions and read data from the Rinkeby network.

With our address, some test ether, and an RPC endpoint in hand, we're finally ready to deploy our contract...

Deploying the contract

We can use the forge create command to deploy a compiled contract. We'll need to pass Forge the RPC URL and private key we generated as parameters, as well as the name of our contract:

$ forge create \
--rpc-url https://eth-rinkeby.alchemyapi.io/v2/<API key> \
--private-key 61bc97eb39d98d3103ec4d107906575189f1c7dbebbddcb400a6cccb72e65c53 \
TicTacToken
[â Š] Compiling...
No files changed, compilation skipped
Deployer: 0xed43c19583204fb9efd041a4d9787bbe5c1965c3
Deployed to: 0x35b92cdec048edc082b4f614bd46468c891eac47
Transaction hash: 0xe4955a560db459b435dbf0312cbdb913364e78d2798a14138d655bc556e114fc

Key management

Passing a private key directly on the command line is a quick and dirty way to deploy our work in progress, but it's not recommended for a serious deployment. Foundry supports a number of other, better methods for securely accessing a private key, including hardware wallets, mnemonic phrases, and an encrypted keystore file. Pretty much any of these are better options.

Notice that Forge printed our deployer account address, as well as the address of the newly deployed contract and the hash of the deployment transaction.

We should be able to see our newly deployed contract on Etherscan, a tool for exploring contracts, accounts, and transactions on Ethereum and test networks. Visit the Rinkeby explorer at https://rinkeby.etherscan.io/ and enter our contract address:

Rinkeby Etherscan

You'll see some information about the deployed contract: the creation transaction, the address that deployed it, and its balance in (Rinkeby) ether:

Etherscan contract information

Click on the link under "Txn hash" to see details of the deployment transaction:

Deployment transaction details

Just one more setup step before we interact with our contract: verifying the source code on Etherscan.

Verifying the contract

Verifying our contract's source code on Etherscan enables us to read and write data directly from the Etherscan interface. It's considered a good practice to verify public contracts on Etherscan, so that anyone can inspect the underlying contract code.

Before our contract is verified, the "Contract" tab on Etherscan will show the raw bytecode of our contract. Not very helpful to anyone who might want to read and understand it:

Contract bytecode

To verify our contract, we'll need an Etherscan API key. Create an account at etherscan.io and generate one.

We'll also need to know the exact compiler version used to compile our contract. Figuring this out is kind of a pain, but we can access it by calling the Solidity compiler binary directly. You can find it in a hidden .svm directory inside your home directory. In our case, we used version 0.8.10:

$ ~/.svm/0.8.10/solc-0.8.10 --version
solc, the solidity compiler commandline interface
Version: 0.8.10+commit.fc410830.Darwin.appleclang

We can then use the forge verify-contract command to verify our contract on Etherscan:

$ forge verify-contract --compiler-version v0.8.10+commit.fc410830 \
--chain-id 4 \
0x35B92CDec048EDC082B4F614Bd46468c891Eac47 \
src/TicTacToken.sol:TicTacToken \
<Etherscan API Key>
Submitted contract for verification:
    Response: `OK`
    GUID: `pgrw1kkx9dwencukh8tpd4attlxtq89yftlci9cxghbnxgdvt9`
    url: https://rinkeby.etherscan.io/address/0x35b92cdec048edc082b4f614bd46468c891eac47#code

We've passed the exact compiler version we just looked up, the chain ID for the Rinkeby network, the address of our deployed contract, the source file and name of our contract, and our Etherscan API key.

Phew! It's now verified on Etherscan, where we can see the source code and compiler configuration:

Verified contract on Etherscan

We've taken a long detour with a lot of new tools, but they will all come in handy as we continue to build out our Tic Tac Toe game. Now that we can see our contract on Etherscan, let's explore it a bit.

Exploring the basic board contract

Now that our contract is verified on Etherscan, we can read its source code and even interact with it from the Etherscan interface.

Under the "Read Contract" tab, we can read data like public state variables and call view functions. Notice how the board function exposes the default getter? We have to provide an index and press the "Query" button to read a value from our contract.

The currentTurn, getBoard, and winner functions are all views that take no arguments, so Etherscan shows their values directly.

Looks like everything we should expect: an empty board array full of zeros, X's turn by default, and no winner yet.

Reading contract data

Want to see something even cooler? We can interact with our contract under the "Write Contract tab.

Safely using Write Contract

Be extremely careful with Etherscan's "Write Contract" feature. Using this should always feel a little dangerous, because it is! It's a good idea to use a separate, isolated wallet and account to interact with testnet contracts.

Never, ever write to a contract on Etherscan that you don't trust or understand.

To interact with the contract, first click the "Connect to Web3" button on the "Write Contract" tab:

Writing contract data

Follow the prompts to connect a wallet:

Connect a wallet

Once you're connected, you'll see a green indicator and the address of your connected account:

Wallet connected to Etherscan

From here, we can call functions on our contract! Let's marke the first space with an "X", by entering space 0 and symbol 1:

Marking the first space

Remember that state changing transactions cost gas and require payment in (testnet) ether. Pressing the "Write" button will prompt your connected walled to confirm the transaction, and we'll need a sufficient ether balance in our wallet in order to send the transaction and execute the function call. Here's what the prompt looks like in Metamask:

Metamask prompt

If you're following along and need Rinkeby ETH, you can request it from the Paradigm faucet to your wallet address just like we did when we funded the deployer account.

Watch for a notification from your wallet that the transaction is confirmed. Depending on how busy the testnet is, it may take a few seconds to a few minutes for your transaction to be mined and confirmed.

Once it's confirmed, we can return to the "Read" tab and see the new state of the board. Sure enough, it's now player O's turn and the first square is marked:

Updated board

Try this out for yourself! Send a few test transactions and see what happens to the board state. If you connect a row, does the board detect a winner? What happens if you make an invalid move and trigger a require statement?

In addition to our existing board code, I've added a resetBoard function that will clear the board. Call this if you need to reset the state.

Adding access control

It's great that we can interact with our Tic Tac Toe game contract, but hopefully you can already see the shortcomings of our current code: everyone else can interact withour contract, too! Anyone can mark a square on the board or clobber it altogether at any time by calling our contract's public functions. Rather than a public free-for-all, we'll want to restrict who's allowed to call the functions on our contract.

Fortunately, another property of the weird and wonderful Ethereum programming paradigm makes this possible. Since every state changing function call is a cryptographically signed transaction, we can tell exactly who's calling a function! (Or at least, identify their public key/address). Solidity makes this easy by providing a special global variable called msg.sender.

Wallets, accounts, addresses, and keys

An account is an entity with an ether balance that can send Ethereum transactions. There are two types of accounts: externally owned accounts and contracts. Every externally owned account (or "EOA") has a corresponding public and private key, and is controlled by the owner of its private key. Contract accounts do not have keys and are instead controlled by their deployed code.

Every account has an address, a unique identifier made up of 42 hexadecimal characters, like 0xeD43C19583204FB9eFd041a4d9787bbE5c1965C3. For an externally owned account, this address is derived from its public key. For a contract account, it's derived from the address of the contract creator and their transaction history. An address is not a public key, but it corresponds to one.

A wallet is a software application for managing an Ethereum account. It typically handles generating a keypair and corresponding address and securely storing the private key for one or many accounts.

Using msg.sender

Inside a smart contract, Solidity provides a special, globally accessible msg.sender variable, which stores the address of the current caller. If the caller is an EOA, msg.sender will be the caller account's address. If the caller is another contract, msg.sender will be that contract's address.

Let's write a test to explore how it works:

    function test_msg_sender() public {
        // Not sure what this will return yet...
        // Let's try the zero address and see.
        assertEq(ttt.msgSender(), address(0));
    }

Run it to find out:

$ forge test -m msg_sender

Running 1 test for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL] test_msg_sender() (gas: 16368)
Logs:
  Error: a == b not satisfied [address]
    Expected: 0x0000000000000000000000000000000000000000
      Actual: 0xb4c79dab8f259c7aee6e5b2aa729821864227e84

Test result: FAILED. 0 passed; 1 failed; finished in 2.35ms

Failed tests:
[FAIL] test_msg_sender() (gas: 16368)

In this context, msg.sender is the address 0xb4c79dab8f259c7aee6e5b2aa729821864227e84, which corresponds to the address of the TicTacTokenTest contract. Since our test harness contract is calling the function, it's the msg.sender.

We can access the address of the current contract by calling address(this). Let's update our test:

    function test_msg_sender() public {
        assertEq(ttt.msgSender(), address(this));
    }

Success!

Running 1 test for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_msg_sender() (gas: 5502)
Test result: ok. 1 passed; 0 failed; finished in 1.64ms

Now let's add another layer to explore how msg.sender changes based on the current caller. At the top of our test file, let's create another contract, Caller. We'll give it access to our game contract and add a function call that calls our game's msgSender() function and returns the address:

contract Caller {

    TicTacToken internal ttt;

    constructor(TicTacToken _ttt) {
        ttt = _ttt;
    }
    function call() public returns (address) {
        return ttt.msgSender();
    }
}

Now let's create two instances of the Caller contract in our test. Any guesses on the value of msg.sender here?

    function test_msg_sender() public {
        Caller caller1 = new Caller(ttt);
        Caller caller2 = new Caller(ttt);

        assertEq(ttt.msgSender(), address(this));

        assertEq(caller1.call(), address(0));
        assertEq(caller2.call(), address(0));
    }

Let's run the test to find out:

$ forge test -m msg_sender
Running 1 test for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL] test_msg_sender() (gas: 256093)
Logs:
  Error: a == b not satisfied [address]
    Expected: 0x0000000000000000000000000000000000000000
      Actual: 0x185a4dc360ce69bdccee33b3784b0282f7961aea
  Error: a == b not satisfied [address]
    Expected: 0x0000000000000000000000000000000000000000
      Actual: 0xefc56627233b02ea95bae7e19f648d7dcd5bb132

Test result: FAILED. 0 passed; 1 failed; finished in 826.17µs

Failed tests:
[FAIL] test_msg_sender() (gas: 256093)

Encountered a total of 1 failing tests, 0 tests succeeded

Notice that the values are different for caller1 and caller2. Since the caller is now another, intermediate contract, msg.sender is the address of caller1 and caller2 respectively. We can access them in the tests like so:

    function test_msg_sender() public {
        Caller caller1 = new Caller(ttt);
        Caller caller2 = new Caller(ttt);

        assertEq(ttt.msgSender(), address(this));

        assertEq(caller1.call(), address(caller1));
        assertEq(caller2.call(), address(caller2));
    }

Let's run the tests and make sure:

$ forge test -m msg_sender
Running 1 test for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_msg_sender() (gas: 239187)
Test result: ok. 1 passed; 0 failed; finished in 829.67µs

In the context of our tests, msg.sender has always been another contract (remember that our ds-test test harness is itself a Solidity contract), but if an EOA were to call our msgSender function, it would return that EOA's address.

Requiring a specific caller

We can use msg.sender in combination with a require statement to check who's calling our contract functions and limit access to specific addresses. Let's start by restricting who's allowed to resetBoard. For example, we could update the function to something like this:

    function resetBoard() public {
        require(msg.sender == ???, "Unauthorized");
        delete board;
    }

What address should we check against in the right hand side of this comparison? Recall that in our test environment, the caller contract address is 0xb4c79dab8f259c7aee6e5b2aa729821864227e84. We could hardcode it like this:

    function resetBoard() public {
        require(
            msg.sender == address(0xb4c79dab8f259c7aee6e5b2aa729821864227e84),
            "Unauthorized"
        );
        delete board;
    }

But this won't work if anyone besides our test contract calls resetBoard. Instead, we need to set and store the address in a state variable. Something like this:

    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function resetBoard() public {
        require(
            msg.sender == owner,
            "Unauthorized"
        );
        delete board;
    }

Let's start with a test, then update the implementation. We'll define an OWNER address as a constant in our test contract, pass it as an argument to our game during setup, and refer to it in our test.

    address internal constant OWNER = address(1);

    function setUp() public {
        ttt = new TicTacToken(OWNER);
    }

    function test_contract_owner() public {
        assertEq(ttt.owner(), OWNER);
    }

We'll need to update the constructor first:

$ forge test
Error:
   0: Compiler run failed
      TypeError: Wrong argument count for function call: 1 arguments given but expected 0.
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:32:15:
         |
      32 |         ttt = new TicTacToken(OWNER);
         |               ^^^^^^^^^^^^^^^^^^^^^^

Let's add a state variable and set it in the constructor:

    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

Looks good:

$ forge test
Running 19 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74789)
[PASS] test_can_mark_space_with_X() (gas: 51124)
[PASS] test_cannot_mark_space_with_Z() (gas: 10687)
[PASS] test_cannot_overwrite_marked_space() (gas: 56705)
[PASS] test_checks_for_antidiagonal_win() (gas: 183739)
[PASS] test_checks_for_diagonal_win() (gas: 161865)
[PASS] test_checks_for_horizontal_win() (gas: 159915)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160241)
[PASS] test_checks_for_vertical_win() (gas: 182570)
[PASS] test_contract_owner() (gas: 7706)
[PASS] test_draw_returns_no_winner() (gas: 227181)
[PASS] test_empty_board_returns_no_winner() (gas: 32236)
[PASS] test_game_in_progress_returns_no_winner() (gas: 75903)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32195)
[PASS] test_msg_sender() (gas: 238455)
[PASS] test_reset_board() (gas: 125168)
[PASS] test_symbols_must_alternate() (gas: 56453)
[PASS] test_tracks_current_turn() (gas: 76749)
Test result: ok. 19 passed; 0 failed; finished in 3.70ms

We still haven't added a check against the msg.sender in our resetBoard function. Let's first add a couple tests. We can use a new cheatcode to manipulate msg.sender in our tests. The vm.prank cheatcode sets the msg.sender address for the following line, similar to how vm.expectRevert applies to the immediately following statement.

    function test_owner_can_reset_board() public {
        vm.prank(OWNER);
        ttt.resetBoard();
    }

    function test_non_owner_cannot_reset_board() public {
        vm.expectRevert("Unauthorized");
        ttt.resetBoard();
    }

In the first test above, we set msg.sender to the OWNER address before calling ttt.resetBoard(). In the second, msg.sender will be the address of our test contract, and we expect the call to resetBoard to revert.

Our tests fail as expected:

$ forge test
Running 21 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74767)
[PASS] test_can_mark_space_with_X() (gas: 51124)
[PASS] test_cannot_mark_space_with_Z() (gas: 10665)
[PASS] test_cannot_overwrite_marked_space() (gas: 56705)
[PASS] test_checks_for_antidiagonal_win() (gas: 183739)
[PASS] test_checks_for_diagonal_win() (gas: 161865)
[PASS] test_checks_for_horizontal_win() (gas: 159915)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160241)
[PASS] test_checks_for_vertical_win() (gas: 182548)
[PASS] test_contract_owner() (gas: 7706)
[PASS] test_draw_returns_no_winner() (gas: 227203)
[PASS] test_empty_board_returns_no_winner() (gas: 32347)
[PASS] test_game_in_progress_returns_no_winner() (gas: 75903)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32195)
[PASS] test_msg_sender() (gas: 238455)
[FAIL. Reason: Call did not revert as expected] test_non_owner_cannot_reset_board() (gas: 30817)
[PASS] test_owner_can_reset_board() (gas: 30799)
[PASS] test_reset_board() (gas: 125168)
[PASS] test_symbols_must_alternate() (gas: 56453)
[PASS] test_tracks_current_turn() (gas: 76749)
Test result: FAILED. 20 passed; 1 failed; finished in 3.44ms

Failed tests:
[FAIL. Reason: Call did not revert as expected] test_non_owner_cannot_reset_board() (gas: 30817)

Let's update resetBoard to add a require statement:

    function resetBoard() public {
        require(
            msg.sender == owner,
            "Unauthorized"
        );
        delete board;
    }

Run the tests once more...

Running 21 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74767)
[PASS] test_can_mark_space_with_X() (gas: 51124)
[PASS] test_cannot_mark_space_with_Z() (gas: 10676)
[PASS] test_cannot_overwrite_marked_space() (gas: 56705)
[PASS] test_checks_for_antidiagonal_win() (gas: 183739)
[PASS] test_checks_for_diagonal_win() (gas: 161865)
[PASS] test_checks_for_horizontal_win() (gas: 159915)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160241)
[PASS] test_checks_for_vertical_win() (gas: 182548)
[PASS] test_contract_owner() (gas: 7706)
[PASS] test_draw_returns_no_winner() (gas: 227203)
[PASS] test_empty_board_returns_no_winner() (gas: 32347)
[PASS] test_game_in_progress_returns_no_winner() (gas: 75903)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32195)
[PASS] test_msg_sender() (gas: 238455)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32939)
[FAIL. Reason: Unauthorized] test_reset_board() (gas: 147658)
[PASS] test_symbols_must_alternate() (gas: 56453)
[PASS] test_tracks_current_turn() (gas: 76749)
Test result: FAILED. 20 passed; 1 failed; finished in 3.25ms

Failed tests:
[FAIL. Reason: Unauthorized] test_reset_board() (gas: 147658)

Encountered a total of 1 failing tests, 20 tests succeeded

Our latest two tests both passed, but we've caused our previous resetBoard test to fail. We'll have to add a vm.prank there, too:

    function test_reset_board() public {
        ttt.markSpace(3, X);
        ttt.markSpace(0, O);
        ttt.markSpace(4, X);
        ttt.markSpace(1, O);
        ttt.markSpace(5, X);
        vm.prank(OWNER);
        ttt.resetBoard();
        uint256[9] memory expected = [
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY
        ];
        uint256[9] memory actual = ttt.getBoard();

        for (uint256 i = 0; i < 9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }

One more test run and we should be all green again:

$ forge test
Running 21 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_can_mark_space_with_O() (gas: 74767)
[PASS] test_can_mark_space_with_X() (gas: 51124)
[PASS] test_cannot_mark_space_with_Z() (gas: 10676)
[PASS] test_cannot_overwrite_marked_space() (gas: 56705)
[PASS] test_checks_for_antidiagonal_win() (gas: 183739)
[PASS] test_checks_for_diagonal_win() (gas: 161865)
[PASS] test_checks_for_horizontal_win() (gas: 159915)
[PASS] test_checks_for_horizontal_win_row2() (gas: 160241)
[PASS] test_checks_for_vertical_win() (gas: 182548)
[PASS] test_contract_owner() (gas: 7706)
[PASS] test_draw_returns_no_winner() (gas: 227203)
[PASS] test_empty_board_returns_no_winner() (gas: 32347)
[PASS] test_game_in_progress_returns_no_winner() (gas: 75903)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32195)
[PASS] test_msg_sender() (gas: 238455)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32939)
[PASS] test_reset_board() (gas: 130859)
[PASS] test_symbols_must_alternate() (gas: 56453)
[PASS] test_tracks_current_turn() (gas: 76749)
Test result: ok. 21 passed; 0 failed; finished in 4.46ms

Requiring authorized players

Let's follow the same pattern as the contract owner and store authorized addresses for player X and player O. We can start by passing these as constructor arguments, too.

First, two new tests:

    address internal constant PLAYER_X = address(2);
    address internal constant PLAYER_O = address(3);

    function setUp() public {
        ttt = new TicTacToken(OWNER, PLAYER_X, PLAYER_O);
    }

    function test_stores_player_X() public {
        assertEq(ttt.playerX(), PLAYER_X);
    }

    function test_stores_player_O() public {
        assertEq(ttt.playerO(), PLAYER_O);
    }

Then we can add new state variables and constructor args in our contract:

    address public playerX;
    address public playerO;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        playerX = _playerX;
        playerO = _playerO;
    }

Now that we're storing these addresses, let's limit access to markSpace. Just like in resetBoard, let's check that the caller is one of the authorized addresses. Here are a few tests:

    function test_auth_nonplayer_cannot_mark_space() public {
        vm.expectRevert("Unauthorized");
        ttt.markSpace(0, X);
    }

    function test_auth_playerX_can_mark_space() public {
        vm.prank(PLAYER_X);
        ttt.markSpace(0, X);
    }

    function test_auth_playerO_can_mark_space() public {
        vm.prank(PLAYER_X);
        ttt.markSpace(0, X);

        vm.prank(PLAYER_O);
        ttt.markSpace(1, O);
    }

To start, let's just run our new tests rather than the whole suite. Before adding a new require in markSpace, the first one fails, since anyone can mark the board:

$ forge test
Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL. Reason: Call did not revert as expected] test_auth_nonplayer_cannot_mark_space() (gas: 55315)
[PASS] test_auth_playerO_can_mark_space() (gas: 79285)
[PASS] test_auth_playerX_can_mark_space() (gas: 55236)
Test result: FAILED. 2 passed; 1 failed; finished in 2.70ms

Let's add a new require check and a helper function:

    function markSpace(uint256 space, uint256 symbol) public {
        require(_validPlayer(msg.sender), "Unauthorized");
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function _validPlayer(address player) internal view returns (bool) {
        return player == playerX || player == playerO;
    }

With our check in place, the tests all pass:

Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14982)
[PASS] test_auth_playerO_can_mark_space() (gas: 83832)
[PASS] test_auth_playerX_can_mark_space() (gas: 57445)
Test result: ok. 3 passed; 0 failed; finished in 2.14ms

Since msg.sender is global, we can access it from _validPlayer without passing it to our helper:

    function markSpace(uint256 space, uint256 symbol) public {
        require(_validPlayer(), "Unauthorized");
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function _validPlayer() internal view returns (bool) {
        return msg.sender == playerX || msg.sender == playerO;
    }

Let's run the whole suite:

$ forge test
Running 26 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14982)
[PASS] test_auth_playerO_can_mark_space() (gas: 83832)
[PASS] test_auth_playerX_can_mark_space() (gas: 57445)
[FAIL. Reason: Unauthorized] test_can_mark_space_with_O() (gas: 9965)
[FAIL. Reason: Unauthorized] test_can_mark_space_with_X() (gas: 9901)
[FAIL. Reason: Unauthorized] test_cannot_mark_space_with_Z() (gas: 14920)
[FAIL. Reason: Unauthorized] test_cannot_overwrite_marked_space() (gas: 9900)
[FAIL. Reason: Unauthorized] test_checks_for_antidiagonal_win() (gas: 9947)
[FAIL. Reason: Unauthorized] test_checks_for_diagonal_win() (gas: 9899)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win() (gas: 9945)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win_row2() (gas: 9921)
[FAIL. Reason: Unauthorized] test_checks_for_vertical_win() (gas: 9923)
[PASS] test_contract_owner() (gas: 7728)
[FAIL. Reason: Unauthorized] test_draw_returns_no_winner() (gas: 9900)
[PASS] test_empty_board_returns_no_winner() (gas: 32281)
[FAIL. Reason: Unauthorized] test_game_in_progress_returns_no_winner() (gas: 9937)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32173)
[PASS] test_msg_sender() (gas: 238516)
[PASS] test_non_owner_cannot_reset_board() (gas: 12684)
[PASS] test_owner_can_reset_board() (gas: 32917)
[FAIL. Reason: Unauthorized] test_reset_board() (gas: 9965)
[PASS] test_stores_player_O() (gas: 7727)
[PASS] test_stores_player_X() (gas: 7772)
[FAIL. Reason: Unauthorized] test_symbols_must_alternate() (gas: 9943)
[FAIL. Reason: Unauthorized] test_tracks_current_turn() (gas: 12881)
Test result: FAILED. 12 passed; 14 failed; finished in 3.70ms

Failed tests:
[FAIL. Reason: Unauthorized] test_can_mark_space_with_O() (gas: 9965)
[FAIL. Reason: Unauthorized] test_can_mark_space_with_X() (gas: 9901)
[FAIL. Reason: Unauthorized] test_cannot_mark_space_with_Z() (gas: 14920)
[FAIL. Reason: Unauthorized] test_cannot_overwrite_marked_space() (gas: 9900)
[FAIL. Reason: Unauthorized] test_checks_for_antidiagonal_win() (gas: 9947)
[FAIL. Reason: Unauthorized] test_checks_for_diagonal_win() (gas: 9899)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win() (gas: 9945)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win_row2() (gas: 9921)
[FAIL. Reason: Unauthorized] test_checks_for_vertical_win() (gas: 9923)
[FAIL. Reason: Unauthorized] test_draw_returns_no_winner() (gas: 9900)
[FAIL. Reason: Unauthorized] test_game_in_progress_returns_no_winner() (gas: 9937)
[FAIL. Reason: Unauthorized] test_reset_board() (gas: 9965)
[FAIL. Reason: Unauthorized] test_symbols_must_alternate() (gas: 9943)
[FAIL. Reason: Unauthorized] test_tracks_current_turn() (gas: 12881)

Well, the good news is that our modifier works! The bad news is that we now need to prank the address before almost every line of our tests. It's possible to update our tests this way, but let's look at a cleaner pattern: creating a mock user.

Creating a mock user

Once we updated our game to check for authorized player addresses, we broke most of our tests:

Failed tests:
[FAIL. Reason: Unauthorized] test_can_mark_space_with_O() (gas: 9965)
[FAIL. Reason: Unauthorized] test_can_mark_space_with_X() (gas: 9901)
[FAIL. Reason: Unauthorized] test_cannot_mark_space_with_Z() (gas: 14920)
[FAIL. Reason: Unauthorized] test_cannot_overwrite_marked_space() (gas: 9900)
[FAIL. Reason: Unauthorized] test_checks_for_antidiagonal_win() (gas: 9947)
[FAIL. Reason: Unauthorized] test_checks_for_diagonal_win() (gas: 9899)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win() (gas: 9945)
[FAIL. Reason: Unauthorized] test_checks_for_horizontal_win_row2() (gas: 9921)
[FAIL. Reason: Unauthorized] test_checks_for_vertical_win() (gas: 9923)
[FAIL. Reason: Unauthorized] test_draw_returns_no_winner() (gas: 9900)
[FAIL. Reason: Unauthorized] test_game_in_progress_returns_no_winner() (gas: 9937)
[FAIL. Reason: Unauthorized] test_reset_board() (gas: 9965)
[FAIL. Reason: Unauthorized] test_symbols_must_alternate() (gas: 9943)
[FAIL. Reason: Unauthorized] test_tracks_current_turn() (gas: 12881)

Encountered a total of 14 failing tests, 12 tests succeeded

We could use a bunch of vm.prank cheatcodes to stub out the address before every call to ttt.markSpace, but that's pretty verbose. Instead, let's use another common ds-test pattern: creating a mock user.

At the top of our tests, let's create a new User contract that stores an internal address at construction time:

contract User {

    address internal _address;

    constructor(address address_) {
        _address = address_;
    }
}

Next, let's pass in an instance of our game, and the Vm:

contract User {

    TicTacToken internal ttt;
    Vm internal vm;
    address internal _address;

    constructor(address address_, TicTacToken _ttt, Vm _vm) {
        _address = address_;
        ttt = _ttt;
        vm = _vm;
    }
}

Let's give this contract its own markSpace function that takes the same arguments and delegates to ttt:

contract User {

    TicTacToken internal ttt;
    Vm internal vm;
    address internal _address;

    constructor(address address_, TicTacToken _ttt, Vm _vm) {
        _address = address_;
        ttt = _ttt;
        vm = _vm;
    }

    function markSpace(uint256 space, uint256 symbol) public {
        ttt.markSpace(space, symbol);
    }
}

Finally, before we call ttt.markSpace, let's use vm.prank to set the caller address to the internal state variable we set up at construction time:

contract User {

    TicTacToken internal ttt;
    Vm internal vm;
    address internal _address;

    constructor(address address_, TicTacToken _ttt, Vm _vm) {
        _address = address_;
        ttt = _ttt;
        vm = _vm;
    }

    function markSpace(uint256 space, uint256 symbol) public {
        vm.prank(_address);
        ttt.markSpace(space, symbol);
    }
}

We've created a helper contract that can act as a mock user in our tests. Let's create variables for playerX and playerO at the top of our tests and create them in the setUp method:

    User internal playerX;
    User internal playerO;

    function setUp() public {
        ttt = new TicTacToken(OWNER, PLAYER_X, PLAYER_O);
        playerX = new User(PLAYER_X, ttt, vm);
        playerO = new User(PLAYER_O, ttt, vm);
    }

Now, rather than call vm.prank over and over, we can use our helper contracts to simulate user calls instead:

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0, X);
        assertEq(ttt.board(0), X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0, X);
        playerO.markSpace(1, O);
        assertEq(ttt.board(1), O);
    }

    function test_cannot_mark_space_with_Z() public {
        vm.expectRevert("Invalid symbol");
        playerX.markSpace(0, 3);
    }

    function test_cannot_overwrite_marked_space() public {
        playerX.markSpace(0, X);

        vm.expectRevert("Already marked");
        playerO.markSpace(0, O);
    }

    function test_symbols_must_alternate() public {
        playerX.markSpace(0, X);
        vm.expectRevert("Not your turn");
        playerO.markSpace(1, X);
    }

    function test_tracks_current_turn() public {
        assertEq(ttt.currentTurn(), X);
        playerX.markSpace(0, X);
        assertEq(ttt.currentTurn(), O);
        playerO.markSpace(1, O);
        assertEq(ttt.currentTurn(), X);
    }

...hopefully you get the idea: throughout our tests, we'll update ttt to playerX or playerO if we need the call to originate from a specific player.

Now that our tests are fixed up, let's take on a refactor and simplify some of our validations.

Simplifying validations

Now that we know who's calling markSpace, we can simplify a few things. For one, we no longer need to pass a symbol to the markSpace function. Instead, we can infer the correct symbol from the caller's address. That means some of our tests, like marking a space with an invalid symbol, will now be unnecessary.

This will be another big change all over our tests, but let's go for it:

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0);
        assertEq(ttt.board(0), X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0);
        playerO.markSpace(1);
        assertEq(ttt.board(1), O);
    }

    function test_cannot_overwrite_marked_space() public {
        playerX.markSpace(0);

        vm.expectRevert("Already marked");
        playerO.markSpace(0);
    }

Remember also to remove the symbol argument from our mock User and the call it delegates to ttt:

    function markSpace(uint256 space) public {
        vm.prank(_address);
        ttt.markSpace(space);
    }

Over in our production code, we can start by getting the caller's symbol based on address and passing this to our existing validations:

    function markSpace(uint256 space) public {
        require(_validPlayer(), "Unauthorized");
        uint256 symbol = _getSymbol();
        require(_validSymbol(symbol), "Invalid symbol");
        require(_validTurn(symbol), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = symbol;
        _turns++;
    }

    function _getSymbol() public view returns (uint256) {
        if (msg.sender == playerX) return X;
        if (msg.sender == playerO) return O;
        return EMPTY;
    }

The tests look good:

$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14941)
[PASS] test_auth_playerO_can_mark_space() (gas: 84313)
[PASS] test_auth_playerX_can_mark_space() (gas: 57606)
[PASS] test_can_mark_space_with_O() (gas: 105794)
[PASS] test_can_mark_space_with_X() (gas: 67768)
[PASS] test_cannot_overwrite_marked_space() (gas: 83181)
[PASS] test_checks_for_antidiagonal_win() (gas: 221918)
[PASS] test_checks_for_diagonal_win() (gas: 198005)
[PASS] test_checks_for_horizontal_win() (gas: 196150)
[PASS] test_checks_for_horizontal_win_row2() (gas: 196447)
[PASS] test_checks_for_vertical_win() (gas: 220647)
[PASS] test_contract_owner() (gas: 7661)
[PASS] test_draw_returns_no_winner() (gas: 268766)
[PASS] test_empty_board_returns_no_winner() (gas: 32303)
[PASS] test_game_in_progress_returns_no_winner() (gas: 92478)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32173)
[PASS] test_msg_sender() (gas: 238582)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32939)
[PASS] test_reset_board() (gas: 157844)
[PASS] test_stores_player_O() (gas: 7749)
[PASS] test_stores_player_X() (gas: 7794)
[PASS] test_symbols_must_alternate() (gas: 70239)
[PASS] test_tracks_current_turn() (gas: 107864)
Test result: ok. 25 passed; 0 failed; finished in 4.00ms

However, now that passing an invalid symbol is impossible, we can do a bit more. Let's remove that require statement and update the way we check for a _validTurn:

    function markSpace(uint256 space) public {
        require(_validPlayer(), "Unauthorized");
        require(_validTurn(), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = _getSymbol();
        _turns++;
    }

    function _validTurn() internal view returns (bool) {
        return currentTurn() == _getSymbol();
    }

Tests pass and our refactor is complete!

$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14941)
[PASS] test_auth_playerO_can_mark_space() (gas: 84664)
[PASS] test_auth_playerX_can_mark_space() (gas: 57706)
[PASS] test_can_mark_space_with_O() (gas: 106145)
[PASS] test_can_mark_space_with_X() (gas: 67868)
[PASS] test_cannot_overwrite_marked_space() (gas: 83187)
[PASS] test_checks_for_antidiagonal_win() (gas: 222971)
[PASS] test_checks_for_diagonal_win() (gas: 198807)
[PASS] test_checks_for_horizontal_win() (gas: 196952)
[PASS] test_checks_for_horizontal_win_row2() (gas: 197249)
[PASS] test_checks_for_vertical_win() (gas: 221700)
[PASS] test_contract_owner() (gas: 7661)
[PASS] test_draw_returns_no_winner() (gas: 270170)
[PASS] test_empty_board_returns_no_winner() (gas: 32303)
[PASS] test_game_in_progress_returns_no_winner() (gas: 92578)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32173)
[PASS] test_msg_sender() (gas: 238582)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32939)
[PASS] test_reset_board() (gas: 158485)
[PASS] test_stores_player_O() (gas: 7749)
[PASS] test_stores_player_X() (gas: 7794)
[PASS] test_symbols_must_alternate() (gas: 70247)
[PASS] test_tracks_current_turn() (gas: 108215)
Test result: ok. 25 passed; 0 failed; finished in 4.10ms

Refactoring to modifiers

To finish up, let's introduce some new Solidity syntax that will help us simplify even further. We can use function modifiers to perform our authorization checks for the owner and player addresses.

Modifiers are sort of similar to macros in other languages, and look like this:

modifier onlyOwner {
  require(
    msg.sender == address(0x1234),
    "only owner can call this function"
   );
  _;
}

modifier checkMoreThanFourCats {
  require(
    cats > 4,
    "cats must be greater than 4"
   );
  _;
}

modifier setFrobTo43 {
  frob = 43;
  _;
}

modifier requireBalance(uint256 amount) {
  require(balance > amount, "insufficient balance");
  _;
}

modifier nonReentrant() {
  require(!locked, "contract locked");
  locked = true;
   _;
  locked = false;
}

When a modifier is applied to a function, the compiler replaces the _ placeholder inside the modifier with the body of the function it's applied to. For example, these two functions...

function onlyOwnerCanCallThis() public onlyOwner {
  _doAdminStuff();
}

function sendTokens(address token, address recipient) public nonReentrant {
  IERC20(token).transfer(recipient);
}

..."expand" at compile time to the following:

function onlyOwnerCanCallThis() public {
  require(
    msg.sender == address(0x1234),
    "only owner can call this function"
   );
  _doAdminStuff();
}

function sendTokens(address token, address recipient) public {
  require(!locked, "contract locked");
  locked = true;
  IERC20(token).transfer(recipient);
  locked = false;
}

Let's refactor our _validPlayer function and admin check to use modifiers:

    modifier onlyOwner() {
        require(msg.sender == admin, "Unauthorized");
        _;
    }

    modifier onlyPlayer() {
        require(_validPlayer(), "Unauthorized");
        _;
    }

    function markSpace(uint256 space) public onlyPlayer {
        require(_validTurn(), "Not your turn");
        require(_validSpace(i), "Invalid space");
        require(_emptySpace(i), "Already marked");
        board[space] = _getSymbol();
        _turns++;
    }

    function reset() public onlyAdmin {
        delete board;
    }

We can now re-use these modifiers if we need to apply access control to other functions.

Modifiers can be very helpful, but they can also introduce indirection and complexity. It's best to keep them simple and use them sparingly.

🪙 Tokens

Introducing tokens

Last week we added access control to our game. We now have a basic two-player game that's restricted to specific players authenticated by address. This week, we'll turn this one shot game into a repeated one with support for multiple simultaneous games, and add a leaderboard to track wins over time. We'll also explore our first Ethereum native contract abstraction: the ERC20 token standard.

Goals this week

  • Support multiple players and multiple games.
  • Learn about Solidity structs and mappings.
  • Award points to winners and track their balances on a global leaderboard.
  • Learn about the ERC20 token standard.
  • Refactor our custom leaderboard to use an ERC20 token.

Suggested homework

Multiple games

In its current state, our game supports two player addresses, set permanently at contract construction time. The contract's owner can reset the board at any time, but our two players are locked in forever. And it supports only one game at a time. Let's move instead towards a more flexible design that can support many players and many simultaneous games. To do this, we'll need two new Solidity concepts: mappings and structs.

Mappings

Mappings are similar to hashmaps or dictionaries: a key-value data structure that enables us to set and get values by key. To declare a mapping, we must specify its key and value type:

mapping(address => bool) hasPlayedGame;
mapping(uint256 => address) addressById;
mapping(address => uint256[]) gamesByPlayer;

Mappings are a reference type, but their data location must always be storage. We can access a value in a mapping by key using square brackets:

bool played = hasPlayedGame[address(0x1234)];
address account = addressById[23];
uint256[] storage games = gamesByPlayer[address(0x1234)];

And set them using similar syntax:

hasPlayedGame[address(0x5678)] = true;
addressById[24] = address(0x5678);
gamesByPlayer[address(0x1234)].push(5);

Of course, since this is Solidity, mappings are weird in a few ways. First, keys are not stored in any way. Instead the keccak256 hash of a key is used to look up its value. That means there's no concept of an unknown key. Second, empty values in a mapping are initialized with their default value (just like if they were declared as a state variable). When we create a new mapping, it's as if every possible key exists in the mapping and points to an empty entry with a default value.

Structs

Structs group together many variables of different types in a single named type. They may be used inside mappings and arrays and may contain mappings and arrays as fields. We can declare them with the keyword struct:

struct Point {
    int256 x;
    int256 y;
}

struct Rectangle {
    Point: topLeft;
    Point: bottomRight;
}

struct Player {
    string username;
    address account;
    uint256 gamesWon;
    uint256 gamesLost;
    uint256[] gameIds;
}

We can define them in a few ways:

Point memory origin = Point({x: 0, y: 0});

Rectangle memory rect = Rectangle(
    Point(10, 0),
    Point(0, 20)
);

Player player; // Defaults to storage
player.username = "horsefacts";
player.account = address(0x1234);
player.gamesWon = 5;
// gamesLost will remain default value of 0
// gameIds will remain default value of []

And access their fields using the dot operator:

origin.x
> 0

rect.topLeft.x
> 10

player.gameIds.push(1);
player.gameIds[0]
> 1

Structs that contain mappings or dynamic arrays must have data location storage and must be created using the second syntax, since mappings and dynamic arrays cannot be created in memory.

Supporting multiple games

Our contract currently stores the state of a single, global game using several related state variables:

    uint256[9] public board;
    address public playerX;
    address public playerO;
    uint256 internal _turns;

We can instead use a struct to group these together in a data structure that represents an individual game:

struct Game {
    address playerX;
    address playerO;
    uint256[9] board;
    uint256 turns;
}

And a mapping to store many instances of a Game by an integer ID:

mapping(uint256 => Game) gamesById;

Finally, we'll need to parameterize our existing functions to take a game ID as an argument. For example, here's our existing markSpace function:

    function markSpace(uint256 space) public {
        require(_validPlayer(), "Unauthorized");
        require(_validTurn(), "Not your turn");
        require(_emptySpace(space), "Already marked");
        board[space] = _getSymbol(msg.sender);
        _turns++;
    }

We'll now need to pass in a game ID, and probably pass it through to some of our internal helpers:

    function markSpace(uint256 gameId, uint256 space) public {
        require(_validPlayer(gameId), "Unauthorized");
        require(_validTurn(gameId), "Not your turn");
        require(_emptySpace(gameId, space), "Already marked");
        gamesById[gameId].board[space] = _getSymbol(gameId, msg.sender);
        gamesById[gameId].turns++;
    }

Let's approach this change in three steps. First, we'll refactor to use a struct and mapping internally, without changing our external contract interface. Next, we'll update the interface to expect a game ID as an argument. Finally, we'll add functionality to create new games.

Refactoring internally

Let's refactor the way we refer to game data internally to read from the games mapping rather than global variables. First, let's define our new Game struct and add a mapping:

contract TicTacToken {
    uint256[9] public board;
    address public owner;
    address public playerX;
    address public playerO;

    struct Game {
        address playerX;
        address playerO;
        uint256 turns;
        uint256[9] board;
    }
    mapping(uint256 => Game) games;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;
    uint256 internal _turns;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        playerX = _playerX;
        playerO = _playerO;
    }
}

Since nothing is using either of these yet, all our tests still pass:

$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14913)
[PASS] test_auth_playerO_can_mark_space() (gas: 84612)
[PASS] test_auth_playerX_can_mark_space() (gas: 57687)
[PASS] test_can_mark_space_with_O() (gas: 106093)
[PASS] test_can_mark_space_with_X() (gas: 67849)
[PASS] test_cannot_overwrite_marked_space() (gas: 83135)
[PASS] test_checks_for_antidiagonal_win() (gas: 222815)
[PASS] test_checks_for_diagonal_win() (gas: 198684)
[PASS] test_checks_for_horizontal_win() (gas: 196829)
[PASS] test_checks_for_horizontal_win_row2() (gas: 197126)
[PASS] test_checks_for_vertical_win() (gas: 221544)
[PASS] test_contract_owner() (gas: 7661)
[PASS] test_draw_returns_no_winner() (gas: 269962)
[PASS] test_empty_board_returns_no_winner() (gas: 32303)
[PASS] test_game_in_progress_returns_no_winner() (gas: 92559)
[PASS] test_get_board() (gas: 29218)
[PASS] test_has_empty_board() (gas: 32173)
[PASS] test_msg_sender() (gas: 238582)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32939)
[PASS] test_reset_board() (gas: 158387)
[PASS] test_stores_player_O() (gas: 7749)
[PASS] test_stores_player_X() (gas: 7794)
[PASS] test_symbols_must_alternate() (gas: 70209)
[PASS] test_tracks_current_turn() (gas: 108163)
Test result: ok. 25 passed; 0 failed; finished in 2.48ms

Now for a big refactor: let's move the game state from the global state variables to the first struct in the games mapping. That means anywhere we're referring to board, playerX, playerO, or _turns, we should instead point to games[0].board, games[0].playerX, etc. (Find-and-replace can help).

Here's our full contract after this change:

contract TicTacToken {
    uint256[9] public board;
    address public owner;
    address public playerX;
    address public playerO;

    struct Game {
        address playerX;
        address playerO;
        uint256 turns;
        uint256[9] board;
    }
    mapping(uint256 => Game) games;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;
    uint256 internal _turns;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        games[0].playerX = _playerX;
        games[0].playerO = _playerO;
    }

    function getBoard() public view returns (uint256[9] memory) {
        return games[0].board;
    }

    function resetBoard() public {
        require(
            msg.sender == owner,
            "Unauthorized"
        );
        delete games[0].board;
    }

    function markSpace(uint256 space) public {
        require(_validPlayer(), "Unauthorized");
        require(_validTurn(), "Not your turn");
        require(_emptySpace(space), "Already marked");
        games[0].board[space] = _getSymbol(msg.sender);
        games[0].turns++;
    }

    function currentTurn() public view returns (uint256) {
        return (games[0].turns % 2 == 0) ? X : O;
    }

    function msgSender() public view returns (address) {
        return msg.sender;
    }

    function winner() public view returns (uint256) {
        uint256[8] memory wins = [
            _row(0),
            _row(1),
            _row(2),
            _col(0),
            _col(1),
            _col(2),
            _diag(),
            _antiDiag()
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

    function _getSymbol(address player) public view returns (uint256) {
        if (player == games[0].playerX) return X;
        if (player == games[0].playerO) return O;
        return EMPTY;
    }

    function _validTurn() internal view returns (bool) {
        return currentTurn() == _getSymbol(msg.sender);
    }

    function _validPlayer() internal view returns (bool) {
        return msg.sender == games[0].playerX || msg.sender == games[0].playerO;
    }

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

    function _row(uint256 row) internal view returns (uint256) {
        require(row < 3, "Invalid row");
        uint256 idx = 3 * row;
        return games[0].board[idx] * games[0].board[idx + 1] * games[0].board[idx + 2];
    }

    function _col(uint256 col) internal view returns (uint256) {
        require(col < 3, "Invalid column");
        return games[0].board[col] * games[0].board[col + 3] * games[0].board[col + 6];
    }

    function _diag() internal view returns (uint256) {
        return games[0].board[0] * games[0].board[4] * games[0].board[8];
    }

    function _antiDiag() internal view returns (uint256) {
        return games[0].board[2] * games[0].board[4] * games[0].board[6];
    }

    function _validTurn(uint256 symbol) internal view returns (bool) {
        return symbol == currentTurn();
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return games[0].board[i] == EMPTY;
    }

    function _validSymbol(uint256 symbol) internal pure returns (bool) {
        return symbol == X || symbol == O;
    }
}

Was it a clean refactor? Let's run the tests and see:

$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14946)
[PASS] test_auth_playerO_can_mark_space() (gas: 85032)
[PASS] test_auth_playerX_can_mark_space() (gas: 57870)
[PASS] test_cannot_overwrite_marked_space() (gas: 83453)
[PASS] test_checks_for_antidiagonal_win() (gas: 225083)
[PASS] test_checks_for_diagonal_win() (gas: 200715)
[PASS] test_checks_for_horizontal_win() (gas: 198860)
[PASS] test_checks_for_horizontal_win_row2() (gas: 199157)
[PASS] test_checks_for_vertical_win() (gas: 223812)
[PASS] test_contract_owner() (gas: 7661)
[PASS] test_draw_returns_no_winner() (gas: 272650)
[PASS] test_empty_board_returns_no_winner() (gas: 33311)
[PASS] test_game_in_progress_returns_no_winner() (gas: 93750)
[PASS] test_get_board() (gas: 29269)
[PASS] test_has_empty_board() (gas: 32173)
[PASS] test_msg_sender() (gas: 238582)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32996)
[PASS] test_reset_board() (gas: 159292)
[FAIL] test_stores_player_O() (gas: 18614)
Logs:
  Error: a == b not satisfied [address]
    Expected: 0x0000000000000000000000000000000000000003
      Actual: 0x0000000000000000000000000000000000000000

[FAIL] test_stores_player_X() (gas: 18659)
Logs:
  Error: a == b not satisfied [address]
    Expected: 0x0000000000000000000000000000000000000002
      Actual: 0x0000000000000000000000000000000000000000

[FAIL] test_can_mark_space_with_O() (gas: 119317)
Logs:
  Error: a == b not satisfied [uint]
    Expected: 2
      Actual: 0

[FAIL] test_can_mark_space_with_X() (gas: 80836)
Logs:
  Error: a == b not satisfied [uint]
    Expected: 1
      Actual: 0

[PASS] test_symbols_must_alternate() (gas: 70440)
[PASS] test_tracks_current_turn() (gas: 108637)
Test result: FAILED. 21 passed; 4 failed; finished in 4.08ms

Failed tests:
[FAIL] test_can_mark_space_with_O() (gas: 119317)
[FAIL] test_can_mark_space_with_X() (gas: 80836)
[FAIL] test_stores_player_O() (gas: 18614)
[FAIL] test_stores_player_X() (gas: 18659)

Encountered a total of 4 failing tests, 21 tests succeeded

Pretty good, but we've missed a few spots where our tests are relying on default getters for board, playerO, and playerX:

    function test_stores_player_X() public {
        assertEq(ttt.playerX(), PLAYER_X);
    }

    function test_stores_player_O() public {
        assertEq(ttt.playerO(), PLAYER_O);
    }

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0);
        assertEq(ttt.board(0), X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0);
        playerO.markSpace(1);
        assertEq(ttt.board(1), O);
    }

Let's update these to read from the mapping instead. Note that we can use multiple assignment to access the values of a struct:

    function test_stores_player_X() public {
        (address playerXAddr,,) = ttt.games(0);
        assertEq(playerXAddr, PLAYER_X);
    }

    function test_stores_player_O() public {
        (, address playerOAddr,) = ttt.games(0);
        assertEq(playerOAddr, PLAYER_O);
    }

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0);
        assertEq(ttt.getBoard()[0], X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0);
        playerO.markSpace(1);
        assertEq(ttt.getBoard()[1], O);
    }

Confirm that our tests pass:

$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14946)
[PASS] test_auth_playerO_can_mark_space() (gas: 85032)
[PASS] test_auth_playerX_can_mark_space() (gas: 57870)
[PASS] test_can_mark_space_with_O() (gas: 123671)
[PASS] test_can_mark_space_with_X() (gas: 87187)
[PASS] test_cannot_overwrite_marked_space() (gas: 83453)
[PASS] test_checks_for_antidiagonal_win() (gas: 225094)
[PASS] test_checks_for_diagonal_win() (gas: 200726)
[PASS] test_checks_for_horizontal_win() (gas: 198871)
[PASS] test_checks_for_horizontal_win_row2() (gas: 199168)
[PASS] test_checks_for_vertical_win() (gas: 223823)
[PASS] test_contract_owner() (gas: 7696)
[PASS] test_draw_returns_no_winner() (gas: 272661)
[PASS] test_empty_board_returns_no_winner() (gas: 33322)
[PASS] test_game_in_progress_returns_no_winner() (gas: 93761)
[PASS] test_get_board() (gas: 29291)
[PASS] test_has_empty_board() (gas: 32470)
[PASS] test_msg_sender() (gas: 238687)
[PASS] test_non_owner_cannot_reset_board() (gas: 12728)
[PASS] test_owner_can_reset_board() (gas: 33018)
[PASS] test_reset_board() (gas: 159327)
[PASS] test_stores_player_O() (gas: 12233)
[PASS] test_stores_player_X() (gas: 12299)
[PASS] test_symbols_must_alternate() (gas: 70440)
[PASS] test_tracks_current_turn() (gas: 108670)
Test result: ok. 25 passed; 0 failed; finished in 4.00ms

Finally, we can remove the global board, playerX, playerO, and _turns:

contract TicTacToken {
    address public owner;

    struct Game {
        address playerX;
        address playerO;
        uint256 turns;
        uint256[9] board;
    }
    mapping(uint256 => Game) public games;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        games[0].playerX = _playerX;
        games[0].playerO = _playerO;
    }

Up next, updating the external interface to take game ID as a parameter.

Passing game ID

Next we need to update the functions that make up the external interface of our contract to take a game ID as a parameter.

The good news is that it's easy to find all the places we need to update. Anywhere we're referring to games[0] will need to use a function argument instead. The bad news is that there are a lot of them:

contract TicTacToken {
    address public owner;

    struct Game {
        address playerX;
        address playerO;
        uint256 turns;
        uint256[9] board;
    }
    mapping(uint256 => Game) public games;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        games[0].playerX = _playerX;
        games[0].playerO = _playerO;
    }

    function getBoard() public view returns (uint256[9] memory) {
        return games[0].board;
    }

    function resetBoard() public {
        require(
            msg.sender == owner,
            "Unauthorized"
        );
        delete games[0].board;
    }

    function markSpace(uint256 space) public {
        require(_validPlayer(), "Unauthorized");
        require(_validTurn(), "Not your turn");
        require(_emptySpace(space), "Already marked");
        games[0].board[space] = _getSymbol(msg.sender);
        games[0].turns++;
    }

    function currentTurn() public view returns (uint256) {
        return (games[0].turns % 2 == 0) ? X : O;
    }

    function msgSender() public view returns (address) {
        return msg.sender;
    }

    function winner() public view returns (uint256) {
        uint256[8] memory wins = [
            _row(0),
            _row(1),
            _row(2),
            _col(0),
            _col(1),
            _col(2),
            _diag(),
            _antiDiag()
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

    function _getSymbol(address player) public view returns (uint256) {
        if (player == games[0].playerX) return X;
        if (player == games[0].playerO) return O;
        return EMPTY;
    }

    function _validTurn() internal view returns (bool) {
        return currentTurn() == _getSymbol(msg.sender);
    }

    function _validPlayer() internal view returns (bool) {
        return msg.sender == games[0].playerX || msg.sender == games[0].playerO;
    }

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

    function _row(uint256 row) internal view returns (uint256) {
        require(row < 3, "Invalid row");
        uint256 idx = 3 * row;
        return games[0].board[idx] * games[0].board[idx + 1] * games[0].board[idx + 2];
    }

    function _col(uint256 col) internal view returns (uint256) {
        require(col < 3, "Invalid column");
        return games[0].board[col] * games[0].board[col + 3] * games[0].board[col + 6];
    }

    function _diag() internal view returns (uint256) {
        return games[0].board[0] * games[0].board[4] * games[0].board[8];
    }

    function _antiDiag() internal view returns (uint256) {
        return games[0].board[2] * games[0].board[4] * games[0].board[6];
    }

    function _validTurn(uint256 symbol) internal view returns (bool) {
        return symbol == currentTurn();
    }

    function _emptySpace(uint256 i) internal view returns (bool) {
        return games[0].board[i] == EMPTY;
    }

    function _validSymbol(uint256 symbol) internal pure returns (bool) {
        return symbol == X || symbol == O;
    }
}

We'll also need to update many of our tests, to call these new parameterized functions. Rather than approach this as one sweeping change, it's safer and simpler to work function by function. Let's start with getBoard.

First, we'll update the four tests that refer to getBoard to pass a game ID argument:

    function test_get_board() public {
        uint256[9] memory expected = [
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY
        ];
        uint256[9] memory actual = ttt.getBoard(0);

        for (uint256 i = 0; i < 9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }

    function test_reset_board() public {
        playerX.markSpace(3);
        playerO.markSpace(0);
        playerX.markSpace(4);
        playerO.markSpace(1);
        playerX.markSpace(5);
        vm.prank(OWNER);
        ttt.resetBoard();
        uint256[9] memory expected = [
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY
        ];
        uint256[9] memory actual = ttt.getBoard(0);

        for (uint256 i = 0; i < 9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0);
        assertEq(ttt.getBoard(0)[0], X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0);
        playerO.markSpace(1);
        assertEq(ttt.getBoard(0)[1], O);
    }

Tests should now fail with a compiler error:

$ forge test
[â Š] Compiling...
[â ’] Compiling 1 files with 0.8.10
[â ˘] Solc finished in 11.82ms
Error:
   0: Compiler run failed
      TypeError: Wrong argument count for function call: 1 arguments given but expected 0.
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:71:36:
         |
      71 |         uint256[9] memory actual = ttt.getBoard(0);
         |                                    ^^^^^^^^^^^^^^^



      TypeError: Type is not callable
        --> /Users/ecm/Projects/ttt-book-code/src/test/TicTacToken.t.sol:71:36:
         |
      71 |         uint256[9] memory actual = ttt.getBoard(0);
         |                                    ^^^^^^^^^^^^^^^^^

Let's add an id argument to getBoard:

    function getBoard(uint256 id) public view returns (uint256[9] memory) {
        return games[id].board;
    }

And finally, we should be back to green:

$ forge test
Running 24 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 14968)
[PASS] test_auth_playerO_can_mark_space() (gas: 85076)
[PASS] test_auth_playerX_can_mark_space() (gas: 57870)
[PASS] test_can_mark_space_with_O() (gas: 123783)
[PASS] test_can_mark_space_with_X() (gas: 87276)
[PASS] test_cannot_overwrite_marked_space() (gas: 83453)
[PASS] test_checks_for_antidiagonal_win() (gas: 225130)
[PASS] test_checks_for_diagonal_win() (gas: 200762)
[PASS] test_checks_for_horizontal_win() (gas: 198884)
[PASS] test_checks_for_horizontal_win_row2() (gas: 199204)
[PASS] test_checks_for_vertical_win() (gas: 223859)
[PASS] test_contract_owner() (gas: 7696)
[PASS] test_draw_returns_no_winner() (gas: 272697)
[PASS] test_empty_board_returns_no_winner() (gas: 33358)
[PASS] test_game_in_progress_returns_no_winner() (gas: 93797)
[PASS] test_get_board() (gas: 29429)
[PASS] test_msg_sender() (gas: 238687)
[PASS] test_non_owner_cannot_reset_board() (gas: 12706)
[PASS] test_owner_can_reset_board() (gas: 32974)
[PASS] test_reset_board() (gas: 159400)
[PASS] test_stores_player_O() (gas: 12242)
[PASS] test_stores_player_X() (gas: 12308)
[PASS] test_symbols_must_alternate() (gas: 70440)
[PASS] test_tracks_current_turn() (gas: 108670)
Test result: ok. 24 passed; 0 failed; finished in 3.99ms

We can follow the same approach for the rest of our public functions: resetBoard, markSpace, currentTurn, and winner. The interface to almost every function will have to change, but it won't be too bad if we take small steps and let the compiler guide us towards all the necessary changes.

We won't walk through the full exercise here, but here's a look at our game contract and tests following this big refactor:

The game:

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

contract TicTacToken {
    address public owner;

    struct Game {
        address playerX;
        address playerO;
        uint256 turns;
        uint256[9] board;
    }
    mapping(uint256 => Game) public games;

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        games[0].playerX = _playerX;
        games[0].playerO = _playerO;
    }

    function getBoard(uint256 id) public view returns (uint256[9] memory) {
        return games[id].board;
    }

    function resetBoard(uint256 id) public {
        require(
            msg.sender == owner,
            "Unauthorized"
        );
        delete games[id].board;
    }

    function markSpace(uint256 id, uint256 space) public {
        require(_validPlayer(id), "Unauthorized");
        require(_validTurn(id), "Not your turn");
        require(_emptySpace(id, space), "Already marked");
        games[id].board[space] = _getSymbol(id, msg.sender);
        games[id].turns++;
    }

    function currentTurn(uint256 id) public view returns (uint256) {
        return (games[id].turns % 2 == 0) ? X : O;
    }

    function winner(uint256 id) public view returns (uint256) {
        uint256[8] memory wins = [
            _row(id, 0),
            _row(id, 1),
            _row(id, 2),
            _col(id, 0),
            _col(id, 1),
            _col(id, 2),
            _diag(id),
            _antiDiag(id)
        ];
        for (uint256 i; i < wins.length; i++) {
            uint256 win = _checkWin(wins[i]);
            if (win == X || win == O) return win;
        }
        return 0;
    }

    function _getSymbol(uint256 id, address player) public view returns (uint256) {
        if (player == games[id].playerX) return X;
        if (player == games[id].playerO) return O;
        return EMPTY;
    }

    function _validTurn(uint256 id) internal view returns (bool) {
        return currentTurn(id) == _getSymbol(id, msg.sender);
    }

    function _validPlayer(uint256 id) internal view returns (bool) {
        return msg.sender == games[id].playerX || msg.sender == games[id].playerO;
    }

    function _checkWin(uint256 product) internal pure returns (uint256) {
        if (product == 1) {
            return X;
        }
        if (product == 8) {
            return O;
        }
        return 0;
    }

    function _row(uint256 id, uint256 row) internal view returns (uint256) {
        require(row < 3, "Invalid row");
        uint256 idx = 3 * row;
        return games[id].board[idx] * games[id].board[idx + 1] * games[id].board[idx + 2];
    }

    function _col(uint256 id, uint256 col) internal view returns (uint256) {
        require(col < 3, "Invalid column");
        return games[id].board[col] * games[id].board[col + 3] * games[id].board[col + 6];
    }

    function _diag(uint256 id) internal view returns (uint256) {
        return games[id].board[0] * games[id].board[4] * games[id].board[8];
    }

    function _antiDiag(uint256 id) internal view returns (uint256) {
        return games[id].board[2] * games[id].board[4] * games[id].board[6];
    }

    function _validTurn(uint256 id, uint256 symbol) internal view returns (bool) {
        return symbol == currentTurn(id);
    }

    function _emptySpace(uint256 id, uint256 i) internal view returns (bool) {
        return games[id].board[i] == EMPTY;
    }

    function _validSymbol(uint256 symbol) internal pure returns (bool) {
        return symbol == X || symbol == O;
    }
}

The 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 User {

    TicTacToken internal ttt;
    Vm internal vm;
    address internal _address;

    constructor(address address_, TicTacToken _ttt, Vm _vm) {
        _address = address_;
        ttt = _ttt;
        vm = _vm;
    }

    function markSpace(uint256 id, uint256 space) public {
        vm.prank(_address);
        ttt.markSpace(id, space);
    }
}

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

    uint256 internal constant EMPTY = 0;
    uint256 internal constant X = 1;
    uint256 internal constant O = 2;

    address internal constant OWNER = address(1);
    address internal constant PLAYER_X = address(2);
    address internal constant PLAYER_O = address(3);

    function setUp() public {
        ttt = new TicTacToken(OWNER, PLAYER_X, PLAYER_O);
        playerX = new User(PLAYER_X, ttt, vm);
        playerO = new User(PLAYER_O, ttt, vm);
    }

    function test_get_board() public {
        uint256[9] memory expected = [
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY
        ];
        uint256[9] memory actual = ttt.getBoard(0);

        for (uint256 i = 0; i < 9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }

    function test_reset_board() public {
        playerX.markSpace(0, 3);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 1);
        playerX.markSpace(0, 5);
        vm.prank(OWNER);
        ttt.resetBoard(0);
        uint256[9] memory expected = [
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY,
            EMPTY
        ];
        uint256[9] memory actual = ttt.getBoard(0);

        for (uint256 i = 0; i < 9; i++) {
            assertEq(actual[i], expected[i]);
        }
    }

    function test_can_mark_space_with_X() public {
        playerX.markSpace(0, 0);
        assertEq(ttt.getBoard(0)[0], X);
    }

    function test_can_mark_space_with_O() public {
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 1);
        assertEq(ttt.getBoard(0)[1], O);
    }

    function test_cannot_overwrite_marked_space() public {
        playerX.markSpace(0, 0);

        vm.expectRevert("Already marked");
        playerO.markSpace(0, 0);
    }

    function test_symbols_must_alternate() public {
        playerX.markSpace(0, 0);
        vm.expectRevert("Not your turn");
        playerX.markSpace(0, 1);
    }

    function test_tracks_current_turn() public {
        assertEq(ttt.currentTurn(0), X);
        playerX.markSpace(0, 0);
        assertEq(ttt.currentTurn(0), O);
        playerO.markSpace(0, 1);
        assertEq(ttt.currentTurn(0), X);
    }

    function test_checks_for_horizontal_win() public {
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 2);
        assertEq(ttt.winner(0), X);
    }

    function test_checks_for_horizontal_win_row2() public {
        playerX.markSpace(0, 3);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 1);
        playerX.markSpace(0, 5);
        assertEq(ttt.winner(0), X);
    }

    function test_checks_for_vertical_win() public {
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 2);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 6);
        assertEq(ttt.winner(0), O);
    }

    function test_checks_for_diagonal_win() public {
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 1);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 5);
        playerX.markSpace(0, 8);
        assertEq(ttt.winner(0), X);
    }

    function test_checks_for_antidiagonal_win() public {
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 2);
        playerX.markSpace(0, 3);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 5);
        playerO.markSpace(0, 6);
        assertEq(ttt.winner(0), O);
    }

    function test_draw_returns_no_winner() public {
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 2);
        playerO.markSpace(0, 6);
        playerX.markSpace(0, 8);
        playerO.markSpace(0, 5);
        assertEq(ttt.winner(0), 0);
    }

    function test_empty_board_returns_no_winner() public {
        assertEq(ttt.winner(0), 0);
    }

    function test_game_in_progress_returns_no_winner() public {
        playerX.markSpace(0, 1);
        assertEq(ttt.winner(0), 0);
    }

    function test_contract_owner() public {
        assertEq(ttt.owner(), OWNER);
    }

    function test_owner_can_reset_board() public {
        vm.prank(OWNER);
        ttt.resetBoard(0);
    }

    function test_non_owner_cannot_reset_board() public {
        vm.expectRevert("Unauthorized");
        ttt.resetBoard(0);
    }

    function test_stores_player_X() public {
        (address playerXAddr,,) = ttt.games(0);
        assertEq(playerXAddr, PLAYER_X);
    }

    function test_stores_player_O() public {
        (, address playerOAddr,) = ttt.games(0);
        assertEq(playerOAddr, PLAYER_O);
    }

    function test_auth_nonplayer_cannot_mark_space() public {
        vm.expectRevert("Unauthorized");
        ttt.markSpace(0, 0);
    }

    function test_auth_playerX_can_mark_space() public {
        vm.prank(PLAYER_X);
        ttt.markSpace(0, 0);
    }

    function test_auth_playerO_can_mark_space() public {
        vm.prank(PLAYER_X);
        ttt.markSpace(0, 0);

        vm.prank(PLAYER_O);
        ttt.markSpace(0, 1);
    }
}

Now that everything's parameterized by game ID, we have everything we need in place to support multiple simultaneous games.

Simultaneous games

We've refactored our representation of game state to use a mapping of structs, and updated our interface to take game ID as a parameter. All our tests pass, but before we move on, why don't we add one to make sure our games are really isolated? We can play out two games with different IDs and ensure they have different winners:

    function test_games_are_isolated() public {
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 2);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 6);

        playerX.markSpace(1, 0);
        playerO.markSpace(1, 1);
        playerX.markSpace(1, 4);
        playerO.markSpace(1, 5);
        playerX.markSpace(1, 8);

        assertEq(ttt.winner(0), O);
        assertEq(ttt.winner(1), X);
    }

Run the tests to make sure we're OK to move on...

$ forge test
Running 24 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 15150)
[PASS] test_auth_playerO_can_mark_space() (gas: 86115)
[PASS] test_auth_playerX_can_mark_space() (gas: 58296)
[PASS] test_can_mark_space_with_O() (gas: 124844)
[PASS] test_can_mark_space_with_X() (gas: 87678)
[PASS] test_cannot_overwrite_marked_space() (gas: 84386)
[PASS] test_checks_for_antidiagonal_win() (gas: 229074)
[PASS] test_checks_for_diagonal_win() (gas: 204050)
[PASS] test_checks_for_horizontal_win() (gas: 202172)
[PASS] test_checks_for_horizontal_win_row2() (gas: 202492)
[PASS] test_checks_for_vertical_win() (gas: 227806)
[PASS] test_contract_owner() (gas: 7696)
[PASS] test_draw_returns_no_winner() (gas: 277769)
[PASS] test_empty_board_returns_no_winner() (gas: 33944)
[PASS] test_game_in_progress_returns_no_winner() (gas: 94845)
[FAIL. Reason: Unauthorized] test_games_are_isolated() (gas: 234454)
[PASS] test_get_board() (gas: 29362)
[PASS] test_non_owner_cannot_reset_board() (gas: 12800)
[PASS] test_owner_can_reset_board() (gas: 33071)
[PASS] test_reset_board() (gas: 161596)
[PASS] test_stores_player_O() (gas: 12242)
[PASS] test_stores_player_X() (gas: 12308)
[PASS] test_symbols_must_alternate() (gas: 71226)
[PASS] test_tracks_current_turn() (gas: 110065)
Test result: FAILED. 23 passed; 1 failed; finished in 3.66ms

Failed tests:
[FAIL. Reason: Unauthorized] test_games_are_isolated() (gas: 234454)

Encountered a total of 1 failing tests, 23 tests succeeded

Looks like we have one more change to make to our game contract: we're setting up a single game with ID 0 at contract construction time, without any mechanism for creating new games. Trying to play the game with ID 1 fails, because it's an empty struct with playerX and playerO set to address(0):

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        games[0].playerX = _playerX;
        games[0].playerO = _playerO;
    }

Let's pend the previous test and add one to create a newGame function. This should take an address for playerX and playerO and set up a new game.

    function test_creates_new_game() public {
        ttt.newGame(address(5), address(6));
        (address playerXAddr, address playerOAddr, uint256 turns) = ttt.game(1);
        assertEq(playerXAddr, address(5));
        assertEq(playerOAddr, address(6));
        assertEq(turns, 0);
    }

We'll need a few moving parts to make this pass. Let's keep track of the next game ID in an internal state variable, _nextGameID, and increment it when we create a new game. When we create a new game, we'll set the playerX and playerO values on the Game struct inside the games mapping. Finally, we can call newGame in the constructor to handle game 0:

    uint256 internal _nextGameId;

    constructor(address _owner, address _playerX, address _playerO) {
        owner = _owner;
        newGame(_playerX, _playerO);
    }

    function newGame(address playerX, address playerO) public {
        games[_nextGameId].playerX = playerX;
        games[_nextGameId].playerO = playerO;
        _nextGameId++;
    }

Run the tests...

$ forge test
Running 24 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 15150)
[PASS] test_auth_playerO_can_mark_space() (gas: 86137)
[PASS] test_auth_playerX_can_mark_space() (gas: 58318)
[PASS] test_can_mark_space_with_O() (gas: 124910)
[PASS] test_can_mark_space_with_X() (gas: 87745)
[PASS] test_cannot_overwrite_marked_space() (gas: 84430)
[PASS] test_checks_for_antidiagonal_win() (gas: 229206)
[PASS] test_checks_for_diagonal_win() (gas: 204226)
[PASS] test_checks_for_horizontal_win() (gas: 202305)
[PASS] test_checks_for_horizontal_win_row2() (gas: 202580)
[PASS] test_checks_for_vertical_win() (gas: 227938)
[PASS] test_contract_owner() (gas: 7763)
[PASS] test_creates_new_game() (gas: 59124)
[PASS] test_draw_returns_no_winner() (gas: 277945)
[PASS] test_empty_board_returns_no_winner() (gas: 33922)
[PASS] test_game_in_progress_returns_no_winner() (gas: 94845)
[PASS] test_get_board() (gas: 29362)
[PASS] test_non_owner_cannot_reset_board() (gas: 12800)
[PASS] test_owner_can_reset_board() (gas: 33093)
[PASS] test_reset_board() (gas: 161720)
[PASS] test_stores_player_O() (gas: 12242)
[PASS] test_stores_player_X() (gas: 12286)
[PASS] test_symbols_must_alternate() (gas: 71270)
[PASS] test_tracks_current_turn() (gas: 110109)
Test result: ok. 24 passed; 0 failed; finished in 3.83ms

Looks good, but let's remove game setup from the constructor altogether:

    constructor(address _owner) {
        owner = _owner;
    }

Instead, we'll call newGame to set up a new game in our test context:

    function setUp() public {
        ttt = new TicTacToken(OWNER);
        playerX = new User(PLAYER_X, ttt, vm);
        playerO = new User(PLAYER_O, ttt, vm);
        ttt.newGame(PLAYER_X, PLAYER_O);
    }

Finally, we should be able to unpend and run our original test, with one addition: calling newGame to create game 1 alongside game 0:

    function test_games_are_isolated() public {
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 0);
        playerX.markSpace(0, 2);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 4);
        playerO.markSpace(0, 6);

        ttt.newGame(PLAYER_X, PLAYER_O);
        playerX.markSpace(1, 0);
        playerO.markSpace(1, 1);
        playerX.markSpace(1, 4);
        playerO.markSpace(1, 5);
        playerX.markSpace(1, 8);

        assertEq(ttt.winner(0), O);
        assertEq(ttt.winner(1), X);
    }
$ forge test
Running 25 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 15150)
[PASS] test_auth_playerO_can_mark_space() (gas: 86148)
[PASS] test_auth_playerX_can_mark_space() (gas: 58318)
[PASS] test_can_mark_space_with_O() (gas: 124910)
[PASS] test_can_mark_space_with_X() (gas: 87745)
[PASS] test_cannot_overwrite_marked_space() (gas: 84430)
[PASS] test_checks_for_antidiagonal_win() (gas: 229206)
[PASS] test_checks_for_diagonal_win() (gas: 204160)
[PASS] test_checks_for_horizontal_win() (gas: 202305)
[PASS] test_checks_for_horizontal_win_row2() (gas: 202602)
[PASS] test_checks_for_vertical_win() (gas: 227938)
[PASS] test_contract_owner() (gas: 7718)
[PASS] test_creates_new_game() (gas: 59124)
[PASS] test_draw_returns_no_winner() (gas: 277945)
[PASS] test_empty_board_returns_no_winner() (gas: 33944)
[PASS] test_game_in_progress_returns_no_winner() (gas: 94867)
[PASS] test_games_are_isolated() (gas: 450603)
[PASS] test_get_board() (gas: 29384)
[PASS] test_non_owner_cannot_reset_board() (gas: 12800)
[PASS] test_owner_can_reset_board() (gas: 33093)
[PASS] test_reset_board() (gas: 161720)
[PASS] test_stores_player_O() (gas: 12242)
[PASS] test_stores_player_X() (gas: 12308)
[PASS] test_symbols_must_alternate() (gas: 71270)
[PASS] test_tracks_current_turn() (gas: 110109)
Test result: ok. 25 passed; 0 failed; finished in 3.41ms

Success! We've pulled off a major redesign to support many simultaneous games with different players.

Building a leaderboard

Let's use our newfound knowledge of mappings to create a leaderboard that tracks the total number of games won by address. We can build on this to award points to each user based on how quickly they won the game.

Tracking wins

Let's start by tracking the number of wins for each player over time. We'll add a totalWins function that returns the total number of games won by address:

    function test_playerX_win_count_starts_at_zero() public {
        assertEq(ttt.totalWins(PLAYER_X), 0);
    }

    function test_playerO_win_count_starts_at_zero() public {
        assertEq(ttt.totalWins(PLAYER_O), 0);
    }

    function test_increments_win_count_on_win() public {
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 2);
        assertEq(ttt.totalWins(PLAYER_X), 1);

        ttt.newGame(PLAYER_X, PLAYER_O);
        playerX.markSpace(1, 1);
        playerO.markSpace(1, 2);
        playerX.markSpace(1, 3);
        playerO.markSpace(1, 4);
        playerX.markSpace(1, 5);
        playerO.markSpace(1, 6);
        assertEq(ttt.totalWins(PLAYER_O), 1);
    }

We can store the win count using a mapping with the player's address as its key and their uint256 win count as its value. We'll rely on the default getter to create a public totalWins() function:

mapping(address => uint256) public totalWins;

Our first two tests should pass thanks to the mapping's default balue of zero, but we'll need to increment the win count in order to pass the third:

$ forge test -m win_count
Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[FAIL] test_increments_win_count_on_win() (gas: 199302)
Logs:
  Error: a == b not satisfied [uint]
    Expected: 1
      Actual: 0

[PASS] test_playerO_win_count_starts_at_zero() (gas: 7811)
[PASS] test_playerX_win_count_starts_at_zero() (gas: 7845)
Test result: FAILED. 2 passed; 1 failed; finished in 2.65ms

Failed tests:
[FAIL] test_increments_win_count_on_win() (gas: 199302)

Let's check for a winner each time we mark a space, and increment the value in the mapping when we find one:

    function markSpace(uint256 id, uint256 space) public {
        require(_validPlayer(id), "Unauthorized");
        require(_validTurn(id), "Not your turn");
        require(_emptySpace(id, space), "Already marked");
        games[id].board[space] = _getSymbol(id, msg.sender);
        games[id].turns++;
        if(winner(id) != 0) {
            address winnerAddress = _getAddress(id, winner(id));
            totalWins[winnerAddress] += 1;
        }
    }

    function _getAddress(uint256 id, uint256 symbol) internal view returns (address) {
        if (symbol == X) return games[id].playerX;
        if (symbol == O) return games[id].playerO;
        return address(0);
    }

Looking good!

$ forge test -m win_count
[â Š] Compiling...
[â ”] Compiling 2 files with 0.8.10
[â ‘] Solc finished in 414.60ms
Compiler run successful

Running 3 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_increments_win_count_on_win() (gas: 274823)
[PASS] test_playerO_win_count_starts_at_zero() (gas: 7811)
[PASS] test_playerX_win_count_starts_at_zero() (gas: 7845)
Test result: ok. 3 passed; 0 failed; finished in 3.44ms

Tracking points

Rather than just counting wins, let's create a more complex points based leaderboard. We can issue points based on the number of turns it takes to win: a win in three moves should be worth more than a win in five.

Let's award 300 points for a 3-move win, 200 for a 4-move win, and 100 for a 5-move win:

    function test_three_move_win_X() public {
        // x | x | x
        // o | o | .
        // . | . | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 2);
        assertEq(ttt.totalPoints(PLAYER_X), 300);
    }

    function test_three_move_win_O() public {
        // x | x | .
        // o | o | o
        // x | . | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 5);
        assertEq(ttt.totalPoints(PLAYER_O), 300);
    }

    function test_four_move_win_X() public {
        // x | x | x
        // o | o | .
        // x | o | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 2);
        assertEq(ttt.totalPoints(PLAYER_X), 200);
    }

    function test_four_move_win_O() public {
        // x | x | .
        // o | o | o
        // x | o | x
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 8);
        playerO.markSpace(0, 5);
        assertEq(ttt.totalPoints(PLAYER_O), 200);
    }

    function test_five_move_win_X() public {
        // x | x | x
        // o | o | x
        // x | o | o
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 5);
        playerO.markSpace(0, 8);
        playerX.markSpace(0, 2);
        assertEq(ttt.totalPoints(PLAYER_X), 100);
    }

(With only 9 spaces, player O cannot actually win in five moves since we assume they always take the second turn).

We'll track point balances in a mapping just like wins, but increment its value by different amounts. Here's one way we can use the number of turns to determine the number of moves:

    mapping(address => uint256) public totalPoints;

    function markSpace(uint256 id, uint256 space) public {
        require(_validPlayer(id), "Unauthorized");
        require(_validTurn(id), "Not your turn");
        require(_emptySpace(id, space), "Already marked");
        games[id].board[space] = _getSymbol(id, msg.sender);
        games[id].turns++;
        if(winner(id) != 0) {
            address winnerAddress = _getAddress(id, winner(id));
            totalWins[winnerAddress] += 1;
            totalPoints[winnerAddress] += _pointsEarned(id);
        }
    }

    function _pointsEarned(uint256 id) internal view returns (uint256) {
        uint256 turns = games[id].turns;
        uint256 moves;
        if (winner(id) == X) {
            moves = (turns + 1) / 2;
        }
        if (winner(id) == O) {
            moves = turns / 2;
        }
        return 600 - (moves * 100);
    }

Refactoring to tokens

We could continue tracking point balances in our own custom leaderboard, but let's instead refactor towards an Ethereum native design pattern for our problem: tracking points using a custom token.

We'll need to take a quick detour through Ethereum token standards, but along the way we'll see that the smart contracts powering tokens on Ethereum are not much more complicated than our existing totalPoints mapping!

The ERC20 standard

ERC20 (Ethereum Request For Comment Number Twenty) is a standardized smart contract interface for fungible tokens on Ethereum. What does that mean?

Fungible and nonfungible tokens

A token is a digital asset that can represent just about anything: reputation points, units of currency, shares in a liquidity pool, voting power in a governance system, and much more. Tokens are owned by Ethereum accounts (that is, EOAs or smart contracts), and are typically (but not always!) transferrable from one account to another.

A fungible asset is interchangeable and divisible. For example, US dollars, gold, and Ether are all fungible assets. One twenty-dollar bill has the same value as any other, and can be subdivided into dollars and cents. One gold bar is the same as another, and can be subdivided into ounces. One ETH can be used to pay for gas on the Ethereum network the same as any other, and can be subdivided into units as small as one quintillionth of an ETH (called a "wei").

Currencies are not the only type of fungible assets: soybeans, Apple stock, and 1 ounce bags of Cool Ranch Doritos are all fungible assets, too!

A nonfungible asset has unique qualities that mean one asset is not interchangeable for another. Things like diamonds, apartments, the Mona Lisa, and JPEG images of 2007 Kia Sedonas are all nonfungible. They all may have value, but every asset is different, since it has specific qualities that vary. Nonfungible tokens (NFTs) exist on Ethereum, too, and have their own standard, ERC721, which we'll discuss a little later.

Why create a standard?

A standard interface for fungible tokens means application and smart contract developers can depend on a common API and create interoperable contracts, products, and services. Wallets, smart contracts, decentralized finance applications, governance systems, and more all depend on this common interface to read token metadata, access account balances, and transfer from one account to another.

Many of the most popular digital assets are ERC20 tokens. You can see many of them on Etherscan. A few examples from this list:

  • Stablecoins that are designed to remain pegged 1:1 with the dollar like DAI, USDC, and Tether.
  • Wrapped Bitcoin, or wBTC, an ERC20 representation of Bitcoin on the Ethereum blockchain.
  • MATIC and FTM, the native tokens for Polygon and Fantom, two other EVM-compatible networks.
  • stETH and cETH, wrapper tokens that represent staked Ether in Lido and Ether deposits in Compound.
  • UNI, MKR, and COMP, governance tokens for the Uniswap, Maker, and Compound protocols.
  • SHIB, a meme-based dogcoin.

Reading the standard

The ERC20 standard is a short, simple read. Take a look for yourself. Here are the Solidity function signatures of the whole interface:

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)

function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)

function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

An ERC20 token is a smart contract that conforms to this interface. Let's walk through each of the functions.

First up are a few functions that return metadata about the token:

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)
  • name returns a human-readable name, like "Dai Stablecoin."
  • symbol returns a short symbol, like "DAI"
  • decimals returns the number of decimal places used to denominate the token. Most ERC20 tokens use 18 decimals, the same number used to subdivide Ether.
  • totalSupply returns the total supply of the token.

Next, we have two functions related to reading balances and sending tokens:

function balanceOf(address _owner) public view returns (uint256 balance)

The balanceOf function returns the token balance of the account with address _owner.

function transfer(address _to, uint256 _value) public returns (bool success)

The transfer function sends some quantity of tokens _value from the caller account's address to the account with address _to. It's up to the implementation to keep track of balances and transfers internally in whatever way makes sense.

Finally, we have three functions that enable third parties to send tokens on behalf of a user. These are important in order to allow smart contracts to withdraw and spend tokens on behalf of externally owned accounts. Most of the essential complexity in ERC20 lives here:

function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)

transferFrom is a third-party transfer sending _value tokens from the _from address to the _to address. Before a third party can call transferFrom, the _from user must first call the next function:

function approve(address _spender, uint256 _value) public returns (bool success)

approve allows the _spender address to withdraw and transfer a quantity _value of tokens. The _spender may withdraw multiple times, up to the _value amount. This approved amount is called an "allowance," which leads to the next function:

function allowance(address _owner, address _spender) public view returns (uint256 remaining)

allowance returns the remaining quantity of tokens _spender is allowed to withdraw on behalf of _owner.

...and that's it! These nine functions are the foundation of the many thousands of ERC20 tokens on the Ethereum blockchain.

Exploring an implementation

The ERC20 standard defines the external interface and expected behavior of a token contract, but not the internal implementation details, like how to track account balances and approvals. To see how an ERC20 token is actually implemented, let's take a line by line look at the OpenZeppelin ERC20 contract, one widely used ERC20 implementation.

Here are the first few lines:

pragma solidity ^0.8.0;

import "./IERC20.sol";
import "./extensions/IERC20Metadata.sol";
import "../../utils/Context.sol";

contract ERC20 is Context, IERC20, IERC20Metadata {
    mapping(address => uint256) private _balances;

    mapping(address => mapping(address => uint256)) private _allowances;

    uint256 private _totalSupply;

    string private _name;
    string private _symbol;

    constructor(string memory name_, string memory symbol_) {
        _name = name_;
        _symbol = symbol_;
    }

We're inheriting from multiple base contracts, but don't worry too much about their details. Context defines wrapped helper functions for accessing msg.sender and msg.data. IERC20 and IERC20Metadata are empty interfaces. These are good practices for a contract that will be inherited by others like OpenZeppelin, but not too relevant to understanding the internals of how our token implementation works.

The storage variables are more interesting: _balances is a mapping from address to amount, and we've defined strings to store _name and _symbol and a uint for _totalSupply. _allowances is a nested mapping—its key is an address, and value is another mapping. We'll see how it's used to store allowances shortly.

The token name and symbol must be passed to the constructor and are set at construction time.

Moving on, we see implementations of the metadata functions:

    function name() public view virtual override returns (string memory) {
        return _name;
    }

    function symbol() public view virtual override returns (string memory) {
        return _symbol;
    }

    function decimals() public view virtual override returns (uint8) {
        return 18;
    }

    function totalSupply() public view virtual override returns (uint256) {
        return _totalSupply;
    }

These are all wrappers around private state variables, with the exception of decimals, which hardcodes the conventional default of 18. (Derived contracts can override this function if they really want to use a different number of decimals, but it's generally considered good practice to stick with 18).

Next up are views for balanceOf and allowance:

    function balanceOf(address account) public view virtual override returns (uint256) {
        return _balances[account];
    }

    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }

You can see that balanceOf reads the balance by address from the _balances mapping—pretty much the same as the points mapping in our Tic-Tac-Toe game! The allowance function demonstrates accessing a nested mapping—we first look up the owner's allowances mapping by address, then look up the spender's allowance from the nested (address => uint256) mapping.

On to token transfers, our first state-changing behavior...

    function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
        _transfer(_msgSender(), recipient, amount);
        return true;
    }

    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal virtual {
        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

        _beforeTokenTransfer(sender, recipient, amount);

        uint256 senderBalance = _balances[sender];
        require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
        unchecked {
            _balances[sender] = senderBalance - amount;
        }
        _balances[recipient] += amount;

        emit Transfer(sender, recipient, amount);

        _afterTokenTransfer(sender, recipient, amount);
    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual {}

    function _afterTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual {}

transfer itself is pretty boring—it passes arguments through to an internal _transfer helper and returns true. (Note how it's making a call to _msgSender() in the first argument to _transfer. This is an internal helper function derived from Context that simply returns msg.sender).

The _transfer helper is where the real behavior lives. In order, we have:

  1. Two require statements checking that sender and recipient are valid addresses.
  2. A call to a _beforeTokenTransfer hook. You can see that this is defined as an empty virtual function below. If a derived contract wants to perform some action before token transfers, they can override this virtual function to add a "before hook" to the transfer.
  3. Reading the sender's balance from the _balances mapping and storing it in a temporary variable to be reused.
  4. Another require statement ensuring that amount to send does not exceed the sender's balance.
  5. Deducting the transfer amount from the sender's balance and adding it to the recipient's balance. The subtraction here is wrapped in an unchecked block, which saves some gas by skipping checks for arithmetic overflows and underflows. It's safe in this case because we've already checked senderBalance >= amount, so an underflow isn't possible.
  6. Emitting a Transfer event. (We haven't covered this syntax yet, but it's kind of like a log statement).
  7. An _afterTokenTransfer hook similar to the before hook.

Approvals follow a similar pattern as transfers, delegating to an internal helper:

    function approve(address spender, uint256 amount) public virtual override returns (bool) {
        _approve(_msgSender(), spender, amount);
        return true;
    }

    function _approve(
        address owner,
        address spender,
        uint256 amount
    ) internal virtual {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }

Short and simple: two require checks, setting the approval amount in the nested _allowances mapping, and emitting an event.

In addition to approve, OpenZeppelin's ERC20 defines increaseApproval and decreaseApproval functions that are not defined in the ERC20 standard:

    function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
        _approve(_msgSender(), spender, _allowances[_msgSender()][spender] + addedValue);
        return true;
    }

    function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
        uint256 currentAllowance = _allowances[_msgSender()][spender];
        require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
        unchecked {
            _approve(_msgSender(), spender, currentAllowance - subtractedValue);
        }

        return true;
    }

transferFrom checks the sender's allowance, makes a transfer, and updates the approved allowance:

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) public virtual override returns (bool) {
        _transfer(sender, recipient, amount);

        uint256 currentAllowance = _allowances[sender][_msgSender()];
        require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
        unchecked {
            _approve(sender, _msgSender(), currentAllowance - amount);
        }

        return true;
    }

Finally, internal _mint and _burn functions to create and destroy tokens. These update both the _balances mapping and _totalSupply of the token. Note that these are not public functions—they are meant to be used internally according to whatever minting/burning logic is necessary in the derived contract.

    function _mint(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: mint to the zero address");

        _beforeTokenTransfer(address(0), account, amount);

        _totalSupply += amount;
        _balances[account] += amount;
        emit Transfer(address(0), account, amount);

        _afterTokenTransfer(address(0), account, amount);
    }

    function _burn(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: burn from the zero address");

        _beforeTokenTransfer(account, address(0), amount);

        uint256 accountBalance = _balances[account];
        require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
        unchecked {
            _balances[account] = accountBalance - amount;
        }
        _totalSupply -= amount;

        emit Transfer(account, address(0), amount);

        _afterTokenTransfer(account, address(0), amount);
    }

...and that's it again! A little over 150 lines of Solidity for an ERC20 implementation.

Comparing different implementations and their design trade-offs is an interesting exercise. If you're interested in exploring further, here are a few alternative ERC20 contracts:

  • Solmate, an opinionated, gas optimized ERC20.
  • ds-token, a simple contract written in dapphub style.
  • Consensys, an old but concise implementation.
  • MiniMeToken, an ERC20 with extra features for checkpointing balances and cloning tokens.

Creating a token

Now that we've explored how ERC20 tokens work, you may have noticed some similiarities with the leaderboard in our game. The internal mapping storing the number of points awarded by player address in our game is not too different from the internal mapping storing token balances in an ERC20. Let's lean into this similarity and use a token as our mechanism for awarding points.

Of course, tokens are transferrable, so this is conceptually a little bit different than our existing points scoreboard: less like the high score screen at the end of an arcade game, and more like a skee-ball machine that gives out prize tickets.

To start, we need a token of our own. Let's create a new, empty test file: src/test/Token.t.sol:

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

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

import "../Token.sol";

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

    Token internal token;

    function setUp() public {
        token = new Token();
    }
}

...and a new, empty contract for our token, src/Token.sol:

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

contract Token {}

We'll add a few inital tests for the token metadata attributes: name, symbol, and decimals:

    function test_token_name() public {
        assertEq(token.name(), "Tic Tac Token");
    }

    function test_token_symbol() public {
        assertEq(token.symbol(), "TTT");
    }

    function test_token_decimals() public {
        assertEq(token.decimals(), 18);
    }

Over in our token contract, let's import and use OpenZeppelin ERC20:

import "openzeppelin-contracts/contracts/token/ERC20.sol";

contract Token is ERC20 {}

Run the tests, and we'll see a compiler error:

$ forge test
[â Š] Compiling...
[â ’] Compiling 2 files with 0.8.10
[â ˘] Solc finished in 12.85ms
Error:
   0: Compiler run failed
      TypeError: Contract "Token" should be marked as abstract.
       --> /Users/ecm/Projects/ttt-book-code/src/Token.sol:6:1:
        |
      6 | contract Token is ERC20 {}
        | ^^^^^^^^^^^^^^^^^^^^^^^^^^
      Note: Missing implementation:
        --> /Users/ecm/Projects/ttt-book-code/lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol:54:5:
         |
      54 |     constructor(string memory name_, string memory symbol_) {
         |     ^ (Relevant source part starts here and spans across multiple lines).

This Solidity error is a little cryptic, but it's indicating that our base contract ERC20 is expecting constructor arguments. In this case, we need to pass the token name and symbol at construction time.

Let's add a constructor() function and pass the name and symbol as arguments. We can use a modifier-like syntax inline next to the constructor function to pass the arguments needed by ERC20:

import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {

    constructor() ERC20("Tic Tac Token", "TTT") {}

}

With our base contract set up correctly, our tests should pass:

$ forge test
Running 3 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_token_decimals() (gas: 5481)
[PASS] test_token_name() (gas: 9688)
[PASS] test_token_symbol() (gas: 9687)
Test result: ok. 3 passed; 0 failed; finished in 1.67ms

In order to issue tokens to winners, we'll need to expose the ability to mint new tokens. OpenZeppelin ERC20 provides an internal _mint function we can use for this purpose.

Let's start with a test. We'll add a public mint function to our contract that takes an address to credit with the newly created tokens and an amount of tokens to mint. In our test, we'll mint 100 tokens to the test contract, then call balanceOf on the token contract to check that we've received them:

    function test_mint_to_user() public {
        token.mint(address(this), 100 ether);
        assertEq(token.balanceOf(address(this)), 100 ether);
    }

Ether units

The keyword ether in the example above may look a little strange. ether is a globally available unit in Solidity that can apply to any numeric literal. 1 ether is equivalent to 1e18. This is the number of decimal places that denominate ETH, and the number of decimal places that denominate most ERC20 tokens, including ours in the example above. Using the 100 ether in the example above is shorthand for 100 * 1e18, or "100 tokens".

The units gwei and wei are also available keywords representing smaller denominations.

Let's add a public function that will call the internal _mint function from our base ERC20 contract. Just to make sure our tests are working, we'll leave it empty for now:

    function mint(address account, uint256 amount) public {
    }

Run the tests to verify:

$ forge test
Running 4 tests for src/test/Token.t.sol:TicTacTokenTest
[FAIL] test_mint_to_user() (gas: 19258)
Logs:
  Error: a == b not satisfied [uint]
    Expected: 100000000000000000000
      Actual: 0

[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
Test result: FAILED. 3 passed; 1 failed; finished in 539.42µs

Failed tests:
[FAIL] test_mint_to_user() (gas: 19258)

Encountered a total of 1 failing tests, 3 tests succeeded

Great! This is the failure we should expect: our account had a balance of zero tokens, but we expected 100. (Note the 18 decimal places in the expected amount!)

Let's update the public mint function to actually call the internal _mint function and pass through the arguments:

    function mint(address account, uint256 amount) public {
        _mint(account, amount);
    }

When we run the tests again, we'll see that our account has the expected balance:

$ forge test
Running 4 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 52968)
[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
Test result: ok. 4 passed; 0 failed; finished in 701.88µs

We now have a fully functional ERC20. It's not much use testing the built in behavior of our parent contract, but here's just one more test to prove the point. We'll transfer some of our tokens to another address and check the balances of both:

    function test_transfer_tokens() public {
        token.mint(address(this), 100 ether);
        token.transfer(address(42), 50 ether);

        assertEq(token.balanceOf(address(this)), 50 ether);
        assertEq(token.balanceOf(address(42)), 50 ether);
    }

Run our tests, and this should pass, since we've inherited this behavior from the base ERC20 contract.

$ forge test

Running 5 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 52968)
[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
[PASS] test_transfer_tokens() (gas: 79779)
Test result: ok. 5 passed; 0 failed; finished in 701.58µs

We're missing one more important thing: permissioning the mint function so that. Let's use OpenZeppelin Ownable to limit access to mint with the onlyOwner modifier. If we prank another address and try to mint, the call should revert:

    function test_non_owner_cannot_mint() public {
        vm.prank(address(42));
        vm.expectRevert("Ownable: caller is not the owner");
        token.mint(address(this), 100 ether);
    }

We can import Ownable like we did with ERC20, inherit it and use the onlyOwner modifier. Note that we don't need to pass any constructor arguments this time:

import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20, Ownable {

    constructor() ERC20("Tic Tac Token", "TTT") {}

    function mint(address account, uint256 amount) public onlyOwner {
        _mint(account, amount);
    }

}

With the modifier in place, our test should pass:

$ forge test

Running 6 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 55220)
[PASS] test_non_owner_cannot_mint() (gas: 13342)
[PASS] test_token_decimals() (gas: 5460)
[PASS] test_token_name() (gas: 9665)
[PASS] test_token_symbol() (gas: 9753)
[PASS] test_transfer_tokens() (gas: 81899)
Test result: ok. 6 passed; 0 failed; finished in 828.25µs

Awarding points with a token

Next, we need to actually use our new token from within our Tic Tac Toe game. Let's update our existing tests to call token.balanceOf rather than ttt.totalPoints. We'll also need to import our new Token.sol contract, set it up in our test setup block, and pass its address through to our TicTacToken contract:

import "../Token.sol";

contract TicTacTokenTest is DSTest {
    Token internal token;

    function setUp() public {
        token = new Token();
        ttt = new TicTacToken(OWNER, address(token));
        playerX = new User(PLAYER_X, ttt, vm);
        playerO = new User(PLAYER_O, ttt, vm);
        ttt.newGame(PLAYER_X, PLAYER_O);
    }

    function test_increments_win_count_on_win() public {
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 2);
        assertEq(token.balanceOf(PLAYER_X), 1);

        ttt.newGame(PLAYER_X, PLAYER_O);
        playerX.markSpace(1, 1);
        playerO.markSpace(1, 2);
        playerX.markSpace(1, 3);
        playerO.markSpace(1, 4);
        playerX.markSpace(1, 5);
        playerO.markSpace(1, 6);
        assertEq(token.balanceOf(PLAYER_O), 1);
    }

    function test_three_move_win_X() public {
        // x | x | x
        // o | o | .
        // . | . | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 2);
        assertEq(token.balanceOf(PLAYER_X), 300);
    }

    function test_three_move_win_O() public {
        // x | x | .
        // o | o | o
        // x | . | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 5);
        assertEq(token.balanceOf(PLAYER_O), 300);
    }

    function test_four_move_win_X() public {
        // x | x | x
        // o | o | .
        // x | o | .
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 2);
        assertEq(token.balanceOf(PLAYER_X), 200);
    }

    function test_four_move_win_O() public {
        // x | x | .
        // o | o | o
        // x | o | x
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 8);
        playerO.markSpace(0, 5);
        assertEq(token.balanceOf(PLAYER_O), 200);
    }

    function test_five_move_win_X() public {
        // x | x | x
        // o | o | x
        // x | o | o
        playerX.markSpace(0, 0);
        playerO.markSpace(0, 3);
        playerX.markSpace(0, 1);
        playerO.markSpace(0, 4);
        playerX.markSpace(0, 6);
        playerO.markSpace(0, 7);
        playerX.markSpace(0, 5);
        playerO.markSpace(0, 8);
        playerX.markSpace(0, 2);
        assertEq(token.balanceOf(PLAYER_X), 100);
    }

Let's first update the TicTacToken constructor to take the token address as a second argument:

    constructor(address _owner, address _token) {
        owner = _owner;
    }

Now our tests will fail as expected:

Failed tests:
[FAIL] test_five_move_win_X() (gas: 468223)
[FAIL] test_four_move_win_O() (gas: 435191)
[FAIL] test_four_move_win_X() (gas: 398714)
[FAIL] test_three_move_win_O() (gas: 365703)
[FAIL] test_three_move_win_X() (gas: 329183)

In order to mint tokens from our game contract, we'll need to make an external call to the Token contract. We can do so using an interface.

Since our Token contract conforms to the ERC20 interface, we can start with the IERC20 interface provided by OpenZeppelin. Let's define this at the top of our TicTacToken.sol contract:

import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";

interface IToken is IERC20 {}

However, since we've added our own mint function on top of the standard interface, we'll need to define it ourselves:

import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";

interface IToken is IERC20 {
    function mint(address account, uint256 amount) external;
}

Now we can create and store an instance of this interface from the token address. All together, it should look something like this:

import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
interface IToken is IERC20 {
    function mint(address account, uint256 amount) external;
}

contract TicTacToken {
    IToken internal token;

    constructor(address _owner, address _token) {
        owner = _owner;
        token = IToken(_token);
    }
}

Finally, we can call it to award points. Rather than updating the internal totalPoints mapping, we'll call token.mint with the winner address and number of points earned:

    function markSpace(uint256 id, uint256 space) public {
        require(_validPlayer(id), "Unauthorized");
        require(_validTurn(id), "Not your turn");
        require(_emptySpace(id, space), "Already marked");
        games[id].board[space] = _getSymbol(id, msg.sender);
        games[id].turns++;
        if(winner(id) != 0) {
            address winnerAddress = _getAddress(id, winner(id));
            totalWins[winnerAddress] += 1;
            token.mint(winnerAddress, _pointsEarned(id));
        }
    }

Run the tests...

Test result: FAILED. 20 passed; 13 failed; finished in 4.65ms

Failed tests:
[FAIL. Reason: Ownable: caller is not the owner] test_checks_for_antidiagonal_win() (gas: 340300)
[FAIL. Reason: Ownable: caller is not the owner] test_checks_for_diagonal_win() (gas: 303879)
[FAIL. Reason: Ownable: caller is not the owner] test_checks_for_horizontal_win() (gas: 296252)
[FAIL. Reason: Ownable: caller is not the owner] test_checks_for_horizontal_win_row2() (gas: 297474)
[FAIL. Reason: Ownable: caller is not the owner] test_checks_for_vertical_win() (gas: 335265)
[FAIL. Reason: Ownable: caller is not the owner] test_five_move_win_X() (gas: 435268)
[FAIL. Reason: Ownable: caller is not the owner] test_four_move_win_O() (gas: 402235)
[FAIL. Reason: Ownable: caller is not the owner] test_four_move_win_X() (gas: 365747)
[FAIL. Reason: Ownable: caller is not the owner] test_games_are_isolated() (gas: 335298)
[FAIL. Reason: Ownable: caller is not the owner] test_increments_win_count_on_win() (gas: 296185)
[FAIL. Reason: Ownable: caller is not the owner] test_reset_board() (gas: 297506)
[FAIL. Reason: Ownable: caller is not the owner] test_three_move_win_O() (gas: 332736)
[FAIL. Reason: Ownable: caller is not the owner] test_three_move_win_X() (gas: 296227)

Encountered a total of 13 failing tests, 26 tests succeeded

Eek! Our onlyOwner modifier is working as expected, but our tests are failing because our game contract is not the token owner. (By default, the owner of an Ownable contract is its deployer account address. In this case, that's our test contract.)

OpenZeppelin Ownable provides a transferOwnership method that we can call as part of our test setup to reassign ownership to our game contract:

    function setUp() public {
        token = new Token();
        ttt = new TicTacToken(OWNER, address(token));
        playerX = new User(PLAYER_X, ttt, vm);
        playerO = new User(PLAYER_O, ttt, vm);
        ttt.newGame(PLAYER_X, PLAYER_O);
        token.transferOwnership(address(ttt));
    }

With this change in place, our tests should now pass, and we can remove the now unused totalPoints mapping:

Running 33 tests for src/test/TicTacToken.t.sol:TicTacTokenTest
[PASS] test_auth_nonplayer_cannot_mark_space() (gas: 15194)
[PASS] test_auth_playerO_can_mark_space() (gas: 121029)
[PASS] test_auth_playerX_can_mark_space() (gas: 84760)
[PASS] test_can_mark_space_with_O() (gas: 145822)
[PASS] test_can_mark_space_with_X() (gas: 98209)
[PASS] test_cannot_overwrite_marked_space() (gas: 110965)
[PASS] test_checks_for_antidiagonal_win() (gas: 399654)
[PASS] test_checks_for_diagonal_win() (gas: 362891)
[PASS] test_checks_for_horizontal_win() (gas: 353368)
[PASS] test_checks_for_horizontal_win_row2() (gas: 354906)
[PASS] test_checks_for_vertical_win() (gas: 393355)
[PASS] test_contract_owner() (gas: 7785)
[PASS] test_creates_new_game() (gas: 59164)
[PASS] test_draw_returns_no_winner() (gas: 361524)
[PASS] test_empty_board_returns_no_winner() (gas: 33955)
[PASS] test_five_move_win_X() (gas: 484602)
[PASS] test_four_move_win_O() (gas: 451570)
[PASS] test_four_move_win_X() (gas: 415093)
[PASS] test_game_in_progress_returns_no_winner() (gas: 105331)
[PASS] test_games_are_isolated() (gas: 746305)
[PASS] test_get_board() (gas: 29384)
[PASS] test_increments_win_count_on_win() (gas: 725272)
[PASS] test_non_owner_cannot_reset_board() (gas: 12844)
[PASS] test_owner_can_reset_board() (gas: 33137)
[PASS] test_playerO_win_count_starts_at_zero() (gas: 7800)
[PASS] test_playerX_win_count_starts_at_zero() (gas: 7849)
[PASS] test_reset_board() (gas: 283243)
[PASS] test_stores_player_O() (gas: 12342)
[PASS] test_stores_player_X() (gas: 12341)
[PASS] test_symbols_must_alternate() (gas: 97712)
[PASS] test_three_move_win_O() (gas: 382082)
[PASS] test_three_move_win_X() (gas: 345562)
[PASS] test_tracks_current_turn() (gas: 145010)
Test result: ok. 33 passed; 0 failed; finished in 5.49ms

We've created an ERC20 token, assigned ownership to our game, and used it to award points to the winner.

đź–Ľ NFTs

Introducing NFTs

Last week we added support for multiple simultaneous games, built out a leaderboard to track wins, and created our own ERC20 token to award to winners. This week, we'll explore another smart contract standard: ERC721, the Ethereum standard for non-fungible tokens, or NFTs. Over the course of this chapter, we'll create and issue a unique token to each player, then add a dynamic SVG image that shows the current state of the game board.

Goals this week

  • Learn about the ERC721 nonfungible token standard.
  • Understand off chain vs. on chain metadata.
  • Create and issue an ERC721 token to the players in each game.

Suggested homework

Nonfungible tokens

NFTs are in the zeitgeist right now: perhaps you've seen punks, apes and penguins taking over Twitter profiles, or watched a dog JPEG sell for $4 million. By the end of this chapter, you'll understand how NFTs work—and we'll shoehorn them into our Tic-Tac-Toe game.

What are nonfungible tokens?

Unlike fungible ERC20s, NFTs represent unique entities. They are still tokens—ownable, transferrable assets whose ownership is recorded on a digital ledger—but they aren't interchangeable or divisible like ERC20s. A nonfungible token has unique characteristics that mean one token is not the same as another. Like all tokens, they are an abstraction that can be used to represent many things:

  • Collectibles, like Cryptopunks
  • Artwork, like Art Blocks
  • Ownership over real world assets, like a digital deed
  • Obligations, like loans
  • Voting rights or governance power
  • Skills and reputation, like lvl protocol
  • Domain names, like ENS domains
  • Unique instances of a Tic Tac Toe game.

Although there's a lot of hype in the NFT market right now, they are a composable building block that can be much more than a monkey JPEG.

The ERC721 standard

Like their ERC20 predecessors, ERC721 tokens are based on a pretty simple, human readable smart contract standard.

Essentially, an ERC721 token is a unique ID associated with an address, plus an optional blob of metadata describing the token. Like ERC20s, users may transfer ERC721s to other adresses directly or by approving third parties to move tokens on their behalf. As the EIP number indicates, ERC721 was created a few years after ERC20, and some of the design decisions in the standard are reactions to some of the shortcomings of the ERC20 standard.

Here's the core ERC721 interface:

function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function transferFrom(
    address _from,
    address _to,
    uint256 _tokenId
) external payable;
function safeTransferFrom(
    address _from,
    address _to,
    uint256 _tokenId,
    bytes data
) external payable;
function safeTransferFrom(
    address _from,
    address _to,
    uint256 _tokenId
) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
function getApproved(uint256 _tokenId) external view returns (address);
function setApprovalForAll(address _operator, bool _approved) external;
function isApprovedForAll(
    address _owner,
    address _operator
) external view returns (bool);

You’ll notice some familiar function names shared by ERC20, like balanceOf, transferFrom, and approve. Rather than working with fungible amounts, each of these functions takes a unique tokenId as an argument, or returns a unique ID as a value:

function balanceOf(address _owner) external view returns (uint256);
function transferFrom(
    address _from,
    address _to,
    uint256 _tokenId
) external payable;
function approve(address _approved, uint256 _tokenId) external payable;

Other functions are responses to the lessons learned from ERC20. The safeTransferFrom method must first check if the recipient address supports a transfer before sending a token, by calling a special onERC721Received function on the receiver address, if it is a contract. (This enhancement was inspired by the many ERC20 tokens that have been permanently locked in contract addresses without any means to recover them). getApproved, setApprovalForAll, and isApprovedForAll adapt and extend the ERC20 concept of allowances, enabling users to allow a third party to spend all tokens and adding the ability to query approved addresses.

function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
function getApproved(uint256 _tokenId) external view returns (address);
function setApprovalForAll(address _operator, bool _approved) external;
function isApprovedForAll(address _owner, address _operator) external view returns (bool);

Finally, one function unique to ERC721, which returns the owner of a specific token by ID:

function ownerOf(uint256 _tokenId) external view returns (address);

In addition to this core standard, ERC721 tokens may include an optional metadata interface that should look familiar. (And although this is technically optional, pretty much every token does in practice).

function name() external view returns (string _name);
function symbol() external view returns (string _symbol);
function tokenURI(uint256 _tokenId) external view returns (string);

The most interesting thing here is the tokenURI function: this returns a URI that should point to JSON data including a name, description, and image. The standard includes a short JSON schema to describe this metadata:

{
    "title": "Asset Metadata",
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "Identifies the asset to which this NFT represents"
        },
        "description": {
            "type": "string",
            "description": "Describes the asset to which this NFT represents"
        },
        "image": {
            "type": "string",
            "description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
        }
    }
}

Exactly where and how to store this metadata is left unspecified in the spec. In practice, there are many ways to store token metadata, with different tradeoffs around durability and decentralization:

  • Returning metadata from a central API (centralized, less durable)
  • Returning metadata from a storage provider like S3 (centralized, more durable)
  • Storing metadata on a decentralized storage network like Filecoin or Arweave (more decentralized, potentially less durable)
  • Storing metadata on a decentralized file system like IPFS (more decentralized, more low level)
  • Storing metadata directly in contract storage using data URIs (most decentralized, most durable, potentially very expensive)

Like ERC20s, the core behavior in ERC721 is mostly tracking balances by address. As we'll see much, of the interesting stuff lives in the token metadata.

Exploring a token

Reading the interface is all pretty abstract and boring. Let's look at a real token instead. This is Cotopaxi Squall, a Crypto Coven witch:

Cotopaxi Squall

This token has ID #4493, indicated next to her name. However, the rest of the rich, interesting information about Cotopaxi comes from her metadata: a name, description, unique image composed of multiple layers and attributes, archetype, RPG-style stats, and even an astrological chart!

Let's work our way back to the contract and its metadata. Here's the Crypto Coven contract on Etherscan.

Creating an NFT

We've read the ERC721 spec and explored a real world contract. Now let's add an NFT as a component of our game. We haven't created a frontend for our Tic Tac Toe game yet, but we can use an NFT as a primitive UI. We can issue a token to each player at the start of every new game, and use dynamic metadata to display the state of the game board as an image. Players will be able to view their tokens in a wallet or on an NFT marketplace to see the current state of their ongoing games.

As always, let's start with the tests. We'll create a new test/NFT.t.sol, with a couple tests for our new token's name and symbol. Since the interface is similar, this should look pretty close to the tests for our ERC20:

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

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

import "../NFT.sol";

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

    NFT internal nft;

    function setUp() public {
        nft = new NFT();
    }

    function test_token_name() public {
        assertEq(nft.name(), "Tic Tac Token NFT");
    }

    function test_token_symbol() public {
        assertEq(nft.symbol(), "TTT NFT");
    }

}

We'll also create an empty NFT.sol:

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

contract NFT {}

Just like our ERC20, we'll need to import and inherit from the OpenZeppelin ERC721 base contract that provides these metadata functions:

$ forge test
Error:
   0: Compiler run failed
      TypeError: Member "name" not found or not visible after argument-dependent lookup in contract NFT.
        --> /Users/ecm/Projects/ttt-book-code/src/test/NFT.t.sol:19:18:
         |
      19 |         assertEq(nft.name(), "Tic Tac Token NFT");
         |                  ^^^^^^^^

Let's import the ERC721 base contract and pass through the name and symbol constructor args:

import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";

contract NFT is ERC721 {

    constructor() ERC721("Tic Tac Token NFT", "TTT NFT") {}

}

Now we're all set to start building our new token:

$ forge test --match-path src/test/NFT.t.sol
Compiler run successful

Running 2 tests for src/test/NFT.t.sol:TicTacTokenTest
[PASS] test_token_name() (gas: 9745)
[PASS] test_token_symbol() (gas: 9753)
Test result: ok. 2 passed; 0 failed; finished in 1.67ms

Filtering tests

Our test suite is getting pretty big, and we may not want to run all the tests all the time. Forge provides a few command line flags to filter test runs: --match-test will run only test functions matching a specified regex pattern, --match-contract will run only tests in contracts that match a given regex, and --match-path will only run tests from files that match a given glob pattern.

Ownership and minting

Just like our ERC20, we'll need to add the ability to mint new tokens and limit access to authorized callers.

Let's start by adding a mint function. We'll mint token #1 to the test contract address, then call two ERC721 methods to make sure it was created. balanceOf should return the total number of tokens held by the test contract, while ownerOf(1) should return the test contract address:

    function test_token_is_mintable() public {
        nft.mint(address(this), 1);

        assertEq(nft.balanceOf(address(this)), 1);
        assertEq(nft.ownerOf(1), address(this));
    }

Let's add a mint function to our NFT contract to pass this test. We can use the internal _safeMint function from OpenZeppelin ERC721 to mint a token with ID tokenId and transfer it to address to:

    function mint(address to, uint256 tokenId) public {
      _safeMint(to, tokenId);
    }

Safe mints and transfers

ERC721 includes both transferFrom and safeTransferFrom functions in its public interface, and OpenZeppelin's ERC721 base contract includes internal _mint and _safeMint functions.

The "safe" versions of these functions were introduced in reaction to a common usability problem with ERC20 tokens: users accidentally transferring tokens into smart contract addresses that have no mechanism for transferring them back out.

To prevent the same issue with ERC721 tokens, the standard requires that safeTransfer must call a special onERC721Received function on the receiver address if it is a contract. If the receiver returns a magic 4 byte value from this function, it signals that it is able to receive ERC721 tokens. You can think of this as a callback before the token is transferred.

Although this is safer when it comes to preventing accidental transfers, any call to an external contract creates a potential attack vector for reentrancy and other malicious behavior. Many NFT contracts have introduced vulnerabilities by using safeTransfer without protecting against reentrancy. We'll cover reentrancy in more depth later, but for now, be aware that safeTransfer and _safeMint are not always as safe as you might think!

We've added a mint function, now let's run the tests:

$ forge test --match-path src/test/NFT.t.sol
Compiler run successful

Running 3 tests for src/test/NFT.t.sol:TicTacTokenTest
[FAIL. Reason: ERC721: transfer to non ERC721Receiver implementer] test_token_is_mintable() (gas: 53501)
[PASS] test_token_name() (gas: 9801)
[PASS] test_token_symbol() (gas: 9775)
Test result: FAILED. 2 passed; 1 failed; finished in 2.08ms

Failed tests:
[FAIL. Reason: ERC721: transfer to non ERC721Receiver implementer] test_token_is_mintable() (gas: 53501)

Our tests failed, but for a good reason: _safeMint is working as it should! It made a callback to our test contract and reverted the transfer since it doesn't expose an onERC721Received function.

Let's signal that our test contract supports ERC721 transfers by implementing onERC721Received. We need to implement the interface described here, by adding a function to our test contract. We'll ignore all the arguments and simply return the onERC721Received function selector, which is the 4 byte magic value required by the spec.

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) public returns (bytes4) {
        return this.onERC721Received.selector;
    }

Once we've made this change, our tests should pass:

Running 3 tests for src/test/NFT.t.sol:TicTacTokenTest
[PASS] test_token_is_mintable() (gas: 56503)
[PASS] test_token_name() (gas: 9779)
[PASS] test_token_symbol() (gas: 9753)
Test result: ok. 3 passed; 0 failed; finished in 4.31ms

We added our own onERC721Received, but OpenZeppelin includes an ERC721Holder helper contract that provides this method. Let's use it instead and remove our custom function. All we need to do is import it and inherit in our test contract:

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

import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol";
import "../NFT.sol";

contract TicTacTokenTest is DSTest, ERC721Holder {
    ...
}

Finally, let's restrict minting to authorized addresses. Once again, we'll use Ownable:

    function test_token_is_not_mintable_by_nonowner() public {
        vm.prank(address(1));
        vm.expectRevert("Ownable: caller is not the owner");
        nft.mint(address(this), 1);
    }

Let's import and add Ownable to our NFT contract:

import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import "openzeppelin-contracts/contracts/access/Ownable.sol";

contract NFT is ERC721, Ownable {

    constructor() ERC721("Tic Tac Token NFT", "TTT NFT") {}

    function mint(address to, uint256 tokenId) onlyOwner public {
      _safeMint(to, tokenId);
    }

}

And make sure it works as expected:

$ forge test --match-path src/test/NFT.t.sol
Running 4 tests for src/test/NFT.t.sol:TicTacTokenTest
[PASS] test_token_is_mintable() (gas: 58902)
[PASS] test_token_is_not_mintable_by_nonowner() (gas: 13279)
[PASS] test_token_name() (gas: 9807)
[PASS] test_token_symbol() (gas: 9803)
Test result: ok. 4 passed; 0 failed; finished in 1.15ms

The framework for our NFT is complete. Next up, we'll integrate it with the game.