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
23 changes: 17 additions & 6 deletions .github/actions/forge-build/action.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Install Foundry, restore the forge artifact cache, and build the contracts.
# Install Foundry, vendor the Honk verifiers, restore the forge artifact cache,
# and build the contracts.
#
# Four jobs need `forge build` before they can do anything else: solidity runs
# the tests over it, handles-drift shells to `forge fmt`, rust vendors the
# artifacts into the crate, and ts generates bindings from them. Repeating the
# install + cache + build stanza in each job is how the copies drift, so the
# sequence lives here once.
# Every job that needs `forge build` — solidity runs the tests over it, rust
# vendors the artifacts into the crate, ts generates bindings from them, and
# the publish dry-run and both publish jobs do the last two for real — runs
# this composite. Repeating the install + vendor + cache + build stanza in each
# job is how the copies drift, so the sequence lives here once.
#
# The cache key is the SAME in every job that uses this action — it is derived
# only from inputs.foundry-version and the checked-out sources — so whichever
Expand All @@ -30,6 +31,16 @@ runs:
with:
version: ${{ inputs.foundry-version }}

# The Honk verifiers are not in the checkout: they are gitignored, and
# scripts/vendor-circuit-verifiers.sh downloads them from the pinned
# libid-circuits release, refusing any tarball whose sha256 is not the
# literal in solidity/contracts/circuits/circuits.json. So every build,
# test, dry-run and publish compiles a download the pin has just checked.
# Before the cache step, so the vendored sources are in the key's hash.
- name: Vendor circuit verifiers
shell: bash
run: ./scripts/vendor-circuit-verifiers.sh

# `solidity/lib/**` hashes the checked-out submodule trees, so bumping a
# submodule pointer changes the key. The foundry version is in the key too:
# artifacts embed solc metadata, and a stale `out/` from another compiler
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ name: CI
# cache key, and whichever run warms the cache first makes the build a fast
# no-op everywhere else.
#
# Neither generated tree is committed (rust/contracts/artifacts and
# ts/packages/contracts/src/abis are gitignored), so every job that compiles
# the crate or the package regenerates it first — publishing included.
# Nothing generated is committed: the vendored Honk verifiers,
# rust/contracts/artifacts and ts/packages/contracts/src/abis are all
# gitignored. The forge-build composite vendors the verifiers before it
# builds, and every job that compiles the crate or the package regenerates
# its tree first — publishing included.
#
# Job ids are stable on purpose (solidity, handles-drift, rust, ts, dco):
# Job ids are stable on purpose (solidity, generated-tables, rust, ts, dco):
# the release/publish jobs at the bottom of this file hang their `needs:` off
# these names.
#
Expand Down Expand Up @@ -93,7 +95,9 @@ jobs:
# Generated files match their source. handles.json generates the platform
# constants and the handle vector table for every language; a drift between
# the source and a committed output leaves every per-language test green
# while the languages disagree with each other.
# while the languages disagree with each other. The Honk verifiers are not
# checked here: nothing of theirs is committed but the pin, and the
# forge-build composite holds every download to it.
# ---------------------------------------------------------------------------
generated-tables:
name: Generated tables
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,13 @@ rust/contracts/artifacts/
ts/packages/contracts/src/abis/
ts/packages/contracts/src/calls/

# Vendored from the pinned libid-circuits release, never committed: the Honk
# verifiers are that repository's release asset, and circuits.json beside them
# (the version and each tarball's sha256, which IS committed) is the pin every
# download is checked against. CI's forge-build action vendors them before
# every build, test, dry-run and publish. Locally, before `forge build`:
# scripts/vendor-circuit-verifiers.sh # -> solidity/contracts/circuits/*HonkVerifier.sol
solidity/contracts/circuits/*HonkVerifier.sol

# Python bytecode from the generator scripts.
__pycache__/
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ solidity/ # Foundry project root
contracts/
ceremony/ # NotaryService, CeremonyProofVerifier, Platform Verifiers,
# GoogleJwtRoots
circuits/ # the UltraHonk verifiers the Platform Verifiers pin:
# circuits.json pins a libid-circuits release, the
# Solidity is vendored from it and not committed
identity/ # IdentityNames, handle normalization
factory/ # LibidFactory: deterministic CREATE3 deployment
WTIA9.sol # wrapped TIA
Expand All @@ -23,21 +26,26 @@ rust/contracts/ # libid-contracts crate: alloy bindings + embedded artifact
ts/packages/contracts/ # @libid/contracts: viem ABIs, call builders, identity helpers
scripts/
vendor-artifacts.sh
vendor-circuit-verifiers.sh
regen-identity-handles.py
```

## Build and test

```sh
git submodule update --init --recursive
scripts/vendor-circuit-verifiers.sh # -> solidity/contracts/circuits/*HonkVerifier.sol
cd solidity
forge build
forge test
```

`forge build` is the input to the two generated trees, neither of which is
committed. Generate them once after cloning, and again after any change to a
contract they cover:
Nothing generated is committed. The Honk verifiers are downloaded from the
pinned [libid-circuits](https://github.com/libid-org/libid-circuits) release
(see [Circuit verifiers](#circuit-verifiers)), so a fresh clone vendors them
before its first `forge build`, which needs curl, jq, tar and forge. `forge
build` is in turn the input to the two generated trees. Generate them once
after cloning, and again after any change to a contract they cover:

```sh
scripts/vendor-artifacts.sh # -> rust/contracts/artifacts (the crate embeds
Expand All @@ -47,9 +55,10 @@ pnpm -C ts codegen # -> ts/packages/contracts/src/abis (tsc reads
# these, so `pnpm -C ts build` needs them)
```

CI runs both in every job that compiles the crate or the package, and again in
the publish jobs — the published crate and npm package carry the generated
output even though git does not.
CI runs all three before every build, test, dry-run and publish: the
forge-build action vendors the verifiers before it builds, and every job that
compiles the crate or the package regenerates its tree — the published crate
and npm package carry the generated output even though git does not.

## Handle vectors

Expand All @@ -66,6 +75,31 @@ This generates `solidity/contracts/identity/HandleVectors.sol`,
`ts/packages/contracts/src/identity/handleVectors.ts`; CI's handle-tables job
fails when any of them drifts from `handles.json`.

## Circuit verifiers

The ceremony circuits' UltraHonk verifiers are not written here. `bb` derives
each from its circuit's verification key, and
[libid-circuits](https://github.com/libid-org/libid-circuits) runs `bb` and
ships the Solidity in its release tarballs. `scripts/vendor-circuit-verifiers.sh`
downloads it into `solidity/contracts/circuits/`, formatted, where `forge
build` compiles it and the crate embeds it, so no consumer runs `bb`. The
files are gitignored: they are another repository's release asset, and the
pin says which bytes they must be.

`solidity/contracts/circuits/circuits.json` is the pin — the release version
and each tarball's sha256, committed here and checked against every download.
To move it, download the new release's tarballs, take their digests with
`shasum -a 256`, write the version and the digests into `circuits.json`, then:

```sh
scripts/vendor-circuit-verifiers.sh # rewrite the verifiers from the pin
```

CI's forge-build action runs the same script before every build, test,
dry-run and publish, refusing any tarball whose digest is not the pin's; a
release cannot ship a verifier that is not what the pinned circuits release
shipped.

## Releasing

The Rust crate ([`libid-contracts`](https://crates.io/crates/libid-contracts))
Expand Down
42 changes: 27 additions & 15 deletions rust/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
Typed [alloy](https://github.com/alloy-rs/alloy) bindings, embedded forge
artifacts, and deploy/upgrade helpers for the libid identity stack: the
ceremony verification path (`NotaryService`, `CeremonyProofVerifier`, the
three launch Platform Verifiers it routes to, and `GoogleJwtRoots`, the
signing keys the `google/v1` verifier trusts), the naming system
(`IdentityNames`), and the deterministic deployment factory (`LibidFactory`).
three launch Platform Verifiers it routes to, the two UltraHonk verifiers
they pin, and `GoogleJwtRoots`, the signing keys the `google/v1` verifier
trusts), the naming system (`IdentityNames`), and the deterministic
deployment factory (`LibidFactory`).

The compiled artifacts are vendored into the crate, so a consumer can deploy
or upgrade the whole stack against a live network with **zero filesystem
dependencies at runtime**. They are generated, not committed:
`scripts/vendor-artifacts.sh` produces `artifacts/` from `solidity/` and CI
runs it before every build, test and publish. Working in this repo, run it
once after cloning — the crate embeds the directory with `include_dir!`, so
until it exists `cargo build` fails at macro expansion. Signing stays on the
runs it before every build, test and publish. Working in this repo, run
`scripts/vendor-circuit-verifiers.sh` and then it once after cloning — the
Honk verifiers are vendored too, and the crate embeds the directory with
`include_dir!`, so until it exists `cargo build` fails at macro expansion. Signing stays on the
consumer's side: every helper is generic over an alloy `Provider` you have
already wired with a wallet.

Expand Down Expand Up @@ -74,20 +76,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
```

## Example: deploy a Platform Verifier
## Example: deploy a Platform Verifier on its circuit's Honk verifier

A Platform Verifier pins the bb-generated UltraHonk verifier for its circuit
by address and by code hash, holds a Notary Service only if its profile
notarizes anything, and caps its parameters. `platform_verifier::Initializer`
knows those rules: it reads the code hash off the chain, refuses what the
contract would refuse, and builds the exact `initialize` call. The Honk
verifier comes from a `libid-circuits` release and is deployed beforehand.
contract would refuse, and builds the exact `initialize` call.

The Honk verifier is vendored here too, from the pinned `libid-circuits`
release. `circuits::deploy_honk_verifier` deploys the two libraries it links
(`RelationsLib`, `ZKTranscriptLib`), links them in and deploys the verifier —
three transactions — and returns the address the initializer pins.

```rust,no_run
use alloy::{primitives::Address, providers::ProviderBuilder};
use libid_contracts::{
bindings::ceremony::{CeremonyProofVerifier, XPlatformVerifier},
platform_verifier::{deploy_platform_verifier, Initializer, TlsNotaryRoots},
circuits::deploy_honk_verifier,
platform_verifier::{deploy_platform_verifier, Initializer, PlatformVerifier, TlsNotaryRoots},
Artifacts,
};

Expand All @@ -97,8 +104,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.wallet(/* your signer */ todo!())
.connect_http("https://rpc.example.org".parse()?);
let artifacts = Artifacts::embedded();
let (owner, notary, honk_verifier, proof_verifier): (Address, Address, Address, Address) =
todo!();
let (owner, notary, proof_verifier): (Address, Address, Address) = todo!();

// The circuit `x/v1` proves under is `bearer-link`; `PlatformVerifier::circuit`
// says so, and this deploys its verifier with the libraries linked.
let honk_verifier =
deploy_honk_verifier(&provider, &artifacts, PlatformVerifier::X.circuit(), None).await?;

// `x/v1`: two notarized sessions, so a Notary Service is required.
// `Initializer::Google` takes `GoogleRoots` instead — no Notary Service
Expand Down Expand Up @@ -141,9 +152,10 @@ Other entry points:
canonical cross-network factory where missing and deploy protocol proxies
through it at name-derived CREATE3 addresses.
- `deploy::load_linked_bytecode` (or `Artifacts::linked_bytecode`) — deploys
and links external libraries before returning the creation bytecode. Nothing
covered today links one; the UltraHonk verifiers the ceremony circuits bring
will.
and links external libraries before returning the creation bytecode; what
`circuits::deploy_honk_verifier` goes through.
- `circuits::version` — the `libid-circuits` release the vendored verifiers
came from, for a consumer that names a deployment after its artifact.
- `platform_verifier::codehash_at` — the code hash `setTrustRoots` wants
when a Platform Verifier is rotated onto a new circuit release.
- `Artifacts::method_identifiers` — selector extraction from the vendored
Expand Down
82 changes: 61 additions & 21 deletions rust/contracts/src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ pub const COVERED: &[(&str, &str)] = &[
("CeremonyProofVerifier", "CeremonyProofVerifier"),
("ERC1967Proxy", "ERC1967Proxy"),
("GoogleJwtRoots", "GoogleJwtRoots"),
// ceremony: the launch Platform Verifiers (one per profile; the UltraHonk
// verifier each pins comes from the circuits release, not from here)
// ceremony: the launch Platform Verifiers (one per profile)
("XPlatformVerifier", "XPlatformVerifier"),
("GitHubPlatformVerifier", "GitHubPlatformVerifier"),
("GooglePlatformVerifier", "GooglePlatformVerifier"),
// circuits: the UltraHonk verifiers the Platform Verifiers pin, vendored
// from the libid-circuits release, each with the two libraries it links
("BearerLinkHonkVerifier", "BearerLinkHonkVerifier"),
("BearerLinkHonkVerifier", "RelationsLib"),
("BearerLinkHonkVerifier", "ZKTranscriptLib"),
("OidcGoogleHonkVerifier", "OidcGoogleHonkVerifier"),
("OidcGoogleHonkVerifier", "RelationsLib"),
("OidcGoogleHonkVerifier", "ZKTranscriptLib"),
// identity
("IdentityNames", "IdentityNames"),
// ens (deployed once per network, not CREATE3-canonical)
Expand Down Expand Up @@ -82,17 +89,22 @@ impl Artifacts {

/// The raw artifact JSON for `out/<file>.sol/<contract>.json`.
pub fn raw(&self, file: &str, contract: &str) -> Result<serde_json::Value> {
let rel = format!("{file}.sol/{contract}.json");
self.read_json(&format!("{file}.sol/{contract}.json"))
}

/// Any JSON file at `rel` inside the source: an artifact, or the
/// `circuits.json` pin the vendor script copies in beside them.
pub(crate) fn read_json(&self, rel: &str) -> Result<serde_json::Value> {
let contents = match &self.source {
Source::Embedded => EMBEDDED
.get_file(&rel)
.get_file(rel)
.and_then(|f| f.contents_utf8())
.map(str::to_owned)
.ok_or_else(|| Error::Artifact {
detail: format!("no embedded artifact {rel}"),
})?,
Source::Dir(dir) => {
let path = dir.join(&rel);
let path = dir.join(rel);
std::fs::read_to_string(&path).map_err(|e| Error::Artifact {
detail: format!("failed to read artifact {}: {e}", path.display()),
})?
Expand Down Expand Up @@ -130,11 +142,12 @@ impl Artifacts {

/// Creation bytecode with every external library it references deployed
/// (recursively) through `provider` and linked in. Mirrors what forge does
/// automatically. Nothing covered today links a library; the UltraHonk
/// verifiers the ceremony circuits bring link `ZKTranscriptLib`, and this
/// is the path they will deploy through. For artifacts with no link
/// references this behaves like [`Self::bytecode_named`] (no transaction
/// is sent).
/// automatically. The two UltraHonk verifiers are what links a library
/// today — `RelationsLib` and `ZKTranscriptLib`, vendored beside each —
/// and [`deploy_honk_verifier`](crate::circuits::deploy_honk_verifier)
/// is the one call that takes them through here and deploys the result.
/// For artifacts with no link references this behaves like
/// [`Self::bytecode_named`] (no transaction is sent).
///
/// `sender` opts into explicit nonce management (see
/// [`deploy_contract_from`](crate::deploy::deploy_contract_from)).
Expand Down Expand Up @@ -207,8 +220,8 @@ mod tests {
use super::*;

/// Every covered contract's creation bytecode is present and non-empty.
/// Only the hex is checked here so a future artifact with link
/// placeholders still passes; linking is the anvil tests' business.
/// Only the hex is checked here so the artifacts with link placeholders
/// (the Honk verifiers) pass too; linking is the anvil tests' business.
#[test]
fn every_covered_contract_has_bytecode() {
let artifacts = Artifacts::embedded();
Expand All @@ -223,23 +236,50 @@ mod tests {
}
}

/// Contracts without link references decode straight to bytes — which is
/// every covered contract today, so this doubles as the check that none of
/// them silently grew a library dependency the vendor script must follow.
/// Contracts without link references decode straight to bytes, and the
/// ones with them are exactly the two Honk verifiers — so this doubles
/// as the check that nothing else silently grew a library dependency,
/// and that every library a linked contract names is covered under its
/// own file, where the vendor script and the linker look for it.
#[test]
fn unlinked_contracts_decode() {
fn unlinked_contracts_decode_and_linked_ones_are_the_honk_verifiers() {
let artifacts = Artifacts::embedded();
let mut linked = Vec::new();
for &(file, contract) in COVERED {
if artifacts
.link_references(file, contract)
.unwrap()
.is_empty()
{
let refs = artifacts.link_references(file, contract).unwrap();
if refs.is_empty() {
let bytecode = artifacts
.bytecode_named(file, contract)
.unwrap_or_else(|e| panic!("{file}.sol:{contract}: {e}"));
assert!(!bytecode.is_empty());
continue;
}
linked.push((file, contract));
let err = artifacts.bytecode_named(file, contract).unwrap_err();
assert!(
err.to_string().contains("unresolved link references"),
"{file}.sol:{contract}: {err}"
);
for (path, libs) in &refs {
let stem = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap();
for library in libs.as_object().unwrap().keys() {
assert!(
COVERED.contains(&(stem, library.as_str())),
"{file}.sol:{contract} links {stem}.sol:{library}, which is not covered"
);
}
}
}
linked.sort_unstable();
assert_eq!(
linked,
[
("BearerLinkHonkVerifier", "BearerLinkHonkVerifier"),
("OidcGoogleHonkVerifier", "OidcGoogleHonkVerifier"),
]
);
}
}
Loading
Loading