Guide: Setting up on-chain user verification with ZK Passport
This tutorial walks you through the process of setting up on-chain verification of ZK Passport proofs. By the end of this tutorial, you will have:
- Ensured that you have all the necessary components in place to verify ZK Passport proofs on your chain.
- Deployed the
TD3QueryProofVerifier
contract on your chain to handle proof verification. - Integrated verification logic into your own contract to validate proof public signals.
Prerequisites
- If you are using an EVM-compatible chain other than Rarimo's L2, you'll need to set up ZK Passport Registry state replication as described in Setting up Cross-Chain Verification.
- Access to the
verificator-svc
service for retrieving proof parameters. You can use the public instance ofverificator-svc
(https://api.app.rarime.com/) for testing, but it's recommended to deploy your own instance for production use, as described in Setting upverificator-svc
.
1. Setting up the smart contract for on-chain verification
1.1 Add the Rarimo Passport Contracts library to your project
The contracts are available in the @rarimo/passport-contracts npm package:
- npm
- Yarn
- pnpm
npm install @rarimo/passport-contracts
yarn add @rarimo/passport-contracts
pnpm add @rarimo/passport-contracts
1.2 Deploy the TD3QueryProofVerifier smart contract
The TD3QueryProofVerifier
contract is responsible for verifying ZK proofs on-chain. This contract often comes with a precompiled ZK circuit.
Import the TD3QueryProofVerifier
contract from the Rarimo Passport Contracts library:
import {TD3QueryProofVerifier } from "@rarimo/passport-contracts/sdk/verifier/TD3QueryProofVerifier.sol";
Then deploy it to your chain and record the address of the newly deployed TD3QueryProofVerifier
. You will use this address in your other contracts to verify proofs.
1.3 Integrate proof verification into your smart contract
Once your chain is replicating ZK Registry state and you have a deployed TD3QueryProofVerifier
contract, you can add ZK proof checks to your own contract logic.
Below is a skeleton of the DApp that relies on the Query Proof Verification to perform some actions (token mint, grant a role, etc.).
pragma solidity ^0.8.28;
import {IPoseidonSMT} from "@rarimo/passport-contracts/interfaces/state/IPoseidonSMT.sol";
import {AQueryProofExecutor} from "@rarimo/passport-contracts/sdk/AQueryProofExecutor.sol";
import {PublicSignalsBuilder} from "@rarimo/passport-contracts/sdk/lib/PublicSignalsBuilder.sol";
contract MyPassportContract is AQueryProofExecutor {
struct UserData {
uint256 nullifier;
uint256 identityCreationTimestamp;
}
mapping(uint256 => bool) public usedNullifiers;
constructor(address registrationSMT_, address verifier_) {
__AQueryProofExecutor_init(registrationSMT_, verifier_);
}
// Called before proof verification
function _beforeVerify(bytes32, uint256, bytes memory userPayload_) public override {
(address user, UserData memory userData) = abi.decode(
userPayload_,
(address, UserData)
);
require(!usedNullifiers[userData.nullifier], "Nullifier already used");
usedNullifiers[userData.nullifier] = true;
}
// Called after successful proof verification
function _afterVerify(bytes32, uint256, bytes memory userPayload_) public override {
(address user, UserData memory userData) = abi.decode(
userPayload_,
(address, UserData)
);
// Grant access, mint tokens, or perform other actions
// Example: grantAccess(user);
}
// Builds the public signals for verification
function _buildPublicSignals(
bytes32,
uint256 currentDate_,
bytes memory userPayload_
) public override returns (uint256 dataPointer_) {
(address user, UserData memory userData) = abi.decode(
userPayload_,
(address, UserData)
);
// Query proof verification logic here
return dataPointer_;
}
}
The critical parts are the three required override methods:
// Called before proof verification
function _beforeVerify(bytes32 registrationRoot_, uint256 currentDate_, bytes memory userPayload_) public override { /* ... */ }
// Called after successful proof verification
function _afterVerify(bytes32 registrationRoot_, uint256 currentDate_, bytes memory userPayload_) public override { /* ... */ }
// Builds the public signals for verification
function _buildPublicSignals(bytes32 registrationRoot_, uint256 currentDate_, bytes memory userPayload_)
public override returns (uint256 dataPointer_) { /* ... */ }
Above, your contract inherits from AQueryProofExecutor
, which handles the ZK proof validation internally.
Below, you can see detailed structure for the _buildPublicSignals
function. Which is the core of the SDK, where you setup the constraints for identity verification.
function _buildPublicSignals(
bytes32,
uint256 currentDate_,
bytes memory userPayload_
) public override returns (uint256 dataPointer_) {
(address user, UserData memory userData) = abi.decode(
userPayload_,
(address, UserData)
);
uint256 identityCreationTimestampUpperBound = getIdentityCreationTimestampUpperBound();
uint256 identityCounterUpperBound = type(uint32).max;
if (userData.identityCreationTimestamp > 0) {
identityCreationTimestampUpperBound = userData.identityCreationTimestamp;
identityCounterUpperBound = 1;
}
dataPointer_ = PublicSignalsBuilder.newPublicSignalsBuilder(SELECTOR, userData.nullifier);
dataPointer_.withEventIdAndData(getEventId(user), getEventData());
dataPointer_.withCurrentDate(currentDate_, 1 days);
dataPointer_.withTimestampLowerboundAndUpperbound(0, identityCreationTimestampUpperBound);
dataPointer_.withBirthDateLowerboundAndUpperbound(
PublicSignalsBuilder.ZERO_DATE,
BIRTHDAY_UPPERBOUND
);
dataPointer_.withIdentityCounterLowerbound(0, identityCounterUpperBound);
dataPointer_.withExpirationDateLowerboundAndUpperbound(
currentDate_,
PublicSignalsBuilder.ZERO_DATE
);
return dataPointer_;
}
// Helper functions
function getIdentityCreationTimestampUpperBound() public view returns (uint256) {
return accessStartTimestamp - IPoseidonSMT(getRegistrationSMT()).ROOT_VALIDITY();
}
function getEventId(address user) public view returns (uint256) {
// Implementation depends on your application
return uint256(keccak256(abi.encodePacked(block.chainid, address(this), user)));
}
function getEventData() public view returns (uint256) {
// Implementation depends on your application
return uint256(uint248(uint256(keccak256(abi.encodePacked(/* some data*/)))));
}
The client application calls the function execute(registrationRoot, currentDate, userPayload, zkPoints_)
to perform the actions and verify the Query Proof.
To learn more about the verification parameters(public signals) and how to build them, check out PublicSignalsBuilder.sol.
2. Adding verification to your DApp front-end
The @rarimo/zk-passport-react package provides a QR code that users can scan to initiate the verification process. This component handles the collection of user data, proof generation, and on-chain verification.
2.1 Install the ZK Passport React package
First, install the SDK and the viem
library in your React project:
npm install viem@^2.31.0 @rarimo/zk-passport-react
2.2 Add the QR code component to your DApp
To render the ZK Passport QR code in your DApp, you can use the ZkPassportQrCode
component from the @rarimo/zk-passport-react package:
import ZkPassportQrCode from '@rarimo/zk-passport-react'
import { mainnet } from 'viem/chains' // Import the chain you are using
const requestId = 'account-1'
const apiUrl = 'https://api.app.rarime.com' // or your own instance of `verificator-svc`
const contractAddress = '<your_contract_address>' // Address of the deployed TD3QueryProofVerifier contract from Step 1.2
const receiverAddress = '<your_receiver_address>' // User's wallet address
const chain = mainnet
return (
<ZkPassportQrCode
apiUrl={apiUrl}
requestId={requestId}
verificationOptions={{
contractAddress,
receiverAddress,
chain,
}}
qrProps={{ size: 256 }}
onStatusChange={status => console.log(status)}
onSuccess={proof => console.log(proof)}
onError={error => console.error(error)}
/>
)
To learn more about the ZK Passport React component, check out the ZK Passport React component documentation.
Reference implementation
Here is a simple age-restricted airdrop DApp that allows users to claim tokens if they meet the age requirement:
- ClaimableToken.sol: smart contract that implements the ZK Passport proof verification.
- on-chain-verification-react: front-end that uses the ZK Passport React component to verify user age and uniqueness.
Live demo is available at StackBlitz.
Conclusion
With these steps:
- Set up the ZK Passport Registry state replication on your chain if needed.
- Deploy the
TD3QueryProofVerifier
contract to facilitate ZK proof verification. - Add proof verification into your DApp's smart contract by inheriting from
AQueryProofExecutor
and implementing the required methods. - Integrate proof verification into your DApp's front-end using the ZK Passport React component.
We've set up on-chain verification of ZK Passport proofs. This allows you to verify user identities without revealing sensitive data, ensuring privacy and security in your application.