From 3e5f5eb5aa9f2ea1d9520197f3fc13c5b9f35261 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 14 Sep 2026 17:23:22 +0100 Subject: [PATCH 1/2] feat: ship the Solidity verifier in every circuit's artifacts The verifier derives from the vk alone, so it belongs in the release that publishes the vk. Until now every consumer re-ran bb to derive it, which put a bb pin in repos that have no other reason to know bb exists. build.sh now runs gen-verifier.sh per circuit into artifacts//, so release.yml packages and hashes it with no change. The contract name is derived from the directory and pinned for the two known circuits, so a rename fails the build instead of silently renaming what consumers compile. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- scripts/build.sh | 14 ++++- scripts/gen-verifier.sh | 134 ++++++++++++++++++++++++++++++---------- 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 0cc05b4..a7e2f3a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -15,10 +15,15 @@ # (`bb write_vk --oracle_hash keccak` — keccak because the # consumer is an EVM Solidity verifier) # vk_hash 32-byte hash of the vk, as written by the same command +# .sol the EVM Solidity verifier bb derives from the vk, with +# the memory-safe rewrite applied and the concrete contract +# named after the circuit (bearer-link -> +# BearerLinkHonkVerifier); see scripts/gen-verifier.sh. +# Ships so that consumers compile it and never run bb. # # The vk derives from the ACIR bytecode alone, and the Solidity verifier from -# the vk alone — so these three files are the complete, sufficient input for -# reproducing the on-chain verifiers (scripts/gen-verifier.sh). +# the vk alone — so a release tarball is the complete, sufficient input for +# reproducing every byte of the on-chain verifier. # # Path normalization: nargo embeds absolute source paths in the ACIR json's # file_map (used only for diagnostics; the bytecode, abi, debug_symbols and @@ -97,6 +102,11 @@ for dir in "$ROOT"/circuits/*/; do mv "$vk_tmp/vk" "$OUT/$circuit/vk" mv "$vk_tmp/vk_hash" "$OUT/$circuit/vk_hash" rmdir "$vk_tmp" + + # The Solidity verifier, next to the vk it derives from. gen-verifier.sh + # names the contract after the circuit directory and checks the pinned + # names, so a renamed directory fails here instead of at a consumer. + "$ROOT/scripts/gen-verifier.sh" "$circuit" --artifacts "$OUT" done echo "OK: artifacts written to $OUT" diff --git a/scripts/gen-verifier.sh b/scripts/gen-verifier.sh index 7fe5ac0..dfc783d 100755 --- a/scripts/gen-verifier.sh +++ b/scripts/gen-verifier.sh @@ -1,30 +1,42 @@ #!/usr/bin/env bash -# Generate an EVM Solidity verifier from a locally built verification key -# (run scripts/build.sh first — artifacts are never committed to this repo). +# Generate the EVM Solidity verifier for one circuit from its verification +# key. scripts/build.sh runs this for every circuit, so the verifier ships in +# the release tarball next to the vk it derives from; consumers compile what +# ships and never run bb themselves. # -# scripts/gen-verifier.sh [--contract-name X] +# scripts/gen-verifier.sh [] [--artifacts ] [--contract-name X] # -# directory name under artifacts/ (e.g. oidc-google, -# bearer-link) -# output path for the Solidity source +# directory name under circuits/ and / +# (bearer-link, oidc-google) +# output path; default //.sol, +# next to the vk, which is the release layout +# --artifacts where scripts/build.sh wrote (default ./artifacts); +# point it at an unpacked release to regenerate and +# byte-compare against what shipped # --contract-name X rename the concrete verifier contract from bb's fixed -# `HonkVerifier` to X (e.g. BearerLinkHonkVerifier — -# needed when a consumer compiles two bb verifiers in one -# project and the names would collide) +# `HonkVerifier` to X; default derived from +# (see below) # -# Canonical post-processing (matches what the committed libid-contracts -# verifiers were built with): +# Contract name: bb always emits `HonkVerifier`, and a consumer compiling +# both verifiers in one project needs distinct names, so the concrete +# contract is renamed to HonkVerifier with the directory name in +# PascalCase: bearer-link -> BearerLinkHonkVerifier. The names the current +# circuits ship under are pinned in KNOWN_VERIFIERS below and checked on +# every run: renaming a circuit directory renames the contract every +# consumer compiles, so it fails here instead of shipping. +# +# Post-processing — this is the interchange format, raw bb output plus +# exactly these two rewrites: # 1. every `assembly {` becomes `assembly ("memory-safe") {` — required for # consumers compiling via_ir; -# 2. the optional contract rename above. +# 2. the contract rename above. # # Deliberately NOT done here: `forge fmt`. This repo carries no Foundry -# toolchain; the consumer runs `forge fmt` over the output under its own -# foundry.toml before comparing/committing (libid-contracts does exactly -# that). Raw bb output + the two rewrites above is the interchange format. +# toolchain; the consumer runs `forge fmt` over the shipped file under its +# own foundry.toml before compiling or committing it. # # The verifier derives from the vk ALONE, so this needs only bb (pinned via -# toolchain.env) and artifacts//vk — no nargo, no recompile. +# toolchain.env) and //vk — no nargo, no recompile. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -32,21 +44,68 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck disable=SC1091 source "$ROOT/toolchain.env" -if [[ $# -lt 2 ]]; then - echo "usage: $0 [--contract-name X]" >&2 +# The contract names consumers compile by. Derived by verifier_name, pinned +# here so a renamed directory fails loudly instead of silently shipping a +# differently-named contract. A new circuit needs no entry; a rename of one +# listed here is a breaking change for every consumer and must be deliberate. +KNOWN_VERIFIERS=( + bearer-link=BearerLinkHonkVerifier + oidc-google=OidcGoogleHonkVerifier +) + +# bearer-link -> BearerLinkHonkVerifier +verifier_name() { + local pascal + pascal="$(printf '%s' "$1" | perl -pe 's/(?:^|-)(\w)/\u$1/g')" + printf '%sHonkVerifier' "$pascal" +} + +usage() { + echo "usage: $0 [] [--artifacts ] [--contract-name X]" >&2 exit 2 -fi -circuit="$1" -out="$2" +} + +circuit="" +out="" +artifacts="$ROOT/artifacts" contract_name="" -if [[ "${3:-}" == "--contract-name" ]]; then - contract_name="${4:?--contract-name needs a value}" -elif [[ $# -gt 2 ]]; then - echo "usage: $0 [--contract-name X]" >&2 - exit 2 -fi +while [[ $# -gt 0 ]]; do + case "$1" in + --artifacts) artifacts="${2:?--artifacts needs a directory}"; shift 2 ;; + --contract-name) contract_name="${2:?--contract-name needs a value}"; shift 2 ;; + -*) usage ;; + *) + if [[ -z "$circuit" ]]; then + circuit="$1" + elif [[ -z "$out" ]]; then + out="$1" + else + usage + fi + shift ;; + esac +done +[[ -n "$circuit" ]] || usage -vk="$ROOT/artifacts/$circuit/vk" +for entry in "${KNOWN_VERIFIERS[@]}"; do + known="${entry%%=*}" + want="${entry##*=}" + if [[ ! -d "$ROOT/circuits/$known" ]]; then + echo "error: circuits/$known is gone, but consumers compile it as $want." >&2 + echo " A renamed circuit directory renames the shipped contract; change KNOWN_VERIFIERS in $0 deliberately." >&2 + exit 1 + fi + got="$(verifier_name "$known")" + if [[ "$got" != "$want" ]]; then + echo "error: circuits/$known derives to $got, but consumers compile $want." >&2 + exit 1 + fi +done + +[[ -n "$contract_name" ]] || contract_name="$(verifier_name "$circuit")" +[[ -n "$out" ]] || out="$artifacts/$circuit/$contract_name.sol" + +vk="$artifacts/$circuit/vk" if [[ ! -f "$vk" ]]; then echo "error: no vk at $vk — unknown circuit '$circuit'? (run scripts/build.sh first)" >&2 exit 1 @@ -64,9 +123,20 @@ bb write_solidity_verifier -k "$vk" -o "$out" -t evm # via_ir consumers need the memory-safe annotation on every assembly block. perl -i -pe 's/assembly \{/assembly ("memory-safe") \{/g' "$out" -if [[ -n "$contract_name" ]]; then - # Only the concrete contract is renamed; the abstract base keeps its name. - perl -i -pe "s/contract HonkVerifier is BaseZKHonkVerifier/contract ${contract_name} is BaseZKHonkVerifier/g" "$out" +# Only the concrete contract is renamed; the abstract base keeps its name. +perl -i -pe "s/contract HonkVerifier is BaseZKHonkVerifier/contract ${contract_name} is BaseZKHonkVerifier/g" "$out" + +# Fail loudly if bb's output shape moved under the rewrites: consumers look +# the contract up by name and compile under via_ir, so a silently missed +# rewrite breaks them, not us. +concrete="$(grep -c '^contract .* is BaseZKHonkVerifier' "$out" || true)" +if [[ "$concrete" != 1 ]] || ! grep -q "^contract ${contract_name} is BaseZKHonkVerifier" "$out"; then + echo "error: $out: expected exactly one 'contract ${contract_name} is BaseZKHonkVerifier', found $concrete concrete contract(s)." >&2 + exit 1 +fi +if grep -qE 'assembly[[:space:]]*\{' "$out"; then + echo "error: $out: an assembly block escaped the memory-safe rewrite." >&2 + exit 1 fi -echo "wrote $out (contract $( [[ -n "$contract_name" ]] && echo "$contract_name" || echo HonkVerifier )); run 'forge fmt' in the consumer before diffing/committing." +echo "wrote $out (contract $contract_name); consumers run 'forge fmt' under their own foundry.toml before compiling." From d552546c2e6c72b753278ed69037a0fa1cf88431 Mon Sep 17 00:00:00 2001 From: xgreenx Date: Mon, 14 Sep 2026 17:24:38 +0100 Subject: [PATCH 2/2] docs: consumers compile the shipped verifier, they do not regenerate it The release.yml header, README and toolchain.env described a downstream `verifiers` job re-running bb over the vks. That is no longer the contract: the .sol ships in the tarball and the manifest, and a consumer downloads, checks sha256, runs forge fmt under its own foundry.toml, and compiles. Assisted-by: Claude Fable 5.1 Signed-off-by: xgreenx --- .github/workflows/ci.yml | 15 +++++---- .github/workflows/release.yml | 16 +++++++--- README.md | 60 ++++++++++++++++++++--------------- toolchain.env | 10 +++--- 4 files changed, 60 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f106315..8d1eec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,10 @@ name: CI # Two jobs. `circuits` proves that the committed sources build under the -# pinned toolchain: tests pass, every circuit compiles, and every vk -# generates. Nothing built here is kept — artifacts are never committed and -# ship exclusively as release assets, rebuilt from source by release.yml. +# pinned toolchain: tests pass, every circuit compiles, every vk generates, +# and every Solidity verifier generates under its pinned contract name. +# Nothing built here is kept — artifacts are never committed and ship +# exclusively as release assets, rebuilt from source by release.yml. # `dco` checks the Signed-off-by trailer CONTRIBUTING.md promises. # # Every third-party action is pinned by commit SHA, with the tag in a @@ -110,10 +111,12 @@ jobs: (cd "$dir" && nargo test) done - # Full build: proves every circuit compiles and every vk generates with + # Full build: proves every circuit compiles, every vk generates, and + # every Solidity verifier generates under its pinned contract name with # the pinned toolchain — the exact path release.yml runs to produce the - # release assets. The output is discarded; artifacts only ever ship - # from a release build. + # release assets, so a release cannot break on a step no PR exercised. + # The output is discarded; artifacts only ever ship from a release + # build. - name: Build artifacts (discarded) run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c4610d..7531f13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,8 @@ name: Release # release: # # libid-circuits--.tar.gz one per circuit, containing -# .json, vk, vk_hash +# .json, vk, vk_hash, +# .sol # manifest.json version, toolchain pins, and # sha256 of every tarball and # every file inside them @@ -16,9 +17,12 @@ name: Release # machine-specific paths nargo embeds), so a re-run reproduces the same # bytes. # -# Consumers (libid-contracts' `verifiers` CI job) download the tarballs by -# pinned tag, check them against the manifest's sha256s, and regenerate their -# Solidity verifiers from the vks with the bb version the manifest names. +# bb runs here and nowhere else. The Solidity verifier derives from the vk +# alone, so scripts/build.sh writes it next to the vk (bearer-link -> +# BearerLinkHonkVerifier.sol) and it ships in the same tarball. Consumers +# download by pinned tag, check the tarball and the .sol against the +# manifest's sha256s, run `forge fmt` under their own foundry.toml, and +# compile — no bb, no nargo downstream. # # Every third-party action is pinned by commit SHA, with the tag in a # comment, so a moved tag cannot change what executes. @@ -84,7 +88,9 @@ jobs: [ "$(bb --version | tail -1)" = "${BB_VERSION}" ] # The build.sh toolchain gate re-checks the versions; a runner with the - # wrong toolchain fails here instead of shipping wrong bytes. + # wrong toolchain fails here instead of shipping wrong bytes. The + # Solidity verifiers are written into artifacts// by the same + # script, so the packaging below ships and hashes them untouched. - name: Build artifacts from source run: ./scripts/build.sh --out artifacts diff --git a/README.md b/README.md index d5e196f..b24be14 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # libID circuits Noir zero-knowledge circuits for libID's login flows. The proving artifacts -(ACIR + verification keys) that the on-chain Solidity verifiers in -[libid-contracts] derive from, byte-for-byte, are **not committed** — they +(ACIR + verification keys) and the Solidity verifiers derived from them, +which [libid-contracts] compiles and deploys, are **not committed** — they ship exclusively as GitHub Release assets, rebuilt from these sources by the -release workflow under the pinned toolchain. +release workflow under the pinned toolchain. `bb` runs in this repo and +nowhere else. [libid-contracts]: https://github.com/libid-org/libid-contracts @@ -73,7 +74,12 @@ refuse to run under any other version. as produced); - `vk` — Barretenberg verification key (`bb write_vk --oracle_hash keccak`, keccak because the consumer is EVM); -- `vk_hash` — its 32-byte hash. +- `vk_hash` — its 32-byte hash; +- `.sol` — the EVM Solidity verifier + (`bb write_solidity_verifier` on the vk, plus the two rewrites described + under "Regenerating a Solidity verifier"), the concrete contract named + after the circuit directory: `bearer-link/BearerLinkHonkVerifier.sol`, + `oidc-google/OidcGoogleHonkVerifier.sol`. ```sh scripts/build.sh # build into ./artifacts/ (requires the pinned toolchain) @@ -85,23 +91,28 @@ produces identical bytes (the path normalization above removes the only machine-specific content), so a release built in CI is byte-identical to a local build from the same sources. -## Generating a Solidity verifier +## Regenerating a Solidity verifier + +`scripts/build.sh` writes every verifier. `scripts/gen-verifier.sh` is the +step it runs per circuit, and regenerates one from a vk alone — no nargo — +for example to byte-compare an unpacked release against a local `bb`: ```sh -scripts/gen-verifier.sh oidc-google Verifier.sol -scripts/gen-verifier.sh bearer-link BearerLinkHonkVerifier.sol \ - --contract-name BearerLinkHonkVerifier +scripts/gen-verifier.sh bearer-link # artifacts/bearer-link/BearerLinkHonkVerifier.sol +scripts/gen-verifier.sh oidc-google --artifacts ~/unpacked # from a downloaded release's vk +scripts/gen-verifier.sh oidc-google Verifier.sol --contract-name HonkVerifier ``` -This runs `bb write_solidity_verifier` on the locally built vk (run -`scripts/build.sh` first) and applies the -canonical post-processing: every `assembly {` becomes -`assembly ("memory-safe") {` (required by via_ir consumers), plus the -optional contract rename (bb always names the concrete contract -`HonkVerifier`; a consumer compiling both verifiers needs distinct names). **`forge fmt` is deliberately not run here** — this -repo carries no Foundry toolchain; the consumer formats the output under its -own `foundry.toml` before diffing or committing, which is exactly what -libid-contracts does. +The interchange format is raw `bb write_solidity_verifier` output plus +exactly two rewrites: every `assembly {` becomes `assembly ("memory-safe") {` +(required by via_ir consumers), and the concrete contract is renamed off +bb's fixed `HonkVerifier` to `HonkVerifier` (both verifiers must +compile in one project). The names the current circuits ship under are +pinned in the script and checked on every run, so renaming a circuit +directory fails the build instead of silently renaming the contract +consumers compile. **`forge fmt` is deliberately not run here** — this repo +carries no Foundry toolchain; the consumer formats the shipped file under +its own `foundry.toml` before compiling or committing it. ## Releases @@ -109,18 +120,17 @@ Publishing a GitHub Release tagged `v` builds the artifacts from source with the pinned toolchain (`scripts/build.sh`) and attaches: - `libid-circuits--.tar.gz` — one per circuit, containing - `.json`, `vk`, `vk_hash`; + `.json`, `vk`, `vk_hash`, `.sol`; - `manifest.json` — `{version, tag, toolchain: {nargo, bb}, tarballs: {: {sha256, files: {: sha256}}}}`. -## How consumers verify (the libid-contracts flow) +## Consuming a release -libid-contracts pins a release tag of this repo. Its CI downloads the -tarballs plus `manifest.json` from that release, checks the tarballs against -the manifest's sha256s, installs the bb version the manifest names, -regenerates its verifiers from the vks (write_solidity_verifier + memory-safe -rewrite + the contract rename), runs `forge fmt` over them, and byte-compares -against what it committed. +Pin a release tag. Download the circuit's tarball and `manifest.json`, check +the tarball's sha256 against the manifest, unpack, check `.sol` +against its entry in `files`, run `forge fmt` over it under your own +`foundry.toml`, and compile. No `bb`, no nargo: the verifier is derived +here, once, by the toolchain the manifest names. Verification keys under the pinned toolchain (nargo 1.0.0-beta.25, bb 5.2.0), for the release that drops `x-token`: diff --git a/toolchain.env b/toolchain.env index aeb905b..5b99b78 100644 --- a/toolchain.env +++ b/toolchain.env @@ -1,9 +1,9 @@ # Single source of truth for the proving toolchain. Everything that compiles -# a circuit or derives a verification key — scripts/build.sh, both GitHub -# workflows, and any consumer regenerating a Solidity verifier — reads the -# pins from here. Release artifacts are built by EXACTLY these versions; -# bumping either pin means re-cutting a release and rolling the downstream -# verifiers together with it (see README "Toolchain"). +# a circuit, derives a verification key or generates a Solidity verifier — +# scripts/build.sh, scripts/gen-verifier.sh and both GitHub workflows — +# reads the pins from here. Release artifacts are built by EXACTLY these +# versions; bumping either pin means re-cutting a release, which consumers +# roll their verifier deployments to (see README "Toolchain"). # # Install with: # noirup --version "$NARGO_VERSION"