Desired-state configuration for the libid identity stack, one file per
network, plus the libid-deploy binary and the GitHub Actions that apply a
file to its chain with an AWS KMS signer.
The model is DECLARATIVE:
networks/<name>.tomldeclares what should exist on a chain — including every address, pre-filled up front. Canonical contracts live at CREATE3-deterministic addresses, so the file carries the full address table before the chain has anything on it;validaterejects a canonical key whose value is not exactlypredict_address(factory, name), naming the expected value.- Deployed-vs-not is determined from chain state (
eth_getCodeat the declared address / the factory'sdeployedAtrecord) — never from config emptiness. libid-deploy plancompares the declarations with the chain, read-only: each component is "declared + present" (ok) or "declared + missing" (DEPLOY — apply would put it at exactly the declared address). A wrong declared address never gets that far: it fails validation at load.libid-deploy applydeploys whatever the CHAIN lacks, re-sends the idempotent configuration, and performs explicitly requested upgrades. It never rewrites the file — after an apply the config is byte-identical, and there is no write-back PR. Integration tests can therefore use identical config data regardless of which chain (or how little of the stack) exists yet.
Contract bytecode is embedded in the binary through the
libID-contracts crate —
the core stack, the Platform Verifiers and the ceremony circuits' Honk
verifiers alike, compiled once upstream from the pinned sources. There is
no forge build, no bb and no artifact directory anywhere in this
repository. The platform tables come from libid-identity and
libid-profiles, generated from the same sources the contracts are, so
nothing here restates a value the chain also holds.
Five UUPS proxies, in dependency order: the four libid-contracts' own
script/Deploy.s.sol deploys, in its order, and the handle escrow right
after the identity registry it resolves through:
- NotaryService — the ONE place a notary attestation is
authenticated. It derives the digest from the attested bytes itself and
charges the Notary Fee. Deployed first so its proxy address can be
wired into every consumer's
initialize. - CeremonyProofVerifier — the Supported Version Set: which Platform
Verifier answers for a
(platformId, verifierVersion)pair. Without it the identity registry'sproofVerifierreads zero and every resolver reverts. - IdentityRegistry — the identity registry, keyed by the SHA-256 nodes the circuits prove and pointed at the Proof Verifier. Which platforms it knows is compiled in from the ceremony profiles.
- HandleEscrow — value sent to a handle node before anyone holds it,
claimed by the holder the registry binds the handle to.
initializetakes the registry's address and refuses one whosenodeKeyed()is not true. No setter moves the escrow to another registry afterwards. - GoogleJwtRoots — the Google signing keys the
google/v1Platform Verifier trusts, verified through the Notary Service like any other notarized session. It deploys EMPTY: point a keeper at it, or every Google binding revertsUntrustedModulus.
Then two steps Deploy.s.sol does not have:
- A Honk verifier per ceremony circuit — the bb-generated UltraHonk verifier each platform's proofs are checked under, deployed through the factory under a CREATE3 name carrying the pinned circuits release, unless the Platform Verifiers already use one with the same code.
- A Platform Verifier per platform —
XPlatformVerifier,GitHubPlatformVerifier,GooglePlatformVerifier— deployed behind its own CREATE3 proxy, initialized with(owner, notary, honkVerifier)(Google: the JWT root list instead of a notary), and registered into the Supported Version Set withCeremonyProofVerifier.setVerifier(platformId, 1, verifier). Until that registration lands,acceptsBindingsis false for the platform andbindrevertsUnknownVersion.
Not from here. Each derivation runs once, in the repository that owns its
tool: libID-circuits runs
bb and publishes each circuit's generated verifier in its release
tarball, libid-contracts vendors that Solidity from the release it pins
(by sha256 literal, downloaded in its CI, never committed), compiles it
beside the Platform Verifiers under one foundry.toml, and embeds the
artifacts in the crate. This repository deploys them. The three libid-*
pins in bin/libid-deploy/Cargo.toml are the one place every artifact
moves from — the circuits release the Honk verifiers derive from included
— and Cargo.lock is the only other file a bump touches.
The circuits release the embedded verifiers came from is read back out of
the crate (libid_contracts::circuits::version) and becomes part of each
verifier's factory name below, so the name follows the pin and cannot be
restated. The crate's bindings are sol! over the compiled ABIs, so they
cannot drift from the artifacts.
Each Platform Verifier implementation compiles in the runtime code hash of
its circuit's verifier (circuitCodehash()): initialize, setTrustRoots
and verify revert WrongCircuit for any other code. A bb verifier embeds
its verification key as code constants and exposes no getter, so the code
hash is the only handle on which circuit a deployed verifier answers for.
Apply deploys the verifier and checks its on-chain code hash against the
vendored one (circuit_codehash_at) before wiring it. There are two
circuits, not three: oidc-google proves the Google JWT, and
bearer-link ties a token exchange to an identity for X and GitHub alike,
because their statements are byte-identical — so both TLSNotary platforms
pin one deployed verifier.
A Platform Verifier whose verifier holds the vendored code keeps it,
under whatever name it was deployed, so a circuits release that rebuilds
the same verifiers deploys and rotates nothing. Otherwise the verifier
deploys through the factory under libid.circuits.<circuit>.<version>,
and a second run finds code at that address and sends nothing. A
circuits release with new code is a new name and a new address, and a new
Platform Verifier implementation that pins its hash: apply rotates onto it
only with --upgrade <platform>-platform-verifier in the same run, then
sends setTrustRoots. The proxy and its registration do not move.
Each verifier is bb's optimized template, one contract that links nothing: a fresh chain pays for two verifiers, and a verifier's code hash — the one the Platform Verifiers pin — is the same on every chain.
Both verifiers are under 17 KiB of runtime code, under the EIP-170 limit of 24576; the anvil tests run the default code-size limit, so their passing is the proof.
Every top-level (entry) contract deploys THROUGH the deterministic
LibidFactory via CREATE3, with salt = keccak256(name) for a fixed
canonical name. The factory itself lives at one address on every EVM
network its deployer deploys to (via the keyless Arachnid CREATE2 deployer,
with init code whose only varying input is accounts.deployer, the genesis
admin), so each entry address is a pure function of the deployer and the
name — computable before anything is deployed:
cargo run -- plan --network networks/eden-testnet.toml --print-addressesOne deployer key per environment: testnet's lives in the libid-testnet AWS
account and serves eden-testnet, Sepolia and Arbitrum Sepolia, mainnet's in
libid-mainnet, so the two environments have different tables. The authoritative name table
(bin/libid-deploy/src/names.rs). Renaming an entry = a NEW address,
forever, on every network — names are frozen. The testnet table, deployer
0xdaeb247f5a90c53f2d7a80a81f6cb6acb0d8b907:
| Config key | Canonical name | Address (testnet) |
|---|---|---|
contracts.factory |
— (CREATE2, frozen init code) | 0x5294f6c0f1f086f70c9b510ba7990085d5976281 |
contracts.notary_service |
libid.NotaryService |
0xfcf39134654e3542b956ee6e315ca650e6ba3b8c |
contracts.ceremony_proof_verifier |
libid.CeremonyProofVerifier.2 |
0x6e4585c4f1ac48a20f49bb61163c466dc4f35004 |
contracts.identity_registry |
libid.IdentityRegistry.2 |
0x58ac0cd5ec19c850a75d6183b3c3b2de692fb20d |
contracts.handle_escrow |
libid.HandleEscrow.4 |
0xe7eb0c675b757c12b1312df652242b863624bfab |
contracts.google_jwt_roots |
libid.GoogleJwtRoots |
0x4d9c43d4cf161452b7f66415fa492f4fc42ea583 |
contracts.x_platform_verifier |
libid.XPlatformVerifier.2 |
0x655be95a91a26b4ade476a8992ee25c98bb774a6 |
contracts.github_platform_verifier |
libid.GitHubPlatformVerifier.2 |
0x13cdf6d5666a42e0eb586809f46ce10e469d11f7 |
contracts.google_platform_verifier |
libid.GooglePlatformVerifier.2 |
0x355143c93a645ae914b1315eb1b580072207e0f2 |
The mainnet table, deployer 0x7e00d33b5c571ca2b2879309c4846ddd80f4128e
(Ethereum, Arbitrum One, Base, BNB Smart Chain and Eden, networks/{ethereum,arbitrum,base,bsc,eden}.toml):
| Config key | Address (mainnet) |
|---|---|
contracts.factory |
0x4ab4177683b8c3f40c487f9e765f77f0c5f5a023 |
contracts.notary_service |
0x4f624a90ae152e524b12258ce6aa9df6ed70ddfb |
contracts.ceremony_proof_verifier |
0xcfee504d5a290d72c52b9ffd61d54444d727233b |
contracts.identity_registry |
0xf39343febda25caa8c3861954e33f8a0189d4fda |
contracts.handle_escrow |
0xf2731ab2c9451a9191e0ef64d7fd5352068eb1b5 |
contracts.google_jwt_roots |
0xa2c959b2940bdb8f97af52401c1603000a3aa46b |
contracts.x_platform_verifier |
0xe9b6d548a070fcaa5b43344c0b495d982aaa9fc4 |
contracts.github_platform_verifier |
0x4f6d4a3486a00b260a1dd91f9dde6f333de6861f |
contracts.google_platform_verifier |
0x731f3943879ccf40a21a7b84e9b7ec01776cb9cc |
The circuit verifiers go through the same factory but are not in that
table and not in any network file: their names carry the circuits release
libid-contracts vendors, so a release with new code moves them. The
verifiers in use are libid-circuits 0.7.1's, under its names;
libid-circuits 0.8.0 builds the same code, so they stay:
| Component | Name | Address (testnet) |
|---|---|---|
circuits.bearer-link |
libid.circuits.bearer-link.0.7.1 |
0x90aefe0648a496183c18bef07245c5ed253bee2c |
circuits.oidc-google |
libid.circuits.oidc-google.0.7.1 |
0x787117a19f0bb1df86be05094a749c47d2bd1633 |
On mainnet they are 0xae0674895f5a8a63d2fa94a9e50f41152b9de50b and
0xaa3e32ccd6bb0c497bf203deb3608eb986d3b571.
plan --print-addresses prints the canonical table and the addresses the
pinned circuits release names, where a verifier deploys when no Platform
Verifier uses one with its code.
Implementations stay plain CREATE deploys: their addresses are referenced by a proxy slot, not canonical, and upgrades replace them without moving any entry address.
How apply gets there, in order:
- Onboarding gate. The keyless CREATE2 deployer
(
0x4e59b4…956C) must exist or be installable via its presigned pre-EIP-155 transaction (apply funds the one-time signer with exactly 0.01 native and broadcasts it). There is deliberately no fallback: a chain that rejects the transaction (EIP-155-only) or ships different CREATE2 semantics cannot host the stack and apply hard-errors. - Factory.
FactoryGenesis::ensuredeploys the LibidFactory implementation and proxy at their CREATE2 addresses, the proxy's init code carryingaccounts.deployeras the genesis admin (idempotent). - Canary. The factory must sit at exactly its predicted address; any mismatch means the chain derives CREATE2 addresses non-standardly (zkSync-Era-style) and apply aborts before sending anything else.
- Ownership.
factory.deployis owner-gated (Ownable2Step) and the genesis owner isaccounts.deployer, so apply refuses any signer but that key, on every chain, anvil included. A factory that an earlier apply handed to a differentownerhas to be handed back before new names can deploy. - CREATE3 deploys. Every entry contract goes through
factory.deploy(name, creationCode)and is verified to land onpredict_address(factory, name).
Every value in a network file is public: addresses, accounts, the fee.
The endpoint is not in the file: it is the RPC_URL secret of the
network's GitHub environment, or --rpc-url on a host. The other secret
in the flow is the KMS key, which never leaves AWS.
| Section | Kind | Contents |
|---|---|---|
[network] |
input | name, chain_id (apply refuses a mismatch, whichever endpoint answers); rpc_url only where the environment's endpoint is no secret (local-dev's compose service) — a real network's file names none, and --rpc-url supplies it |
[aws] |
input | region, kms_deployer (key id / alias/... / ARN; the default signer) |
[accounts] |
input | notary (the notary signer — see below), owner (the operational owner the factory ends up with; empty = the deployer) — addresses of keys, not contracts |
[notary_service] |
input | fee_wei — what one attestation verification costs, as a decimal string; fee_recipient — where sweep sends the accrued fees (optional) |
[contracts] |
declared | factory, notary_service, ceremony_proof_verifier, identity_registry, handle_escrow, google_jwt_roots, x_platform_verifier, github_platform_verifier, google_platform_verifier — always present, pre-filled with the canonical table, validated against the prediction |
[safes.<name>] |
input + declared | owners, threshold — what the Safe ends up with; address — pre-filled with the address the name predicts, validated against it |
The circuit verifiers are not in the file. They are a property of the binary's contracts pin — which carries the circuits release — not of a network: a chain keeps the verifiers its Platform Verifiers use while their code matches the pin, and a new one's address derives from the pin the same way the canonical table derives from its names.
The [accounts].owner flow: the factory's genesis owner is
accounts.deployer, the apply signer. apply needs factory ownership only
while it has names left to factory.deploy; at the end of every run it
converges ownership onto owner. Empty owner = the deployer, exact. A
different owner makes apply INITIATE the Ownable2Step handover (that key
must acceptOwnership itself); on a dev chain (anvil/hardhat, detected via
web3_clientVersion) apply completes the handover by impersonation, so a
local stack ends fully owned by the declared operational owner.
The notary split:
accounts.notaryis the notary signer — the identity whose attestations the stack accepts.contracts.notary_serviceis the Notary Service contract (a UUPS proxy) that holds the trusted key set; every other contract takes the proxy address at initialize and verifies through it.- On a fresh deploy the Notary Service deploys first
(
initialize(owner = deployer, notary = accounts.notary, fee = notary_service.fee_wei)) and its proxy is wired into everything else. - Rotation is half declarative:
planshows whether the declared signer is trusted,applysends the onesetNotarythat adds it. Dropping the outgoing key is a separate governance call on purpose — the service holds a SET so a rotation can overlap, and which key to stop trusting is not something the file can say.
Declared-address semantics:
- Every canonical key is always present and pre-filled with the
canonical table;
validateerrors on a value that does not equalpredict_address(factory, name)(naming the expected value) and on an empty canonical key. Presence on-chain is checked viaeth_getCodeat plan/apply time;applydeploys whatever the CHAIN lacks and never touches the file. - The fresh-deploy guard keys on chain state:
--confirm-fresh-deployis required exactly when the FACTORY has no code on-chain (a virgin network — that first apply publishes the entire declared stack). With the factory present, apply converges incrementally without the flag. - After registration apply reads
acceptsBindingsfor every platform and fails if the registry still refuses one.
Each [safes.<name>] table is a Safe multisig at an address that depends
only on accounts.deployer and the name, the same on every EVM chain that
deployer deploys to. Apply creates it through the canonical Safe v1.4.1
contracts, which plan checks by code hash and apply requires:
| Contract | Address |
|---|---|
SafeProxyFactory |
0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67 |
SafeL2 (the singleton) |
0x29fcB43b46531BcA003ddC8FCB67FFE91900C762 |
CompatibilityFallbackHandler |
0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99 |
MultiSendCallOnly |
0x9641d764fc13c8B624c04430C7356C1C7C8102e2 |
- Create.
createProxyWithNonceon theSafeL2singleton, withsetup(owners = [deployer], threshold = 1, fallback handler)and salt noncekeccak256("libid.safes.<name>"). Renaming a table is a new address. - Hand over. While the deployer is an owner and the threshold is 1,
apply sends one Safe transaction, signed by the deployer: a
MultiSendCallOnlybatch ofaddOwnerWithThreshold,removeOwnerandchangeThresholdthat sets exactlyownersandthreshold. The deployer stays only ifownerslists it. - Owners hold it. Once the deployer cannot sign alone, apply sends
nothing to the Safe.
planreports a differing owner set and prints the calls, each from the Safe to itself, for its owners to sign in the Safe app.
plan --print-addresses prints each declared Safe's address.
# parse + sanity checks (add --check-rpc to also probe the endpoint)
cargo run -- validate --network networks/eden-testnet.toml
# read-only diff against the chain; --json for machine output. The plan
# leads with the onboarding gate (CREATE2 deployer + factory) and quotes
# the predicted CREATE3 address of everything missing — even on an empty
# chain. --print-addresses prints the canonical table offline and exits.
cargo run -- plan --network networks/eden-testnet.toml
# converge; the signer defaults to aws.kms_deployer (needs ambient AWS
# credentials), or pass a local key for anvil rehearsal
cargo run -- apply --network networks/eden-testnet.toml \
--signer <64-hex-key-or-kms-id> [--upgrade identity-registry] [--yes] \
[--confirm-fresh-deploy]The --signer spec is classified by shape: 64 hex chars is a local private
key, anything else goes to AWS KMS (region/credentials from the ambient AWS
environment). An all-hex value of the wrong length is rejected as a mangled
key rather than shipped to AWS.
Every command that contacts a chain takes --rpc-url <URL>, and for a
real network that is where the endpoint comes from: its file names none,
because where a node listens is a property of the caller's environment,
not of the network, and a provider's endpoint carries a key. A file may
name the endpoint its own environment reaches — local-dev names its
compose service — and then the flag, for a caller somewhere else, wins
outright with the file as the default. Only the transport moves: the
declared chain id is still enforced against whatever answers, every
address is still the file's, and the file is still never rewritten. A
value that does not parse or does not answer is an error, never a
fallback; a file without an endpoint and no flag is an error naming both.
validate contacts the chain only under --check-rpc, so without it a
file needs no endpoint. plan --print-addresses is offline and rejects the
flag.
In the apply workflow the flag is the RPC_URL secret of the network's
GitHub environment, passed on every call; a network whose environment
lacks it fails before anything is read. Everything written about an
endpoint — the plan's first line, the apply log, a prompt — names it by
origin alone, scheme, host and port, so the key reaches no step summary.
Upgrade components: notary-service, proof-verifier, identity-registry,
handle-escrow, google-jwt-roots, x-platform-verifier,
github-platform-verifier, google-platform-verifier. Each is a UUPS
upgradeToAndCall: the entry address, its storage and its owner all
survive, so an upgrade never moves a canonical address and never disturbs
a registration. Each upgrade runs inside its component's own step, before
apply reads or wires that component: a proxy whose running implementation
predates a getter the wiring reads (plan flags it as WARN ... read failed) converges in the same apply --upgrade run instead of aborting
at the read.
For anvil rehearsal the local dev config names anvil #0 as deployer and
owner alike, so the local signer owns the factory from genesis and no
handover happens. A rehearsal of a handover sets a different owner:
apply initiates it and, on anvil, completes it by impersonation.
.github/workflows/apply.yml, manual only (workflow_dispatch):
- Inputs:
network(choice),mode(plandefault /apply),upgrade(comma list),confirm_fresh_deploy,source(releasedownloads the latest release binary;branchbuilds with cargo),contracts_ref(withbranch: a libid-contracts commit whose artifacts are built and vendored in the run, patched in for the crates.io crate). - Assumes
AWS_DEPLOYER_ROLE_ARNvia GitHub OIDC in the region parsed from the network file. - KMS preflight before any transaction:
describe-keymust report an enabledECC_SECG_P256K1/SIGN_VERIFYkey, the deployer address is derived fromget-public-keyviacast keccak, and its balance must be nonzero. - Runs
planalways;applyonly whenmode: apply. Both render into the step summary. - There is no write-back and no PR step: the file already declares every canonical address, so an apply discovers nothing to record — a post-apply check fails the job if the working tree changed at all. The permissions are read-only on repo contents accordingly.
One apply per network at a time (concurrency, no cancel-in-progress:
a half-applied upgrade is worse than a queued one). The job runs in the
GitHub environment named after the network, so production networks can
demand reviewers.
networks/local-dev.toml is the stack on a throwaway anvil: chain 31337,
anvil account #0 as deployer and operational owner, anvil account #1 as the
notary signer, and a non-zero Notary Fee — a local stack that meters at
no charge lets a client attaching the wrong value pass, and WrongValue is
then first seen where it costs something. The deployer spec in the file is
anvil's own published test key: --signer specs are classified by shape,
so 64 hex characters is a local key and no AWS call happens.
Its rpc_url is the compose service name, http://anvil:8545, which only
resolves inside that network. From anywhere else — the host, a CI job that
started anvil --host 127.0.0.1 --port 8545 — the file is consumed as it
is and --rpc-url names the endpoint:
# inside the compose network
docker compose up -d anvil
libid-deploy apply --network networks/local-dev.toml --yes \
--confirm-fresh-deploy
# from the host, or a CI runner with a bare anvil
anvil --host 127.0.0.1 --port 8545 &
libid-deploy apply --network networks/local-dev.toml \
--rpc-url http://127.0.0.1:8545 --yes --confirm-fresh-deployIntegration tests apply the committed file, unmodified, against a real
anvil through --rpc-url, so it cannot rot into something that only
parses, and prove the flag moves nothing but the transport: the same
addresses land, the file's chain id is enforced against the override, and
an override that does not answer fails instead of falling back to the file.
Copy networks/mainnet.toml.example, fill the input keys (chain, AWS,
accounts, Notary Fee), regenerate [contracts] for the file's deployer with
plan --network <file> --print-addresses (the template's table is anvil
#0's), add the name to the
network choice list in apply.yml, and create the GitHub environment of
that name with an RPC_URL secret holding its endpoint: the file names
none. Run the workflow with mode: plan first. The first apply on a
virgin network needs confirm_fresh_deploy.
Publish a GitHub Release (tag vX.Y.Z). release.yml re-runs the CI
checks on the released ref, then builds libid-deploy for four targets —
x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu,
aarch64-apple-darwin and x86_64-apple-darwin, each natively on a runner
of its architecture — and uploads libid-deploy-<version>-<target>.tar.gz
for each as a release asset. The apply workflow's default source: release consumes the newest Linux x86_64 asset.
- The crate embeds nothing of its own: every contract artifact comes with
the
libid-contractscrate, socargo buildneeds no script, no forge and no bb. Moving a contract, a circuit or a Platform Verifier is a bump of the threelibid-*pins inbin/libid-deploy/Cargo.toml. cargo +nightly fmtonly — stable rustfmt silently ignores the nightly-only options inrustfmt.toml.cargo clippy --all-targets --all-features -- -D warningscargo test— the integration tests needanvilon PATH (spawned bare:--disable-default-create2-deployer, proving the install path) and cover the critical declarative cycle — pre-filled file → fresh apply on a virgin anvil lands everything AT the declared addresses → second apply is a no-op without any flag → the file is BYTE-IDENTICAL throughout — plus drift repair, the handle escrow bound to the IdentityRegistry beside it, the Platform Verifier deploy/register/rotate path, the network-invariance proof: two separate bare anvils converge onto the same declared canonical addresses, and the--rpc-urlcontract: the committed local-dev file, unmodified, converges an anvil the file does not name, while a wrong chain id or a dead override is refused. The circuit verifiers those tests deploy are the real Honk verifiers: each is handed a wrong-length proof and must answer with its own circuit'slogN, and the two must differ. A stand-in contract with unrelated code is used only to drift a trust root, so the pull-back is exercised on a pin that could never verify a proof.- Every commit must be signed off (
git commit -s); see CONTRIBUTING.md.