ZK Circuit
WRIT uses a small Groth16 circuit over BN254 to prove knowledge of a secret trapdoor and bind it to a Poseidon nullifier without revealing which secret produced it. The full circuit has ~4,200 R1CS constraints and compiles to a 32 kB proving key.
What the circuit proves
Given a public epoch root R and a public nullifier N, the prover knows a secret s such that:
N = Poseidon(s, domain)for a fixed domain separatorPoseidon(s) ∈ merkle_tree_with_root(R)— the secret is in the current epoch's accepted set
The first clause makes the nullifier a deterministic function of the secret, so the same human cannot register twice. The second clause lets us rotate the valid secret set without invalidating old registrations — each registration is bound to the epoch root that was live at proof time.
Circuit source
pragma circom 2.1.4;
include "circomlib/poseidon.circom";
include "circomlib/mux1.circom";
template Writ(depth) {
// --- private inputs
signal input secret;
signal input path_elements[depth];
signal input path_indices[depth];
// --- public inputs
signal output nullifier;
signal input root;
// leaf = Poseidon(secret)
component leaf_hash = Poseidon(1);
leaf_hash.inputs[0] <== secret;
// Merkle path
component hashers[depth];
component muxes[depth];
signal cur;
cur <== leaf_hash.out;
for (var i = 0; i < depth; i++) {
muxes[i] = Mux1();
muxes[i].c[0] <== cur;
muxes[i].c[1] <== path_elements[i];
muxes[i].s <== path_indices[i];
hashers[i] = Poseidon(2);
hashers[i].inputs[0] <== muxes[i].c[0];
hashers[i].inputs[1] <== muxes[i].c[1];
cur <== hashers[i].out;
}
cur === root;
// nullifier = Poseidon(secret, DOMAIN_WRIT)
component null_hash = Poseidon(2);
null_hash.inputs[0] <== secret;
null_hash.inputs[1] <== 1_936_812_657; // ASCII "writ" + 0x00
nullifier <== null_hash.out;
}
component main { public [root] } = Writ(20);Tree depth 20 gives 220 ≈ 1.05M leaves per epoch — enough to onboard every human on Solana at current adoption without exhausting a single epoch. When epochs fill, a new root is rotated in.
Proof generation
The browser flow uses snarkjs WASM to generate proofs in 1.8–3.2 seconds on a modern laptop. The witness builder runs in a web worker; the prover runs in the main thread but is non-blocking due to chunked polynomial evaluation.
Public inputs: nullifier, root. Private inputs: secret, merkle path.
Client timing
| Stage | Median | P95 |
|---|---|---|
| Argon2id | 420 ms | 680 ms |
| witness gen | 110 ms | 180 ms |
| prove | 1.4 s | 2.9 s |
| total | 1.9 s | 3.7 s |
On-chain verifier
The verifier is a small Rust module that wraps Solana's alt_bn128_pairing syscall. It consumes the proof bytes plus the two public inputs and either succeeds or errors with ProofInvalid.
use solana_program::alt_bn128::prelude::*;
pub fn verify_groth16(
vk: &VerifyingKey,
proof_a: &[u8; 64],
proof_b: &[u8; 128],
proof_c: &[u8; 64],
inputs: &[[u8; 32]],
) -> Result<()> {
let prepared = prepare_inputs(vk, inputs)?;
let lhs = Pairing::pair(proof_a, proof_b)?;
let rhs1 = Pairing::pair(&vk.alpha_g1, &vk.beta_g2)?;
let rhs2 = Pairing::pair(&prepared, &vk.gamma_g2)?;
let rhs3 = Pairing::pair(proof_c, &vk.delta_g2)?;
let rhs = rhs1 * rhs2 * rhs3;
if lhs != rhs { return err!(ErrorCode::ProofInvalid); }
Ok(())
}Trusted setup
Groth16 requires a per-circuit trusted setup. WRIT runs a Powers-of-Tau ceremony followed by a circuit-specific Phase 2 MPC. Any contributor can participate; compromise requires every contributor to collude. Transcript and contributor list are published at github.com/WritNetwork/writ-v1/ceremony.
Non-goals
- Hiding the epoch. The epoch root is public; an observer can tell which epoch a writ was created in.
- Fully anonymous rotation.
rotate_authorityreveals that the same writ is now controlled by a new wallet. The nullifier stays constant; only the authority key changes. - Private delegations. Scopes are public on-chain. A delegation reveals which agent belongs to which writ. Agents needing privacy from the writ should use separate registrations.