Skip to main content

Guide: How to verify a zero knowledge proof in a smart contract

The following guide will instruct you on integrating zero-knowledge proofs into an existing smart contract written in Solidity.

Let's assume we have a generic ERC-20 contract that has a mint method we want to make age-restricted:

pragma solidity ^0.8.16;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract AgeRestrictedERC20 is IERC20 {
string public name = "Age Restricted ERC20 Token";
string public symbol = "ART";
uint8 public decimals = 18;

// 100 tokens
uint256 public constant AIRDROP_AMOUNT = 100 * 10**uint256(decimals);

// ...skipping the ERC20 boilerplate...

// We want to add the proof verification here
function mint() external {
balanceOf[msg.sender] += AIRDROP_AMOUNT;
totalSupply += AIRDROP_AMOUNT;
emit Transfer(address(0), msg.sender, AIRDROP_AMOUNT);
}
}

Our users were issued verifiable credentials(VC) that contain the user's birthday date:

{
"id": "8edd8112-c415-11ed-b036-debe37e1cbd6",
"proofTypes": ["BJJSignature2021"],
"createdAt": "2023-03-20T11:54:01.110295+01:00",
"expiresAt": "2025-03-20T11:54:01.110295+01:00",
"expired": false,
"schemaHash": "c9b2370371b7fa8b3dab2a5ba81b6838",
"schemaType": "KYCAgeCredential",
"schemaUrl": "https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json/KYCAgeCredential-v3.json",
"revoked": false,
"revNonce": 2136005230,
"credentialSubject": {
"birthday": 19960424,
"documentType": 2,
"id": "did:rarimo:2qDDDKmo436EZGCBAvkqZjADYoNRJszkG7UymZeCHQ"
},
"userID": "did:rarimo:2qFpPHotk6oyaX1fcrpQFT4BMnmg8YszUwxYtaoGoe"
}

We'll mint 100 tokens to every user born before 01/01/2000.

Step 1: Inherit the BaseVerifier contract and add the ZKP verification

We need to import the necessary Rarimo identity contracts and inherit the BaseVerifier contract.

Then, we need to add the ZKP verification logic into our mint method. Our contract must also define the query ID; in our case, it's AGE_PROOF. It is the identifier of the ZKP Request that we will create in the next step.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.16;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {IZKPQueriesStorage} from "../interfaces/IZKPQueriesStorage.sol";
import {IQueryValidator} from "../interfaces/IQueryValidator.sol";

import {BaseVerifier} from "./BaseVerifier.sol";

contract AgeRestrictedERC20 is BaseVerifier, IERC20 {
// ...
string public constant AGE_PROOF_QUERY_ID = "AGE_PROOF";

struct IdentityProofInfo {
address senderAddr;
bool isProved;
}

mapping(address => uint256) public addressToIdentityId;

mapping(uint256 => IdentityProofInfo) internal _identitiesProofInfo;

function __AgeRestrictedERC20_init(IZKPQueriesStorage zkpQueriesStorage_) external initializer {
__BaseVerifier_init(zkpQueriesStorage_);
}

function mint(ProveIdentityParams calldata proveIdentityParams_) external {
_verify(AGE_PROOF_QUERY_ID, proveIdentityParams_);

require(
addressToIdentityId[msg.sender] == 0,
"AgeRestrictedERC20: Msg sender address has already been used to prove the another identity."
);

IQueryValidator queryValidator_ = IQueryValidator(
zkpQueriesStorage.getQueryValidator(IDENTITY_PROOF_QUERY_ID)
);

uint256 identityId_ = proveIdentityParams_.inputs[queryValidator_.getUserIdIndex()];

require(
!isIdentityProved(identityId_),
"AgeRestrictedERC20: Identity has already been proven."
);

addressToIdentityId[msg.sender] = identityId_;
_identitiesProofInfo[identityId_] = IdentityProofInfo(msg.sender, true);

// custom logic is placed here
balanceOf[msg.sender] += AIRDROP_AMOUNT;
totalSupply += AIRDROP_AMOUNT;
emit Transfer(address(0), msg.sender, AIRDROP_AMOUNT);
}

function isIdentityProved(address userAddr_) external view returns (bool) {
return _identitiesProofInfo[addressToIdentityId[userAddr_]].isProved;
}

function isIdentityProved(uint256 identityId_) public view returns (bool) {
return _identitiesProofInfo[identityId_].isProved;
}
}

Step 2: Deploy the contracts

We need to deploy our AgeRestrictedERC20 alongside PoseidonFacade, ZKPQueriesStorage и QueryValidator contracts. To do that, use the scripts and instructions provided here.

Step 3: Set the proof query

After the contract deployment, we should design an Iden3 proof query specifying the zero-knowledge proof's inputs and operations.

Here's a query that verifies that the user was born before 01/01/2000:

const query = {
schema: ethers.BigNumber.from('74977327600848231385663280181476307657'),
claimPathKey: ethers.BigNumber.from(
'20376033832371109177683048456014525905119173674985843915445634726167450989630'
),
operator: 2,
value: [20000101, ...new Array(63).fill(0).map((i) => 0)],
queryHash: '0',
circuitId: 'credentialAtomicQueryMTPV2OnChain',
}

The query has the following fields:

  1. schema: hash of the credential schema;
  2. claimPathKey: represents the path to the query key inside the merklized credential (In our case, it is the path to the birthday key).
  3. operator: is either 1, 2, 3, 4, 5 or 6 (less-than operator we need is 2);
  4. credentialAtomicQueryMTPV2OnChain: ID of the Iden3 circuit used to evaluate the proof. Set to credentialAtomicQueryMTPV2OnChain
  5. value: represents the threshold value you are querying (01/01/2000 date in our case);

You can run the Golang script to get schema hash and claimPathKey using your schema.

To set the query for our smart contracts, use the following script:

import { ethers } from 'hardhat'

async function main() {
const poseidonFacadeAddr = '' // Enter PoseidonFacade address
const zkpQueriesStorageAddr = '' // Enter your ZKPQueriesStorage address
const queryValidatorAddr = '' // Enter your QueryValidatorAddr
const queryId = 'IDENTITY_PROOF' // Enter query ID from your IdentityVerifier contract

const query = {
schema: ethers.BigNumber.from('74977327600848231385663280181476307657'),
claimPathKey: ethers.BigNumber.from(
'20376033832371109177683048456014525905119173674985843915445634726167450989630'
),
operator: 2,
value: [20000101, ...new Array(63).fill(0).map((i) => 0)],
queryHash: '0',
circuitId: 'credentialAtomicQueryMTPV2OnChain',
}

const ZKPQueriesStorageFactory = await ethers.getContractFactory(
'ZKPQueriesStorage',
{
libraries: {
PoseidonFacade: poseidonFacadeAddr,
},
}
)

const zkpQueriesStorage = ZKPQueriesStorageFactory.attach(
zkpQueriesStorageAddr
)

const tx = await zkpQueriesStorage.setZKPQuery(queryId, {
queryValidator: queryValidatorAddr,
circuitQuery: query,
})
await tx.wait()

console.log(
`Your ZKPQuery - ${await zkpQueriesStorage.getQueryInfo(queryId)}`
)
}

main().catch((error) => {
console.error(error)
process.exitCode = 1
})

Step 4: Testing it out

You need to integrate RariMe snap into your DApp to test the flow. Use this snippet to generate and submit the proof inside your application.

As a result, you should receive 100 minted tokens at your address.