This tutorial will contain information about the generated code. this is the continuation of the previous tutorial, niftyzk tutorial 1. You should read that one first.
So let’s scaffold a new project using niftyzk init
we will select a commit-reveal scheme with poseidon hash and add 2 inputs for tamper proofing, address and amount.
$ niftyzk init
Setting up your current directory
? What project do you want to scaffold? Commit-Reveal Scheme
? Choose the hashing algorithm to use: poseidon
? Do you wish to add tamperproof public inputs? (E.g: walletaddress): yes
? Enter the name of the public inputs in a comma separated list (no numbers or special characters): address,amount
Generating circuits
Generating javascript
Done
Run npm install in your project folder
The circuits directory
Navigate to the /circuits/
directory to see the generated code. It should contain 2 files, circuits.circom
which is the entry point and commitment_hasher.circom
which contains the hashing implementation.
circuits.circom
pragma circom 2.0.0;
include "./commitment_hasher.circom";
template CommitmentRevealScheme(){
// Public inputs
signal input nullifierHash;
signal input commitmentHash;
signal input address;
signal input amount;
// private inputs
signal input nullifier;
signal input secret;
// Hidden signals to validate inputs so they can't be tampared with
signal addressSquare;
signal amountSquare;
component commitmentHasher = CommitmentHasher();
commitmentHasher.nullifier <== nullifier;
commitmentHasher.secret <== secret;
// Check if the nullifierHash and commitment are valid
commitmentHasher.nullifierHash === nullifierHash;
commitmentHasher.commitment === commitmentHash;
// An extra operation with the public signal to avoid tampering
addressSquare <== address * address;
amountSquare <== amount * amount;
}
component main {public [nullifierHash,commitmentHash,address,amount]} = CommitmentRevealScheme();
Let’s see from top to bottom what’s going on.
First, it’s using circom 2.0.0 and imports the commitment_hasher.circom template
Next, as you can see on the bottom we have 4 public inputs, these inputs will be exposed to the blockchain when verifying a circuit there. nullifierHash
is used for nullification inside the contract, you can use this variable to make sure the proof is not used twice. commitmentHash
is the hash used for the commit-reveal scheme. We prove we know the preimage of this hash with the proof. address
and amount
are extra public inputs which are added into the circuit. the creator of the proof can add these inputs and submit the proof to the blockchain. A malicious relayer who acquires the proof is unable to modify these inputs if they are made tamper proof as you will see soon.
The private inputs are secret
and nullifier
, both must be kept private. They are used for nullification and the commit reveal scheme.
Hidden signals make sure our extra public inputs are unalterable after the proof is created.We create the commitment hasher and assert that the output includes the public inputs. That’s it. The commitment hasher looks like:
pragma circom 2.0.0;
include "../node_modules/circomlib/circuits/poseidon.circom";
template CommitmentHasher(){
signal input nullifier;
signal input secret;
signal output commitment;
signal output nullifierHash;
component commitmentPoseidon = Poseidon(2);
commitmentPoseidon.inputs[0] <== nullifier;
commitmentPoseidon.inputs[1] <== secret;
commitment <== commitmentPoseidon.out;
component nullifierPoseidon = Poseidon(1);
nullifierPoseidon.inputs[0] <== nullifier;
nullifierHash <== nullifierPoseidon.out;
}
As you can see it combines the inputs and creates new outputs. the commitmentHash is linked to the nullifier, so it’s verifiable that they are related.You could use the commitmentHash for nullification, or keep it like this, it’s up to you. It was designed like this because if you want to have reusable commitments you can add an extra input nonce
to the nullifierHash but keep the commitment as is. That way the owner of the secret can create multiple proofs using the same commitment with a slightly different nullification strategy. It’s useful for account abstraction if you want the account to be reusable.
Javascript code
First we look at the library that was scaffolded and then the tests.
The project depends on ffjavascript
,snarkjs
,circomlib
,circomlibjs
,circom_tester
lib/index.js
contains the source code for the client side code.
First you will the functions for generating circuit inputs:
/**
* @returns {bigint} Returns a random bigint
*/
export function rbigint() { return utils.leBuff2int(crypto.randomBytes(31)) };
For the inputs, 31 byte sized bigint is used. The reason for 31 bytes is because if we use random 32 bytes it can be higher than the snark scalar field
21888242871839275222246405745257275088548364400416034343698204186575808495617
and that would make our circuits fail. So 31 bytes for max input. Next you see the hashing implementation which depends on what was selected and the computeProof
and verifyProof
functions that call snarkjs
test/input.js
Creating the input for a circuit is very important part of development. This file will be called when the unit tests run and also during hot reload.
import { rbigint, generateCommitmentHash, generateNullifierHash,buildHashImplementation } from "../lib/index.js";
/**
* This is a test input, generated for the starting circuit.
* If you update the inputs, you need to update this function to match it.
*/
export async function getInput(){
await buildHashImplementation();
const secret = rbigint();
const nullifier = rbigint();
let address = rbigint();
let amount = rbigint();
const commitmentHash = await generateCommitmentHash(nullifier, secret);
const nullifierHash = await generateNullifierHash(nullifier);
return {secret, nullifier, nullifierHash,commitmentHash,address,amount}
}
// Assert the output for hotreload by returning the expected output
// Edit this to fit your circuit
export async function getOutput() {
return { out: 0 }
}
The getInput()
function defines our circuit input and the getOutput
function is used for output assertion when running niftyzk dev
Let’s see what’s going on. First we build the hash implementation. It’s because all implementations are wasm and need to be built first. The built wasm is cached for reuse.
Then we assign random numbers for inputs, compute the hashes and return them. The return values are a mix of private and public inputs and they are fed to the circuit directly.
The current circuit template doesn’t have output signals, so the getOutput
function is not implemented. You should return values you expect here after changing your circuit.
Merkle tree
Now let’s take a look at a commit-reveal scheme with a merkle tree
niftyzk init
will can rebuild the current project into a new one.
Setting up your current directory
? What project do you want to scaffold? Commit-Reveal Scheme with Fixed Merkle Tree
? Choose the hashing algorithm to use: poseidon
? Do you wish to add tamperproof public inputs? (E.g: walletaddress): yes
? Enter the name of the public inputs in a comma separated list (no numbers or special characters): address,amount
Let’s set up one with poseidon hash and merkle tree
You will see that the commitment_hasher.circom
file stayed the same but the circuits.circom
changed and there is a new merkletree.circom
file.
include "./commitment_hasher.circom";
include "./merkletree.circom";
template CommitmentRevealScheme(levels){
// Public inputs
signal input nullifierHash;
signal input commitmentHash;
signal input address;
signal input amount;
signal input root;
signal input pathElements[levels]; // The merkle proof which is fixed size, pathElements contains the hashes
signal input pathIndices[levels]; // Indices encode if we hash left or right
// private inputs
signal input nullifier;
signal input secret;
// Hidden signals to validate inputs so they can't be tampared with
signal addressSquare;
signal amountSquare;
component commitmentHasher = CommitmentHasher();
commitmentHasher.nullifier <== nullifier;
commitmentHasher.secret <== secret;
// Check if the nullifierHash and commitment are valid
commitmentHasher.nullifierHash === nullifierHash;
commitmentHasher.commitment === commitmentHash;
// An extra operation with the public signal to avoid tampering
addressSquare <== address * address;
amountSquare <== amount * amount;
// Check if the merkle root contains the commitmentHash!
component tree = MerkleTreeChecker(levels);
tree.leaf <== commitmentHasher.commitment;
tree.root <== root;
for (var i = 0; i < levels; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
}
component main {public [nullifierHash,commitmentHash,root,address,amount]} = CommitmentRevealScheme(20);
So quite a few things changed. We got new public and private inputs and new templates.
So first we have the merkle tree root
as a new public input and pathElements
and pathIndices
, these contain the merkle proof. The levels
variable taken specifies the size of the merkle tree, the default 20 will give a lot of branches to work with.
The new template we use is the MerkleTreeChecker
and we just assign the signals to it’s inputs. The MerkleTreeChecker will assert correctness. It’s assumed that the commitment is one of the leaves and the merkle proof is used for proving it. The pathElements
contain the leaves and the pathIndices
specify if we hash left or right.
Let’s see the other file:
pragma circom 2.0.0;
include "../node_modules/circomlib/circuits/poseidon.circom";
template HashLeftRight(){
signal input left;
signal input right;
signal output hash;
component poseidonHash = Poseidon(2);
poseidonHash.inputs[0] <== left;
poseidonHash.inputs[1] <== right;
hash <== poseidonHash.out;
}
// if s == 0 returns [in[0], in[1]]
// if s == 1 returns [in[1], in[0]]
template DualMux() {
signal input in[2];
signal input s;
signal output out[2];
s * (1 - s) === 0;
out[0] <== (in[1] - in[0])*s + in[0];
out[1] <== (in[0] - in[1])*s + in[1];
}
// Verifies that a merkle proof is correct for given root and leaf
// pathIndices input in an array of 0/1 selectors telling whether
// given pathElement is on the left or right side of the merkle path
template MerkleTreeChecker(levels) {
signal input leaf;
signal input root;
signal input pathElements[levels];
signal input pathIndices[levels];
component selectors[levels];
component hashers[levels];
signal levelHashes[levels];
levelHashes[0] <== leaf;
for (var i = 1; i < levels; i++) {
selectors[i] = DualMux();
hashers[i] = HashLeftRight();
selectors[i].in[1] <== levelHashes[i - 1];
selectors[i].in[0] <== pathElements[i];
selectors[i].s <== pathIndices[i];
hashers[i].left <== selectors[i].out[0];
hashers[i].right <== selectors[i].out[1];
levelHashes[i] <== hashers[i].hash;
}
root === levelHashes[levels -1];
}
Okay, so quite a few things going on. Let me explain.
First we have a hasher that hashes leaves from left to right. It’s uses the implementation we selected.
After this the DualMux
is a Multiplexer that will just swap inputs. It works based on pathIndices
and aligns the pathElements
correctly so we can hash left to right for all cases.
The merkle tree checker takes inputs and computes the merkle root
by combining pathElements
I hope this saves you a lot of time developing your circuits.now lets jump to javascript:
lib/merkletree.js
The important changes you will find in a new file, merkletree.js which now contains functions to work with merkle trees.It’s zero extra dependency and everything is implemented from scratch. You can run it in any environment.
You will see new functions to call, generateMerkleTree
,generateMerkleProof
You need to supply an array of leaves which are commitments and call generateMerkleTree
to obtain the tree. Once you have a commitment you want to create a proof for, you can call generateMerkleProof
To use the merkle proof as pathElements and pathIndices you call encodeForCircuit
Important! Trees with uneven leaves are padded! The padding works by duplicating the last branch. This means that the merkle tree contains duplicate leaves but it never contains zeros. Other padding methods could pad with zeroes, so could use the hash(0) for a leaf, that implementation would be insecure on the blockchain.Every uneven level is padded. If we have 9 leaves on the first level, then the last leaf is duplicated etc.
Merkle tree commands
The project gives you a few commands to work with from the CLI to interact with merkle trees manually.There are many use-cases, for example if you want to manage a tree for withdrawing airdrops, you might just manipulate it manually.
lib/run.js
contains that gives you 3 commands, new, proof, verify, you can access them using npm also.
npm run new
will create a new merkle tree with a similar output:
CREATING A NEW MERKLE TREE
Enter how many secrets would you like to generate:
8
Generating secrets and hashing commitments. Please wait!
Done
Generating merkle tree from commitments!
Done.Root is 9399890428704023149822996752765634449585627060469866502272705510954136382993
Serializing data.
Writing to file.
Done
Now you will see it created a private
and a public
directory.
The public directory contains the merkle tree and commitment hashes, nullifier hashes and the private contains secrets used to compute them.The files are named using the root hashes.
npm run proof
The command will ask you for a root hash and a commitment to verify. It will split out a JSON which contains the merkle proof. I do not copy it here due to it’s size.
npm run verify
This command will ask you for the merkle root and the proof and verifies the proof.
You are free to modify the merkle tree tooling to adjust it to work with your circuits
Top comments (0)