build strategy · noir

Real proofs, one prompt, one build.

Every entry in this catalog compiles down to the same shape: a small Noir circuit, a client-only prover, and one product surface on top. It's the only pattern that ships a working zero-knowledge demo in one shot, inside the 5-credit budget.

Why a circuit and not an API check?

An API check means handing over the data and trusting whoever holds it. A circuit proves the statement and nothing else: the private inputs stay in the browser, the verifier gets a proof plus a public commitment. Nothing about it is a token, a coin, or a speculation.

Three modes, one circuit

Start in the browser — compile Noir at runtime, prove with UltraHonk, zero setup. Ship a precompiled ACIR artifact when you want a faster first load, or verify natively and on-chain when the proof has to travel. The circuit never has to change.

src/components/zk/zk-circuit.ts — the circuit
// src/components/zk/zk-circuit.ts — the whole security statement
// Built for the Noir Catalyst — StreetKode Fam · Indian Krump Festival 14
export const NARGO_TOML = `[package]
name = "catalyst"
type = "bin"

[dependencies]`;

export const CIRCUIT_SOURCE = `
fn main(
    dob_year: u32,
    binding_lo: Field,
    binding_hi: Field,
    threshold_year: pub u32,
) -> pub Field {
    // private predicate: the year never becomes public
    assert(dob_year <= threshold_year);
    // commitment binding: links the proof to one secret source
    std::hash::pedersen_hash([binding_lo, binding_hi])
}
`;
vite.config.ts — the two bundler traps
// vite.config.ts — the two traps that decide whether this ships
export default defineConfig({
  optimizeDeps: {
    // esbuild rewrites the wasm-bindgen glue: proving then HANGS with no error
    exclude: ["@noir-lang/noir_wasm", "@noir-lang/noir_js", "@aztec/bb.js"],
  },
  plugins: [
    // Serve node_modules/@noir-lang/noir_wasm/web/main.mjs VERBATIM at a stable
    // URL and import it with import(/* @vite-ignore */ "/vendor/noir_wasm/main.mjs").
    // Re-bundling it emits `const Infinity = Infinity` and the production build
    // dies with "Cannot access 'j' before initialization" — preview looks fine.
    serveNoirWasm(),
  ],
});
client entry — compile, prove, verify, tamper
// client-only entry — nothing here may run during SSR
const { compile, createFileManager } = await import(/* @vite-ignore */ "/vendor/noir_wasm/main.mjs");
const { Noir } = await import("@noir-lang/noir_js");
const bb = await import("@aztec/bb.js");

const fm = createFileManager("/");
await fm.writeFile("./src/main.nr", new Blob([CIRCUIT_SOURCE]).stream());
await fm.writeFile("./Nargo.toml", new Blob([NARGO_TOML]).stream());
const compiled = await compile(fm);
const program = "program" in compiled ? compiled.program : compiled;

const api = await bb.Barretenberg.new({ threads: 1 });  // >1 needs COOP/COEP
const backend = new bb.UltraHonkBackend(program.bytecode, api);
const noir = new Noir(program);

const { witness } = await noir.execute({
  dob_year: 1994, binding_lo, binding_hi, threshold_year: 2007,
});
const proof = await backend.generateProof(witness);
const ok = await backend.verifyProof(proof);            // true

// the only convincing evidence: flip a byte, expect false
const bad = { ...proof, proof: tamper(proof.proof) };
const shouldBeFalse = await backend.verifyProof(bad);
src/lib/binding.ts — two 128-bit limbs
// src/lib/binding.ts — SHA-256 does not fit one Field (BN254 ≈ 254 bits)
export async function credentialBinding(secret: string) {
  const digest = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret)),
  );
  const limb = (bytes: Uint8Array) =>
    "0x" + [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
  return {
    binding_lo: limb(digest.slice(0, 16)),   // two 128-bit limbs
    binding_hi: limb(digest.slice(16, 32)),
  };
}
build modes
# Browser prover — no secrets at all. Start here.
# Noir + Barretenberg run client-side; the secret never leaves the page.

# Precompiled circuit — ship the ACIR artifact, skip the compiler download:
# nargo compile  ->  target/catalyst.json  ->  import as a static asset

# Native + on-chain — verify the proof outside the browser:
ZK_RPC_URL=https://<chain-rpc>
ZK_VERIFIER_ADDRESS=0x<deployed-verifier>

# Whatever the mode, the mega-prompt then:
#    - keeps every private input inside the circuit
#    - reports per-phase progress with per-phase timeouts
#    - proves the proof is real by failing a tampered one

Rules of the build.

  • · Pick one idea, pick one mode, paste its mega-prompt. That's the whole build.
  • · Add all three ZK packages to optimizeDeps.exclude — otherwise proving hangs silently.
  • · Keep the prover in a client-only entry; crypto.subtle and WebAssembly need a browser.
  • · Pin noir_wasm and noir_js to the same beta; the ACIR format shifts between them.
  • · Use threads: 1 unless you can afford COOP/COEP isolation.
  • · Split any 256-bit digest into two 128-bit Field limbs.
  • · Wrap every phase in its own timeout — an unbounded await looks identical to a hang.
  • · Show the tamper check and the timings — judges want evidence the proof is real.
  • · Say plainly what the circuit does not prove: issuer signatures verified outside stay outside.
  • · Add the footer credit: "Built for the Noir Catalyst — StreetKode Fam · Indian Krump Festival 14".