Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ name: Release
# release:
#
# libid-circuits-<version>-<circuit>.tar.gz one per circuit, containing
# <package>.json, vk, vk_hash
# <package>.json, vk, vk_hash,
# <Contract>.sol
# manifest.json version, toolchain pins, and
# sha256 of every tarball and
# every file inside them
Expand All @@ -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.
Expand Down Expand Up @@ -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/<circuit>/ by the same
# script, so the packaging below ships and hashes them untouched.
- name: Build artifacts from source
run: ./scripts/build.sh --out artifacts

Expand Down
60 changes: 35 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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;
- `<Contract>.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)
Expand All @@ -85,42 +91,46 @@ 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 `<Circuit>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

Publishing a GitHub Release tagged `v<version>` builds the artifacts from
source with the pinned toolchain (`scripts/build.sh`) and attaches:

- `libid-circuits-<version>-<circuit>.tar.gz` — one per circuit, containing
`<package>.json`, `vk`, `vk_hash`;
`<package>.json`, `vk`, `vk_hash`, `<Contract>.sol`;
- `manifest.json` — `{version, tag, toolchain: {nargo, bb}, tarballs:
{<tarball>: {sha256, files: {<name>: 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 `<Contract>.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`:
Expand Down
14 changes: 12 additions & 2 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <Contract>.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
Expand Down Expand Up @@ -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"
134 changes: 102 additions & 32 deletions scripts/gen-verifier.sh
Original file line number Diff line number Diff line change
@@ -1,52 +1,111 @@
#!/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 <circuit> <out.sol> [--contract-name X]
# scripts/gen-verifier.sh <circuit> [<out.sol>] [--artifacts <dir>] [--contract-name X]
#
# <circuit> directory name under artifacts/ (e.g. oidc-google,
# bearer-link)
# <out.sol> output path for the Solidity source
# <circuit> directory name under circuits/ and <artifacts>/
# (bearer-link, oidc-google)
# <out.sol> output path; default <artifacts>/<circuit>/<Contract>.sol,
# next to the vk, which is the release layout
# --artifacts <dir> 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 <circuit>
# (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 <Circuit>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/<circuit>/vk — no nargo, no recompile.
# toolchain.env) and <artifacts>/<circuit>/vk — no nargo, no recompile.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../toolchain.env
# shellcheck disable=SC1091
source "$ROOT/toolchain.env"

if [[ $# -lt 2 ]]; then
echo "usage: $0 <circuit> <out.sol> [--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 <circuit> [<out.sol>] [--artifacts <dir>] [--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 <circuit> <out.sol> [--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
Expand All @@ -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."
10 changes: 5 additions & 5 deletions toolchain.env
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading