HESTIAdocs

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:

ts
import { CIRCUIT_ARITIES } from "@hestia/circuits";
// readonly ["1x2", "2x2"]
  • 1x2 — one input note, two outputs (recipient + change). This is the workhorse the high-level send and unshield use, 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 owner equals poseidon([sk]), and the prover knows sk.
  • 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 withdrawAmount plus feeAmount; 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, associationRootinput notes, their sk, randomness
nullifiers, outCommitmentsinput values, output values, labels
withdrawAmount, token, recipientMerkle 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:

ts
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.

ts
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 wasm is a few MB and each zkey is 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 from crypto.randomBytes and discarded — never written to disk or committed. Because the secret is gone, no one (including the deployer) holds the toxic waste. The matching zkeys are the ones served from /circuits, so a browser proof verifies against the on-chain verifier — confirmed by replaying a real proof against the deployed TransactionVerifier1x2 (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.