Zero-Knowledge Proofs
Orbinum uses Groth16 zero-knowledge proofs over the BN254 curve to let users prove ownership, transaction validity, and identity claims without revealing private data. Proofs are generated client-side, submitted on-chain, and verified in ~15ms by pallet-zk-verifier.
This page covers the cryptographic foundations: proof system, hash function, circuit designs, and the end-to-end proof flow.
Proof System: Groth16 over BN254
Why Groth16?
pallet-zk-verifier, cheaper than an in-EVM pairing checkWhy BN254 Curve?
The BN254 (also called alt_bn128) is an elliptic curve specifically designed for efficient pairing operations:
Cryptographic Primitives
Poseidon Hash Function
Poseidon is the primary hash function used by all Orbinum circuits. It is specifically designed for ZK arithmetic: where SHA-256 costs ~25,000 R1CS constraints, Poseidon costs ~300 — an 80× reduction in proving time.
// From primitives/zk-core
pub fn poseidon_hash_2(left: [u8; 32], right: [u8; 32]) -> [u8; 32]
pub fn poseidon_hash_4(inputs: [[u8; 32]; 4]) -> [u8; 32]
Constraint cost comparison
| Hash Function | R1CS Constraints | ZK-Friendly | Standardized |
|---|---|---|---|
| Poseidon | ~300 | ✅ Yes | ✅ Yes |
| SHA-256 | ~25,000 | ❌ No | ✅ Yes |
| Pedersen | ~750 | ✅ Yes | ⚠️ No |
How We Use Poseidon
1. Note Commitments
When you create a private note, we hash all its details into a single commitment:
commitment = Poseidon(value, asset_id, owner_pubkey, blinding)
This commitment goes into the Merkle tree. It's public but reveals nothing about the note's content.
2. Nullifiers
When you spend a note, we compute a nullifier to prevent double-spending:
nullifier = Poseidon(commitment, spending_key)
Without your spending key, no one can link the nullifier back to the original commitment.
3. Merkle Tree Hashing
The Merkle tree uses Poseidon to combine leaves:
parent = Poseidon(left_child, right_child)
This allows efficient proof of inclusion with only ~20 hash operations per tree of up to 1,048,576 notes. Trees are sealed and replaced as they fill — see the Merkle forest.
Circuits
Each circuit defines the constraints that constitute a valid operation. The Groth16 prover runs these constraints offline; the on-chain verifier checks the resulting proof in ~15ms regardless of constraint count.
The Transfer Circuit
When you make a private transfer, the circuit checks these constraints:
Private Inputs (Your Secrets)
value_a, value_bblinding_a, blinding_bpath_a, path_b (proving notes exist)skvalue_c, value_d, blinding_c, blinding_dPublic Inputs (Visible to Everyone)
nullifier_a, nullifier_b (prevent double-spending)commitment_c, commitment_droot (snapshot of tree state)asset_idfee (deducted from input sum; paid to block author)The 4 Critical Constraints
merkle_verify(commitment_a, path_a, root) == 1 merkle_verify(commitment_b, path_b, root) == 1
nullifier_a == poseidon(commitment_a, sk) nullifier_b == poseidon(commitment_b, sk)
value_a + value_b == value_c + value_d + fee
The fee is a public signal committed to at proof-generation time. It is paid to the
block author as a private credit inside the shielded pool. See Gasless Fees.
commitment_c == poseidon(value_c, asset_id, owner_pk_c, blinding_c) commitment_d == poseidon(value_d, asset_id, owner_pk_d, blinding_d)
The Private Link Circuit
When an account dispatches a call via a private link, the circuit proves two things simultaneously: that the caller controls the address behind a stored Poseidon commitment, and that the address signed the specific call being dispatched — without ever writing the address to chain storage.
Private Inputs (Your Secrets)
addressblindingchain_id_fesignaturePublic Inputs (Visible to Everyone)
commitmentcall_hash_feThe 2 Critical Constraints
inner = poseidon2(chain_id_fe, address_fe) commitment == poseidon2(inner, blinding)
ecdsa_verify(address, call_hash_fe, signature) == 1
Circuit Complexity
Different operations require different circuit sizes:
| Circuit | R1CS Constraints | Proving Time | Proof Size | Use Case |
|---|---|---|---|---|
| Transfer | 33,687 | ~2–3s | 128 bytes | Private transfer (2 in → 2 out) with gasless fee |
| Unshield | 16,903 | ~750ms | 128 bytes | Withdraw to public, total or partial, with gasless fee |
| Value Proof | ~300 | <50ms | 128 bytes | Validator claiming relay fees |
| Private Link | 487 | <50ms | 128 bytes | Dispatch call via private identity |
Proving times are measured on a local development machine and scale with the prover's hardware. Verification is ~15ms for transfer and unshield, <5ms for the two small circuits — independent of constraint count, which is the property that makes Groth16 practical on-chain.
Depositing into the pool is a public operation — the amount and sender are visible on-chain
anyway — so shield submits no ZK proof and has no verification key.
Each constraint is a mathematical equation that must hold true. More constraints = larger circuit = longer proving time. But verification time stays constant (~15ms) thanks to Groth16!
Proof Flow
End-to-end walkthrough of generating and submitting a private transfer:
Step-by-step: Private Transfer
Your wallet selects input notes (with values, blinding factors, spending key) and creates output notes. This all happens locally — nothing is shared yet.
Your wallet loads transfer.wasm (the circuit) and transfer.zkey (proving key) from artifacts. These are generated during trusted setup.
The circuit runs locally to compute all intermediate values: commitments, nullifiers, Merkle path verification, value balance checks. This creates the "witness" — proof that your inputs satisfy all constraints.
Using the witness and proving key, Groth16 generates a 128-byte proof. This takes ~2–3 seconds on a typical laptop. The proof says "I know secret inputs that satisfy all circuit constraints" without revealing what those inputs are.
Your wallet sends: the proof (128 bytes), nullifiers, output commitments, Merkle root, asset ID, fee, and the circuit version the spent notes were created under. These public inputs allow verification without revealing private data.
The runtime's pallet-zk-verifier loads the verification key for the circuit version the notes were created under — not necessarily the active one — and runs Groth16 verification. If valid, nullifiers are marked as spent and new commitments are added to the tree. See On-Chain ZK Verification for how this VK registry works and how it is upgraded.
Trusted Setup
Groth16 requires a trusted setup ceremony to generate the proving and verification keys. This is a one-time process per circuit. The output is:
- A proving key (
.zkey/.ark) — used client-side to generate proofs - A verification key — deployed on-chain into
pallet-zk-verifier
Ceremony Phases
Multiple participants contribute randomness. Each adds their secret and passes it to the next person. This creates a large file of random values (~10-50 GB).
Using the Powers of Tau, we generate proving keys (.zkey) and verification keys for each circuit (transfer, unshield, value proof, private link).
All participants must permanently delete their random secrets. These secrets are called "toxic waste" because they could be used to create fake proofs.
The 1-of-N Trust Assumption
The beautiful property of trusted setup ceremonies:
Even if 99 out of 100 participants collude or leak their secrets, if just ONE person follows the protocol correctly, the entire system remains secure.
Current Status
The current proving/verification keys in /artifacts are for testing only. They were generated in a local, non-production setup.
Before mainnet launch (Q4 2026), we will conduct a public multi-party ceremony with community participation and full transparency.
What to read next
This page covers the cryptographic foundations. The following pages explain how this system is deployed and operated on Orbinum: