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
.
Step 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
Step 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.
Step 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.
References
Here are a couple of code references that demonstrate how to collect proof public signals and pass them to a verification contract:
-
ClaimableToken.sol Simplest example with the uniqueness and age checks
-
BioPassportVoting.sol Demonstrates a voting contract that uses ZK proofs for passport-based identity checks.
Collect public signals and proof from verificator-svc
and send it to the contract
When a user scans a QR code and submits a proof:
- Your DApp calls the
getProof
endpoint fromverificator-svc
. - Receive both
pubSignals
and theproof
from the service:
{
"pubSignals": ["12345", "67890", "..."],
"proof": {
"pi_a": ["0x123...", "0x456..."],
"pi_b": [["0x789...", "0xabc..."], ["0xdef...", "0x123..."]],
"pi_c": ["0x456...", "0x789..."]
}
}
- Call the
execute
method with following parameters:registrationRoot
,currentDate
,userPayload
andproof
.
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. - Integrate proof verification into your DApp's smart contract by inheriting from
AQueryProofExecutor
and implementing the required methods.
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.