(photo by @micheile)
Snapps are zero-knowledge smart contracts that will launch on Mina next year. You can learn more about them here. This tutorial teaches you how to write a tic-tac-toe game using snarkyjs, the official library to write snapps on Mina.
You can quickly create a project by using the Snapp CLI:
you can also follow along this tutorial with the following command:
To write a smart contract, import what you need from snarkyjs and simply extend the SmartContract
class.
A zero-knowledge smart contract, or snapp, is very close in concept to the ones you see on Ethereum:
Let's start with the state.
Snapps, at the lowest level, only understand field elements, of type Field
. Field
is a type similar to Ethereum's u256
except that it is a bit smaller. Essentially it's a number between 0 and 28948022309329048855892746252171976963363056481941560715954676764349967630336. Every other type has to be derived from Field
, and we provide a number of such helpful types in snarky. (You can also create your own types but we won't use that here.)
First, a tic-tac-toe game needs to track the state of the game. The board.
The state of a Snapp can only hold 8 field elements. This is not set in stone, but the more storage a snapp can contain, and the harder it becomes to participate in consensus (and thus the less decentralized the network becomes). Snapps that need to handle large state can do so via Merkle trees, but I won't be talking about that here.
A tic-tac-toe board is made of 9 tiles, that's one too many :( so what can we do about it? Well as I said, a field element is a pretty big number, so let's just encode our board into one:
We'll also need to keep track of who's turn is it, and if the game is finished (someone has won):
Notice that the public keys of the players are not decorated with @state
. This is because we don't need to store that information on chain, we can simply hardcode it in the snapp. If you wanted to be able to start a new game with new players, you would store these on chain so that you could mutate them via a method of the snapp. But we're keeping it simple.
Also, PublicKey
and Bool
are two types provided by the standard library and built on top of Field
. Bool
is built on top of a single field element, so in total our on-chain state has 3 field elements. That'll work!
Next, we must initialize that state in a constructor. let's look at the code:
The constructor does two things that might look weird. First, it takes an address
as argument, this is the address where the snapp will be deployed (this might disappear in future versions of snarky). Second, the constructor takes an initialBalance
, which might be needed to pay the account creation fee (if you haven't already). The rest should be pretty straight forward.
We really only need one method: a method to play. We can create one with the @method
decorator:
The method takes the player's public key, two coordinates, and a signature (to make sure that they own the public key) over the coordinates.
The board can be visualized as such:
y\x | 0 | 1 | 2 |
---|---|---|---|
0 | O | ||
1 | X | X | |
2 |
Let's look at the logic that we will have to implement:
Does that make sense? In the rest of this tutorial I go over every step
if the game is already finished, abort.
To do this, we have to read the state of the snapp with get
. When you execute the program locally, this will go and fetch the state of the snapp from the blockchain (hence the asynchronous call with await
). Your execution will then only be deemed valid by the network if they see the same state (in this case the value of gameDone
) on their side.
Using assert functions like [assertEquals()
] is the natural way to abort the program if it takes the wrong path. Under the hood, what happens is that the prover won't be able to satisfy the circuit and create a proof if the assert is triggered.
ensure that we know the private key associated to the public key and that our public key is known to the snapp
First, let's verify that the public key is one of the hardcoded key:
Next, let's verify that the player truly owns the private key associated with that public key by verifying a signature over the coordinates.
Make sure that it's our turn, and set the state for the next player
Earlier, we encoded a player as a boolean, with false
describing the first player and true
the second one. So let's derive this first.
In zero-knowledge proofs, we can't have branches with too much logic. The program pretty much always has to do the same thing. That being said, what we can do is to assign a different value to a variable depending on a condition. For example, this is how we can get the boolean that describes us as a player:
Circuit.if()
takes three arguments, the first one is a condition that must be a Bool
type, the other two are the returned values depending on if the condition is true or false.
Now that we have the player
value, we can use it to check if it our turn:
And we can update the state for the next player via the set()
function:
get and deserialize the board
There's many strategies here, but a simple one is to deserialize the field element into bits (via toBits()
) and interpret these bits as the board.
We can simply flatten the board like this:
Yet, this is not enough. A bit can only represent two states, and we have three: empty, false (player 1), true (player 2).
So we'll use two lists of size 9 instead:
As you can see, it is important to track if a tile is empty or not, because in the first list 0 represents both the empty tile and a move from the player 1.
To make the rest of the logic easier to read, I'm going to use the Optional
type to encode the [not_empty, player]
array. It looks like this:
It is simply a Bool
that describes if it's empty (no one has played that tile yet), and if it's not empty the value associated with it (a cross or a circle, encoded as the player
Bool
).
Here's what the full deserialization code looks like:
update the board (and the state) with our move
Before we do anything, we need to check that the coordinates are valid:
Now here comes the trickiest part. Zero-knowledge smart contracts have some constraints: they don't allow you to dynamically index into an array, so you can't do this:
instead, you have to go through every tile, and check if it's the one to update. (Note that you can't have dynamic for loops as well, they need to be of constant size).
And you can use the same tricks to make sure you can play on the tile, and to update the board:
Finally, we can serialize and store this update into the snapp's state:
did I just win? If so, update the state as well
Finally, we can check if someone won by checking all rows, columns, and diagonals. This part is a bit tedious and boring, so here's the code:
The full code is available here if you want to play with it.