Zero-knowledge circuits
The Circom join-split circuits, the Groth16 prover, what the proof guarantees, and the trusted-setup caveat.
Hestia's privacy comes from a join-split circuit written in Circom 2 and proven with Groth16 (via snarkjs). A spend produces a succinct proof that the transaction is valid — balanced, authorized, included, and compliant — while revealing none of the private values.
Two arities
The transaction circuit comes in two shapes, named inputs × outputs:
import { CIRCUIT_ARITIES } from "@hestia/circuits";
// readonly ["1x2", "2x2"]1x2— one input note, two outputs (recipient + change). This is the workhorse the high-levelsendandunshielduse, and it is what enforces the single-lineage rule.2x2— two inputs, two outputs, for consolidating notes when needed.
Each arity has its own compiled artifacts and its own on-chain verifier.
What the proof guarantees
Inside the proof, the circuit constrains every property that keeps the pool sound — without exposing the witness:
- Ownership — each input note's
ownerequalsposeidon([sk]), and the prover knowssk. - Inclusion — each input commitment is a leaf under a known tree root.
- Nullifier correctness — each published nullifier equals
poseidon([commitment, leafIndex, sk]). - Balance — inputs equal outputs plus the public
withdrawAmountplusfeeAmount; no value is created or destroyed. - Range — every value is a non-negative field element below
2^248, so the balance check can't be gamed by field wraparound. - Token consistency — inputs and outputs share one token; assets never mix in a spend.
- Lineage — outputs inherit the input's
label, and that label is proven a member of the association set.
Public vs. private signals
| Public (on-chain) | Private (witness, never leaves the device) |
|---|---|
Merkle root, associationRoot | input notes, their sk, randomness |
nullifiers, outCommitments | input values, output values, labels |
withdrawAmount, token, recipient | Merkle paths, association path |
feeAmount, relayer | — |
The contract checks the proof against exactly these public signals, so binding recipient,
withdrawAmount, feeAmount, and relayer into the proof is what stops a
relayer from altering your transaction.
The browser-safe prover
The proving entry point has no Node-only dependencies, so it bundles for the browser. You give it the witness input and the artifact sources; it returns the proof in the exact shape the generated verifier expects:
import { proveForContractWith, type ContractProof } from "@hestia/circuits";
const proof: ContractProof = await proveForContractWith(input, {
wasm: "/circuits/transaction1x2.wasm",
zkey: "/circuits/transaction1x2.zkey",
});
interface ContractProof {
a: [bigint, bigint];
b: [[bigint, bigint], [bigint, bigint]];
c: [bigint, bigint];
publicSignals: string[];
}Artifacts are loaded from caller-provided sources — file paths in Node, URLs in the
browser — which is why the SDK takes an artifacts map. The node-only
harness (disk resolution, witness building from notes) lives separately so it never leaks into
a browser bundle.
import type { ArtifactsByArity } from "@hestia/circuits";
const artifacts: ArtifactsByArity = {
"1x2": { wasm: "/circuits/transaction1x2.wasm", zkey: "/circuits/transaction1x2.zkey" },
"2x2": { wasm: "/circuits/transaction2x2.wasm", zkey: "/circuits/transaction2x2.zkey" },
};The artifacts are large — the
wasmis a few MB and eachzkeyis tens of MB. The Labs console serves them from/circuits; a production deployment should serve them as static assets or from a CDN. See self-hosting.
Trusted setup
Groth16 requires a per-circuit trusted setup that produces the proving and verifying keys. Hestia keeps two setups, on purpose:
- The open-source dev ceremony. The
zkeys in the repository come from a ceremony with fixed entropy (scripts/ceremony.mjs). It is reproducible — anyone can re-run it and get byte-identical keys — which is exactly why it is not secure for real value: its toxic waste is public, so anyone could forge proofs against it. It exists for testing and for verifying the pipeline end to end. - The live deployment's ceremony. The keys behind the verifiers deployed to Base mainnet
come from a separate setup (
scripts/ceremony-prod.mjs) with a fresh Powers of Tau and per-circuit contributions drawn fromcrypto.randomBytesand discarded — never written to disk or committed. Because the secret is gone, no one (including the deployer) holds the toxic waste. The matchingzkeys are the ones served from/circuits, so a browser proof verifies against the on-chain verifier — confirmed by replaying a real proof against the deployedTransactionVerifier1x2(verifyProof → true).
The production setup is single-contributor, not a public multi-party ceremony with a published transcript. A multi-party ceremony and an external audit remain the bar before trusting the pool with large value.
