This repo implements batch verification of World ID proofs to enable cheaper batch claims of WLD grants.
It is implemented via two components:
- ZK circuits for batch World ID proof verification using Axiom's ZK circuit libraries.
- Smart contracts implementing WLD grant claims based on batch-verified World ID proof results.
In what follows, we describe two different flows for WLD grants using this integration.
Note: The work in this repo has not been audited and should not be deployed in production prior to additional security review.
We implement two versions of WLD grants based on batch verification of World ID proofs.
WorldcoinAggregationV1
: After proof verification, all the receivers in the batch immediately have their grant transferred to them.WorldcoinAggregationV2
: After proof verification, the receivers (or someone on their behalf) can prove into a Merkle root and transfer the grant to the receiver.
We recommend WLD Grant Protocol V1 for the best UX and cost for the grantee. We describe each variation below.
In the V1 design, the grant contract automatically transfers the grant amount to each of the users in the batch in the same transaction as the verification. This design means users do not need to make an additional on-chain transaction to receive the grant, but incurs additional calldata and transfer cost for each grantee.
The V1 grant contract supports at most MAX_NUM_CLAIMS
at once, and receives as part of the claim transaction a ZK proof whose unique public output is a Keccak hash of the 3 + 3 * MAX_NUM_CLAIMS
ZK-verified quantities:
- vkeyHash - the Keccak hash of the flattened Groth16 vkey
- numClaims - the number of claims, which should satisfy 1 <= numClaims <= MAX_NUM_CLAIMS
- root - the World ID root the proofs are relative to
- grantIds_i for i = 1, ..., MAX_NUM_CLAIMS
- receivers_i for i = 1, ..., MAX_NUM_CLAIMS
- nullifierHashes_i for i = 1, ..., MAX_NUM_CLAIMS
The ZK proof verifies in ZK that:
- For
0 <= idx < numClaims
, there are valid World ID proofs corresponding to(root, claimedNullifierHashes[idx], receivers[idx], grantIds[idx])
with the given Groth16vkeyHash
.
The V1 grants contract then:
- checks the
vkeyHash
for the World ID Groth16 proof is valid - checks that
grantIds[idx]
is valid for0 <= idx < numClaims
- checks for each claiming grantee that the nullifier hash was not previously used
- sends
WLD
to fulfill the claim
To simplify re-execution in the case of insufficient WLD
balance, claims are all-or-nothing -- if the contract has insufficient WLD
to fulfill the entire batch, the entire claim transaction will be reverted.
In the V2 design, the grant contract implements a two-step process to distribute the grant. First, it verifies in ZK a Keccak Merkle root of a tree of eligible grant recipients with leaves given by abi.encodePacked(grantId, receiver, nullifierHash)
. This Merkle root is stored on-chain, and grantees use ordinary Merkle proofs to prove eligibility and claim their grants.
The V2 grant contract supports at most MAX_NUM_CLAIMS
at once, where MAX_NUM_CLAIMS
must be a power of two. It verifies the following 3 ZK-verified quantities using a ZK proof:
- vkeyHash - the Keccak hash of the flattened Groth16 vkey
- root - the World ID root the proofs are relative to
- claimsRoot - the Keccak Merkle root of the tree with `MAX_NUM_CLAIMS` leaves
The ZK proof verifies that
- There is a value
1 <= numClaims <= MAX_NUM_CLAIMS
and arraysgrantIds
,receivers
andclaimedNullifierHashes
so that for0 <= idx < numClaims
, there are valid World ID proofs corresponding to(root, claimedNullifierHashes[idx], receivers[idx], grantIds[idx])
. - The hash
claimsRoot
is the Keccak Merkle root of the tree withMAX_NUM_CLAIMS
leaves, where the firstnumClaims
leaves are given byabi.encodePacked(grantIds[idx], receivers[idx], claimedNullifierHashes[idx])
and the remaining leaves areabi.encodePacked(uint256(0), address(0), byte32(0))
.
The V2 grants contract then:
- checks the
vkeyHash
for the World ID Groth16 proof is valid - stores
claimsRoot
for grantees to claim WLD against
Grantees can claim WLD
rewards from the V2 grants contract by passing a Merkle proof into:
function claim(
uint256 grantId,
uint256 root,
address receiver,
bytes32 nullifierHash,
bytes32[] calldata leaves,
bytes32 isLeftBytes
) external {
<...>
}
Note that we use a Keccak Merkle tree without sorting of child hashes, meaning the parent hash is given by parent = keccak256(abi.encodePacked(left, right))
. This means Merkle proof verification requires an indication of whether each proof hash is a left or right child, which we encode in bytes32 isLeftBytes
so that byte idx
of isLeftBytes
is 1
if and only if leaves[idx]
is a left child.
To benchmark gas usage and off-chain costs, we deployed WorldcoinAggregationV1
and WorldcoinAggregationV2
for sizes 16, 32, 64, 128, 256 (v1 only) and 8192 (v2 only)
on Sepolia. Because the WLD token is not deployed on Sepolia, we mocked the WLD, RootValidator, and Grant contracts, as detailed below. After deployment, we initiated transactions for various batch sizes and measured amortized gas usage, calldata size and off-chain costs.
Test data was generated using semaphore-rs with the depth_30
feature flag. Our profiling assumes that the grantees have a 0 balance for WLD. If the grantees are existing WLD holders, the WLD transfer will take 17.1K less gas, with the difference (20K - 2.9K) coming from the cost of the SSTORE
opcode for updating grantee balances.
We deployed Grant Protocol V1 on Sepolia for different claim sizes and made sample fulfill transactions. We measure gas usage, calldata size and off-chain costs, displayed in the table below. We also compute gas attributed to proof verification, which excludes gas used for WLD token transfers and other business logic. We recommend this configuration setting for the best cost and UX.
In these benchmarks, onchain costs include L1 and L2 gas. Our onchain cost estimates assume an L2 gas cost of 0.06 gwei, L1 blob base fee of 1wei, and $3000 ETH. Our offchain cost estimates are conservative benchmarks based on on-demand AWS compute instances (m6a.4xlarge
).
# Claims | Sepolia Address | Fulfill Tx | L2 Gas/Claim | Proof Gas/Claim | Calldata/Claim | Onchain $/Claim | Offchain $/Claim |
---|---|---|---|---|---|---|---|
16 | 0x3689d27A428543100E7CeB663F55616cdE896F07 | Fulfill Tx | 75K | 23K | 232 | $0.0139 | $0.0208 |
32 | 0xF2EF0b7300BF2B0F0a7a310BABde640b3E74997B | Fulfill Tx | 64K | 11K | 164 | $0.0118 | $0.0181 |
64 | 0xe515583983388956147277Ec7a4347964D77bFbc | Fulfill Tx | 58K | 6K | 130 | $0.0107 | $0.0170 |
128 | 0x0cd9558c9f3BB010F8A0ec3Fd301178e1fc925F8 | Fulfill Tx | 56K | 3K | 113 | $0.0103 | $0.0158 |
256 | 0xa5fac0910068B7a570B0De0c2411A4185A3c3b03 | Fulfill Tx | 54K | 1.4K | 105 | $0.0100 | $0.0156 |
We deployed Grant Protocol V2 on Sepolia for different sizes and made sample fulfill and claim transactions. We measured gas usage, calldata usage and off-chain costs, shown in the table below. We also show gas attributed to proof verification, which excludes gas used for WLD token transfers and other business logic.
In these benchmarks, onchain costs include L1 and L2 gas. Our onchain cost estimates assume an L2 gas cost of 0.06 gwei, L1 blob base fee of 1wei, and $3000 ETH. Our offchain cost estimates are conservative benchmarks based on on-demand AWS compute instances (m6a.4xlarge
).
# Claims | Sepolia Address | Fulfill/Claim Tx | L2 Gas/Claim | Proof Gas/Claim | Calldata/Claim | Onchain $/Claim | Offchain $/Claim |
---|---|---|---|---|---|---|---|
16 | 0x0725a6d62f7d9eC34197c57Bbc34B6657e251bf9 | Fulfill Claim | 113K | 23K | 482 | $0.0212 | $0.0207 |
32 | 0xDbef001fF19867075F02bB6Ee3D490235885AABA | Fulfill Claim | 103K | 11K | 451 | $0.0193 | $0.0225 |
64 | 0x15C11FA9f87819020ec63997e7f1FcDeb71E2420 | Fulfill Claim | 98K | 6K | 452 | $0.0185 | $0.0218 |
128 | 0xE43aB117477b9976fE02198299D933fdaC80E319 | Fulfill Claim | 96K | 3K | 468 | $0.0182 | $0.0217 |
8192 | 0x708151E55a73bf359A1E0cC87Ff7D88c87Db9859 | Fulfill Claim | 97K | 0.04K | 644 | $0.0188 | $0.214 |
For a given batch size, V2 consumes more gas than V1 per claim due to the additional claim transaction. As the batch size increases, the calldata per claim mostly decreases, reaching its minimum when the batch size is 32. After that the calldata per claim starts to increase due to increased calldata usage from the claim transaction.
We deployed the following other contracts to mock different aspects of the Worldcoin system on Sepolia and run the integration into Axiom.
Name | Sepolia Address | Description |
---|---|---|
WLDMock | 0xe93D97b0Bd30bD61a9D02B0A471DbB329D5d1fd8 | An ERC20 contract which mocks the WLD contract |
RootValidatorMock | 0x9c06c3F1deecb530857127009EBE7d112ecd0E3F | A contract which implements the IRootValidator interface and never reverts on the the requireValidRoot call |
GrantMock | 0x5d1F6aDfff773A2146f1f3c947Ddad1945103DaC | A contract which implements the IGrant interface and nver reverts on the checkValidity call |
circuit/
: Circuits and prover backend for standalone batch World ID aggregation.README.md
: Documentation for the standalone World ID aggregation circuits for WLD grants can be found in their own README.
src/
WorldcoinAggregationV1.sol
: The smart contract for theWorldcoinAggregationV1
version.WorldcoinAggregationV2.sol
: The smart contract for theWorldcoinAggregationV2
version.interfaces/
: Interfaces used throughout the aggregation contracts.
test/
: Full coverage testing over both the aggregation contracts. Includes end-to-end testing with a mocked witness generation over the circuit, unit testing, and fuzzes (where relevant).
Clone this repository (and git submodule dependencies) with:
git clone --recursive git@github.com:axiom-crypto/worldcoin-aggregation.git
cd worldcoin-aggregation
cp .env.example .env
To run the tests, fill in .env
with your PROVIDER_URI_11155111
. This must be a Sepolia RPC.
Run the tests with
forge test