ZK Poker — A Simple ZK-SNARK Circuit

Zero-knowledge proofs are an important tool for us in decentralising ourselves. How do we maintain privacy when our platform stores data that’s globally transparent? What about scaling solutions that do work ‘off-chain’, which then needs to be confirmed by on-chain evidence? Zero-knowledge proofs play their part in solving problems such as these.

When it comes to writing code for ZK circuits, we have a number of libraries to choose from. I discovered circom and snarkjs from the team at iden3. It’s a good choice for newcomers to this area: circuits are easy to code, and they run with easy commands that don’t require exotic environments.

The tutorial that’s provided for learning circom and snarkjs is really helpful. I found it very satisfying, and a great educational experience, to see the process unfold in front of you. This article aims to lead you a step beyond that introductory tutorial, and to explore a few additional aspects of circom. It’s based on a simple, modified version of poker in which a zero-knowledge proof plays a part.

ZK Poker

The rules of ZK Poker are a little simpler than other varieties of the game.

  • Players are dealt 5 cards,
  • No card exchanges are allowed.
  • When ranking hands, only pairs count. Straights, flushes, full houses, and so on are ignored.
  • Options during a player’s turn are to fold, to see, or to raise.
  • Players must not bid when their hand does not contain a pair.

That last rule — no bluffing- is the one that’s to be enforced by the ZK proof. The bidder can avoid having his or her cards disclosed, while still proving to other players that they are obeying the no bluff rule. We’ll ignore the mechanics of the game (evaluating the winner, assessing bids, etc) and simply concentrate on the circuit that will create the ZKP.

The Circuit

A rough outline of the circuit is:

  • Collect inputs: the hand, and the player’s bid option
  • Look for at least one pair in the hand.
  • Evaluate the kind of bid (fold or play)
  • Set up a constraint to check that a choice has been made
  • Set up a constraint to check that the choice valid given the presence or otherwise of a pair.

Let’s walk through the circuit code a few lines at a time, and see what we can learn at each point.

Includes

It starts with some include specifications:

include “../node_modules/circomlib/circuits/gates.circom”;
include “../node_modules/circomlib/circuits/comparators.circom”;

I should point out that my folder structure is:

<project root>\poker

So the include path I’ve specified works for this structure. You’ll need to install circomlib, which is an additional requirement to the introductory circom/snarkjs tutorial. Use this command, from the project root folder:

npm install --save circomlib

Circuit Template

template Poker() {
   ... circuit body ...
}
component main = Poker();

The structure is much the same as in the introductory tutorial. It’s a single class, and the entry point is declared. Nice and simple.

Declaring Inputs, Outputs, and Intermediate Results

signal private input cards[5]; // Each 2..14
signal input isSee; // 1 or 0
signal input isFold; // 1 or 0
signal input raise; // int
signal output out; // 1 or 0

// Intermediate results
signal isBid;
signal isRaise;
signal hasChosen;

The hand of cards is declared as an array: cards[5]. Cards are simply represented by their face value. Suits are ignored. Ace =14; King=13; Queen=12; Jack=11; and so on, down to 2. The hand is a private input, indicating that it must remain a secret.

The bidding choice, of course, is public, since it will be stated openly to the other players. The fold and see choices are represented as booleans. Note that this is not (and can not be) explicitly declared. Circom is based on javascript, hence variable types are inferred from the data. (I should have issued a trigger warning for fans of robust data typing.) The raise input will be an integer, being the actual amount of the raise.

The only output is out, and will take a value of 1 for valid or 0 for not valid.

Some intermediate values are declared as signals. We’ll see them in action later.

Counting the Pairs

This block of code determines whether the hand contains a pair. Note that the code is a lot like plain javascript. It doesn’t contain any constraint declarations, nor any manipulation of signals.

There is an insight here into the way circuits work. A real-life circuit might involve some pre-processing or intermediate evaluation, but beware: such code is not part of the formal proof. Only the constraints are baked in at setup time, and confirmed via the proof/verify cycle. A dishonest prover might subvert the intermediate results to falsify the witness. I really should update the code so that constraints are build in to the pair evaluation.

As it stands, the pre-processing step results in a single variable, numPairs. That’s going to be used in subsequent steps.

The logic here is a nested loop that consider each card in turn, and compares each subsequent card to see if we have a pair. As soon as a pair is found, we add to numPairs and exit. We’re not interested in whether it’s actually part of a 3- or 4-of-a-kind, nor whether there’s a second pair. All of these possibilities count as a pair for the purpose of the constraint that we’ll evaluate. Also note that break doesn’t seem to be implemented in this trimmed-down version of javascript. This code simply triggers an exit by forcing the iterators high.

 // Count pairs
var numPairs = 0;
for (var i=0; i<4; i++) {
for (var j=i+1; j<5; j++) {
if (cards[i] == cards[j]) {
numPairs++;
// break doesn’t work. Just force j and i to exit
j = 5;
i = 5;
}
}
}

Constraints

The introductory tutorial covers signal operations: <--, -->, <==, ==> , and === . What’s new in this block of code is components. A component is an instance of once of the classes imported, in this case, from the circomlib library. Note the way these are invoked. The input signals to the component (named a, b, in, etc.) are assigned using signal operators. The component’s output signal (often named out) can be similarly used.

Here, we’re using components for boolean operators. Check out the circomlib code to see what’s available. There’s a comprehensive library of functions, such as hashing operations, elliptic curve operations, and more.

And so, our constraints are defined. There’s a constraint to check that a choice has been made (fold, see, or raise). There’s another to check that either a pair is present or fold has been chosen. And finally, the out signal is set to 1.

 // isRaise = (raise != 0)
isRaise <-- (raise > 0);
isBid <-- (isRaise || isSee);
 // Constraint: Must be either bid or fold: isBid XOR isFold = 1
hasChosen <-- isBid + isFold — 2*isBid*isFold;
hasChosen === 1;
 // Constraint: numPairs must be > 0 if isBid = 1
var hasPairs = (numPairs > 0);
component not3 = NOT();
not3.in <-- isBid;
 component or2 = OR();
or2.a <-- hasPairs;
or2.b <-- not3.out;
or2.out === 1;
 out <-- or2.out;

Running the Proof

The initial steps will be familiar from the introductory tutorial:

circom poker.circom -o poker.json
snarkjs setup -o poker.json

The next step requires the inputs to be provided. Let’s take a look at a sample input file (input.json):

{“cards”: [8, 7, 4, 7, 13], “isFold”: 0, “isSee”: 0, “raise”: 10 }

The hand has a pair, so all bidding options are available. The player has chosen to raise by 10. We’ll expect this input to be passed as valid. The hand remains private, while the other inputs are public. I’ve glossed over the possibility of the player entering a false hand. Perhaps it could be handled by hashing the hand, making the hash public, thus allowing for confirmation at the end of the hand. In a real-life zero knowledge proof, all such aspects of the procedure, including the circuit code and the setup, need to be agreed in advance by both the prover and the verifier. The verifier would insist on removing any possibility that would allow the prover to falsify their hand.

Let’s go through the next steps, to generate the proof:

snarkjs calculatewitness -c poker.json
snarkjs proof

Proof generation performance will run in O(n) time, where n=number of constraints. We have too few constraints in this example to see this happening. See here for a test with many more constraints.

And now the verification, run by the verifier:

PS C:\dev\snarks\poker> snarkjs verify
OK

… and it works!

With a well-designed ZK-SNARK, the chain of trust goes back to the setup. There’s only one thing the prover can do to get that ‘OK’ result from the verify step, and that’s to create a witness that satisfies all the constraints.

I hope you find this useful.

The Complete Circuit

include “../node_modules/circomlib/circuits/gates.circom”;
include “../node_modules/circomlib/circuits/comparators.circom”;
template Poker() {
signal private input cards[5]; // Each 2..14
signal input isSee; // 1 or 0
signal input raise; // int
signal input isFold; // 1 or 0
signal output out; // 1 or 0
// Intermediate results
signal isBid;
signal isRaise;
signal hasChosen;
 // Count pairs
var numPairs = 0;
for (var i=0; i<4; i++) {
for (var j=i+1; j<5; j++) {
if (cards[i] == cards[j]) {
numPairs++;
// break doesn’t work. Just force j and i to exit
j = 5;
i = 5;
}
}
}
// isRaise = (raise != 0)
isRaise <--(raise > 0);
isBid <--(isRaise || isSee);
// Constraint: Must be either bid or fold: isBid XOR isFold = 1
hasChosen <--isBid + isFold — 2*isBid*isFold;
hasChosen === 1;
 // Constraint: numPairs must be > 0 if isBid = 1
var hasPairs = (numPairs > 0);
component not3 = NOT();
not3.in <-- isBid;
 component or2 = OR();
or2.a <-- hasPairs;
or2.b <-- not3.out;
or2.out === 1;
 out <-- or2.out;
}
component main = Poker();

You can find the code in github