The on chain half of zkICAO verification: a reference registry contract and the Solidity verifiers it uses, for proofs produced by zkICAO/circuits.
Status: early development, private. The tests run against real proofs and pass; nothing here is deployed anywhere.
src/ZkIcaoRegistry.sol is the aggregate bundle form of the off chain checklist, ported to a contract. A holder registers once with two proofs:
- a registration proof, which carries the whole document chain (Passive Authentication, data group extraction, attribute commitment, signer trust) verified recursively in circuit and exposes six values: domain, context, commitment, secret binding, proving date, signer registry root
- a nullifier proof, the scoped uniqueness value for the document
The contract requires the two to describe one document, holds them to its own policy (domain, signer registry root, proving date window), verifies both, and stores the nullifier. A second registration of the same document reverts without identifying anyone.
The context is the sender address: a proof prepared for one sender reverts in anyone else's transaction, so relaying requires the holder to have chosen the relayer at proving time.
src/RegistrationVerifier.sol and src/NullifierVerifier.sol are generated by bb write_solidity_verifier from the keccak flavored verification keys of the two circuits, and carry a provenance header saying exactly that. Regenerate them when the circuits change; do not edit them.
With bb 4.2.0-aztecnr-rc.2 proofs and forge on this repository:
| What | Number |
|---|---|
register() transaction on a devnet, intrinsic and calldata included |
5,520,416 gas |
register() execution alone, metered in the test |
5,212,756 gas |
| of which the registry's own logic | 52,813 gas |
| registration verifier, deployed code | 24,254 bytes |
| margin to the EIP-170 limit | 322 bytes |
| registration proof, keccak flavor | 11,072 bytes |
| nullifier proof, keccak flavor | 7,616 bytes |
The two register() figures are the same run seen from two places: a node charges the transaction, the test meters the call, and the difference is the transaction intrinsics plus nineteen kilobytes of calldata.
And the other proving system, for the same kind of statement:
| What | Groth16 | UltraHonk |
|---|---|---|
| verifying one proof, gas | 222,345 | about 2,580,000 of the execution above |
| verifier, deployed code | 1,718 bytes | 24,254 bytes |
| proof | 256 bytes | 11,072 bytes |
That is the trade in one table. A Groth16 verification is roughly eleven times cheaper than an UltraHonk one and the verifier is fourteen times smaller as a contract, and it costs a per circuit phase 2 ceremony that UltraHonk does not need. A deployment that will not run a ceremony has the other option and pays for it here.
The Groth16 verifier in src/CompareVerifier.sol is tied to one proving
key, so it is tied to one ceremony. The one committed here belongs to a
local development contribution and is fit for tests and nothing else.
Those numbers are the design in one line. Almost everything a registration costs is verifying the proofs and carrying eighteen kilobytes of them as calldata; the contract's own work is 52,813 gas, nearly all of it two storage writes. It stays that way only because the contract derives nothing.
That matters specifically for the hash. The circuits use Poseidon2, chosen for what it costs inside a circuit, and no chain has a precompile for it, so recomputing one here would be tens of thousands of gas each. It is never needed: every Poseidon2 runs inside a proof and arrives as a public input, leaving the contract to compare field elements. The proofs' transcript uses keccak, which is why they are produced with the keccak oracle, and the contract uses keccak only where Solidity's storage layout does, to address a mapping.
The constraint would break if this contract maintained a structure it had to hash into, an accumulator of commitments for instance, since inserting means one Poseidon2 per level. A test measures the contract's own cost with verification removed so that giving this up shows as a number.
That margin is the number to watch. The optimizer setting in foundry.toml is load bearing rather than a preference: at Foundry's default of 200 runs the verifier is 25,184 bytes and cannot be deployed to any chain, and with the optimizer off it is 33,880. A test measures the deployed code so this cannot regress quietly, and the EVM version is pinned for the same reason a gas number needs one.
The proving cost sits on the holder's device, not here: the recursive registration proof takes about 14 seconds on a laptop.
forge test -vv. The fixtures under test/fixtures are real proofs produced by the circuits repository's bundle command over a generated Doc 9303 document, committed as the fixture of record like the circuit test data. Regenerating the bundle produces fresh keys, so regenerating fixtures means recopying all four files together; a mixed set describes two documents and fails.
Covered: a registration that verifies with its gas measured, the same document twice, a proof bound to another sender, a tampered proof of either kind, a nullifier from another document, a registry the proof was not anchored to, and a proving date outside the window.
script/devnet.sh deploys and registers against a node. It points at a
local devnet by default, because that needs nobody's funds and nobody's key
and the EVM it runs is the same EVM; set RPC_URL and PRIVATE_KEY and the
same script runs against a testnet.
It is not a substitute for the tests, it checks what a test cannot: that the deploy script works, that the contracts fit and deploy, that a node accepts a transaction carrying eighteen kilobytes of proof, and what a registration costs in a real block. Measured on a devnet: deployment succeeds, the registration costs 5,520,416 gas, the commitment and the nullifier are readable on chain afterwards, and a second registration of the same document reverts.
One thing has to line up. The contract takes the sender as the context, so the proofs must be bound to the address that will send them. The bundle takes that address:
ZKICAO_CONTEXT=$(python3 -c "print(int('<your sender>', 16))") \
cargo run -- bundle
The script refuses to run if the fixtures are bound to anyone else, rather than sending a transaction that reverts.
script/Deploy.s.sol deploys the two verifiers and one registry. Every policy value comes from the environment and every one is required, because none of them have a sensible default: a default domain would put two applications in one scope, and a default trust root would anchor to a set nobody chose. Copy .env.example, which has a placeholder and an explanation in each slot, and fill it in.
No key material is read by any file here. The signer is passed to forge, against a keystore account or a hardware wallet.
Not audited, not deployed, and not a product. One registry serves one application domain; anything beyond the reference checklist, retiring nullifiers on reissue, upgradability, governance of the date window, is an application decision this reference deliberately does not take.
zkICAO is an independent open source project, not affiliated with or endorsed by ICAO. See TRADEMARKS.md.
MIT