Skip to content

Repository files navigation

zkICAO contracts

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.

What is in here

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.

Measured

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.

Tests

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.

Running it on a chain

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.

Deploying

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.

What this is not

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.

License

MIT

About

Reference on chain registry and generated Solidity verifiers for zkICAO proofs

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages