Your First ZK Program
Write a private payment in Rust and prove it in zero knowledge: the note exists, is spent once and balances, while amounts, owners and the note stay hidden.
On this page
Alice holds a note worth 100 on a private ledger and pays Bob 30 of it. In this guide you write the Rust program that proves her payment follows the rules, while the network that checks the proof never sees an amount, either party, or which note she spent. The program uses two Hekate chiplets: Keccak for the SHA3-256 hashes behind keys, notes and the tree, and integer arithmetic for the balance.
Notes, commitments in a Merkle tree and nullifiers come from Zcash's protocol. This version keeps one note in, two notes out and a tree of 16 notes.
§ How a private payment works
The ledger stores fingerprints of notes, and a payment proves five facts about them.
A note is a value with an owner: Alice's holds 100. The owner is an address, the SHA3-256 hash of a secret only Alice knows, and a random nonce makes every note different from every other.
When a note is created, the ledger stores its commitment, the SHA3-256 hash of owner, nonce and value. The hash hides the note, because nobody can test guesses about the value without the nonce. It also binds the note: no one can find a second note with the same commitment. The ledger keeps every commitment as a leaf of a Merkle tree and publishes the tree's root.
When a note is spent, the ledger records its nullifier, the hash of the owner's secret and the note's nonce. Only the owner can compute it, nothing links it to the commitment, and a second spend of the same note produces the same nullifier, which the ledger refuses.
| The network sees | Stays with Alice |
|---|---|
| the tree root | which leaf is her note, and the path to it |
| the nullifier | her secret, and her note's nonce and value |
| the commitments of Bob's note and of her change | the amounts 30 and 70, Bob's address, the new nonces |
| the proof |
The proof states five facts that connect the two columns:
- Alice's secret hashes to the owner of a note.
- That note's commitment is a leaf of the tree with this root.
- The nullifier is the hash of the same secret and the same note's nonce.
- Bob's note and the change hash to the two published commitments.
- 30 plus 70 is 100, and the payment does not exceed the note.
The right column never leaves Alice's device. This is client-side proving: the proof is made where the private data already is.
§ Set up the project
You need a Rust project with the Hekate crates and an ARM64 machine to prove on. Installation lists the platforms and explains the prover binary.
hekate-prover-sys downloads the signed prover binary on the first build, and ct selects its constant-time variant,
the one for private data.
The program takes four files, one job each:
src/payment.rsholds the notes and the hashing a wallet does, in plain Rust.src/circuit.rsdeclares the table, its chiplets and its constraints: the statement.src/trace.rsfills the table for one payment: the witness.src/main.rsproves the payment and verifies it the way the network would.
Each block below names its file and starts at that file's line number.
§ Notes and payments
A Note holds the three fields its commitment hashes, and a Payment holds everything Alice knows.
1use ;
2use ;
3use Error;
4
5pub const DEPTH: usize = 4;
6
7pub const KEY_TAG: u64 = 1;
8pub const NOTE_TAG: u64 = 2;
9pub const NODE_TAG: u64 = 3;
10pub const NULLIFIER_TAG: u64 = 4;
11
12pub type Word = ;
13
14
19
20
28
29
A Word holds 32 bytes as four 64-bit lanes, the unit Keccak works in. DEPTH is the height of the note tree: four
levels hold 16 notes. Each tag goes into the first lane of its hash and names the hash's job. A nullifier and a tree
node both hash 72 bytes, and their different tags keep one from passing for the other.
Note::lanes lays a note out for hashing: the note tag, the owner, the nonce, then the value. path is the Merkle path
from her note to the root: at each level, the sibling's hash and whether the node on her side is the right child.
Everything in Payment is the witness, the private input to the proof.
§ Hashes on the host
Alice's wallet computes every hash before proving, and keeps the Keccak call behind each one.
37
46
47
56
57
60
61
64
65
96
97
sha3_256 from hekate-keccak returns a SHA3-256 digest and the list of Keccak-f[1600] calls that produced it. A
message of up to 135 bytes fits in one call, and sha3 refuses longer ones because the circuit gives every hash
exactly one call. digest reads the hash from the first four lanes of the call's output.
example_payment gives Alice a note of 100, pays 30 to a fresh address for Bob and returns 70 to her own. The random
siblings stand in for the other notes in the tree; a real wallet gets its path from the ledger.
§ The table
A Hekate program is a table: each row is a step, each column holds one value per row, and the proof shows that every row satisfies the constraints.
1use crate;
2use Config;
3use errors;
4use ;
5use KeccakChiplet;
6use ;
7use ChipletDef;
8use ;
9use ConstraintSystem;
10use ;
11
12pub const KEY_CALL: usize = 0;
13pub const NOTE_CALL: usize = 1;
14pub const ROOT_CALL: usize = NOTE_CALL + DEPTH;
15pub const NULLIFIER_CALL: usize = ROOT_CALL + 1;
16pub const PAY_CALL: usize = NULLIFIER_CALL + 1;
17pub const CHANGE_CALL: usize = PAY_CALL + 1;
18pub const CALLS: usize = CHANGE_CALL + 1;
19
20pub const PUBLISHED: = ;
21
22pub const ADD_ROW: usize = 0;
23pub const LT_ROW: usize = 1;
24
25pub type F = Block128;
26
27define_columns!
F is the field Hekate proves over, GF(2^128). Every hash in the payment is one call to the Keccak-f[1600]
permutation, and the constants put the nine calls in order: Alice's address, her note's commitment, one call per tree
level up to the root, the nullifier, then the commitments of Bob's note and of the change. PUBLISHED names the four
calls whose outputs the network sees. ADD_ROW and LT_ROW are the two rows that call the arithmetic chiplet.
define_columns! names the columns and gives each a type: B64 holds 64 bits, B32 holds 32 and Bit holds one.
A column takes only the space its type needs.
LANEShold the 25 lanes of the Keccak state. Callkwrites its input on row25kand its output on row25k + 24, andKECCAKmarks those two rows.SECRET,NONCEand the three values are carried columns. Each holds the same value on every row, which lets two checks on distant rows read the same number.SIBLINGandIS_RIGHThold one step of the tree path.OPCODEandLT_RESULTbelong to the arithmetic calls.- The columns ending in
_ATare markers. Each one is 1 on the rows where its constraint applies and 0 everywhere else.
§ The circuit
build_program declares the table, plugs in the two chiplets, pins every value the verifier already knows and
publishes the four values the network sees.
52pub const
55
56pub const
59
60
65
66
in_row and out_row turn a call's position into its two rows, and table_rows sizes the table: a power of two, and
at least min_table_rows(). markers lists the rows each marker switches on. The circuit and the trace both read it,
which keeps the two in step.
82
136
137
A chiplet is a ready-made table with its own constraints, joined to yours by a bus. cx.call puts a request
on a bus: on every row its selector marks, the listed columns go out as one message, and the bus checks that the
chiplet answers with the same messages in the same order. The Keccak chiplet only answers with outputs that are
Keccak-f[1600] of their inputs, and the arithmetic chiplet only with correct results.
mount puts the Keccak chiplet's columns into your table, next to the lanes it talks to. attach gives the
arithmetic chiplet a table of its own, which travels next to yours in the witness.
The selectors and markers are fixed columns: their values are part of the circuit, and the verifier computes them
itself. A selector the prover could fill in would let it switch a check off, and compile rejects a bus whose
selector is not fixed.
The two arithmetic calls read the carried values. Row 0 asks for V_PAY + V_CHANGE and expects V_IN. Row 1 asks
whether V_IN is less than V_PAY. The fixed OPCODE column holds 0, the ADD opcode, on row 0 and LT on row 1,
and a boundary pins LT_RESULT to 0 on row 1: the answer has to be no.
frame pins the public part of each hash input: the tag in lane 0, the SHA3-256 padding after the message (a 0x06
byte, zeros, and 0x80 in the last byte of lane 16) and zeros in the eight capacity lanes. The lanes that hold
secrets stay free. Without the padding and capacity pins, the published digests would not have to be SHA3-256 of
anything.
publish turns the first four lanes of each call in PUBLISHED into public inputs: the root, the nullifier and the
two new commitments. The verifier brings its own copy of these values and checks the proof against them.
§ The constraints
Three groups of constraints connect the nine hashes to each other and to the carried values. Each constraint is a formula over the current row and the next that must equal zero on every row.
151
Hekate's field is binary: adding is XOR, and a + b is zero exactly when a equals b. Every constraint here is
an equality written that way.
- Carried values.
next(c) + col(c)holds a carried column steady from row to row. Row 0 and row 150 then read the same secret, and the prover cannot feed one secret to the address and another to the nullifier. - Marked equalities.
assert_zero_when(marker, constraint)applies a constraint on marked rows only. The secret fills lanes 1 to 4 of the address and nullifier inputs, the nonce fills lanes 5 to 8 of the commitment and nullifier inputs, and each value fills lane 9 of its note. On the last row of the address hash,nextreaches one row down: the address that hash produced must be the owner inside the commitment. - Tree steps. On the last row of the commitment and of each tree hash below the root, the digest moves into the
next hash's input.
swapisis_right · (node + sibling). Whenis_rightis 0 the swap is zero and both halves stay in place; when it is 1, XOR exchanges them.assert_booleankeepsis_rightto 0 or 1.
The balance has no constraint here: the arithmetic chiplet proves it. Its ADD works modulo 2^64, like
u64::wrapping_add, and ADD alone would accept a payment of 130 with change of 2^64 minus 30, which wraps around to
100. The LT call refuses that payment, because 100 is less than 130. You run exactly this attack below.
§ Filling the table
The trace starts from the nine Keccak calls, in circuit order, and writes the values the constraints read.
1use crate;
2use crate;
3use errors;
4use ;
5use ;
8use ;
9use ;
10use Error;
11
12
hashes replays the payment's hashes in the order the circuit expects them. On the way up the tree, is_right decides
which side the current node takes, and each parent becomes the node of the next level.
46
111
112
TraceBuilder::new_secret starts every column at zero and wipes the memory when the trace is dropped. Each call
writes its input and output state, the carried values go on every row, and each tree step writes its sibling and
direction. The fixed columns get the values the circuit fixed, and preflight checks the two against each other.
generate_keccak_trace builds the Keccak chiplet's columns from the call inputs, and they go after yours, where
mount placed them. The arithmetic chiplet's table holds the two operations in the order the calls emit them.
§ Prove and verify
main builds the payment, checks the table with preflight, proves it, and verifies the proof the way the network
would.
1
2
3
4
5use ;
6use Config;
7use DefaultHasher;
8use Transcript;
9use program_id;
10use ;
11use prove;
12use preflight;
13use HekateVerifier;
14use ;
15use ;
16use Error;
17use ;
18
19const LABEL: & = b"private-payment";
20
21
78
79
moddeclares the three other files.Config::default()is the production configuration, zero knowledge included.preflightevaluates every constraint and bus on the concrete table before any proving starts, and reports every failure. Run it first: a proof of a broken table only tells you that verification failed.provesends the program, the instance and the witness to the prover binary. The seed blinds the proof and must be fresh random bytes for every proof: one seed used for two different witnesses discloses them.- The network holds the program, the instance and the proof.
program_idhashes the circuit, and a real verifier stores it as a constant: a proof made for any other circuit then fails. The label and theConfigmust match the prover's, and onlyOk(true)accepts the payment.
After the proof, the network runs two checks of its own: the root must be one it published, and the nullifier must not be in its set of spent ones. It then records the nullifier and adds the two new commitments to the tree.
§ Run it
The program prints the four public values, different on every run, and the verdict:
root 46b9aea28972c80d5ec2c4da6d17f3872246d6a1bb283119b7963977ce8b30bf
nullifier 57c6ae0d4d996853b202e995479832ba9057593640a53a036efdaefa60d31036
payment c3c6e965e8e7dc3d49e8c9074ce9e07624f0b466b1e832e24f0a754da66efd35
change 85fac2ca999c76c0a0578191a273da0c60d6603687fd6cd39f1ff54003f33fcc
the network accepts the payment
§ Try to cheat
Make Alice pay Bob 130 out of her 100, and preflight stops the proof before it starts.
In example_payment, in src/payment.rs, set Bob's value to 130 and the change to 100u64.wrapping_sub(130), then
run again. The commitments and the ADD call still balance, because 130 plus the wrapped change is 100 modulo 2^64.
The LT call does not:
PREFLIGHT: 0 constraint, 0 boundary, 0 fixed column, 1 bus
bus "int_arith_link" [Permutation] (3 endpoints) failures: bus_imbalance
Main: 512 rows, 1 active, product=Flat(Block128(317219965915207002739574547601044851246)), claimed_sum=Flat(Block128(302841299352895124812380006818726758057))
Main: 512 rows, 1 active, product=Flat(Block128(212338368371443115162742677577723555665)), claimed_sum=Flat(Block128(228810123010016694098178293648795798352))
Chiplet 0: 512 rows, 2 active, product=Flat(Block128(210055261572217285347633200455283535795)), claimed_sum=Flat(Block128(67995653764804293838376205785726945516))
Error: "the trace fails preflight"
The two Main lines are the ADD and LT requests from your table, and Chiplet 0 is the arithmetic chiplet's table.
Their messages no longer match: the chiplet computed LT as 1 where the request expected 0. Remove the preflight check
and the prover still returns a proof, which the verifier rejects.
§ What the example leaves out
The program proves the rules of one payment; a payment network needs more around it.
- More notes per payment. Real transactions spend and create several notes, and add dummy notes to hide how many are real.
- Telling Bob. Bob needs the owner, nonce and value of his note to spend it later. Networks encrypt them to him, and Hekate proves such an encryption with its ML-KEM and AES chiplets.
- Unique nonces. Bob relies on Alice to pick a fresh nonce. Two notes with the same nonce share a nullifier, and only one of them can ever be spent. Zcash's specification calls this the Faerie Gold attack and derives new nonces from data no two payments share.
- Secrets in memory. The secret and the seed sit in plain variables here. Production code keeps them in types that wipe themselves, as the trace already does.
§ Next steps
- Installation: the prover binary, its two variants and the platforms it runs on.
- Keccak-f[1600]: what the Keccak chiplet proves and what stays with your program.
- ML-KEM: prove that a note was encrypted to its recipient.
- The examples in the hekate repository: a complete program for every chiplet.