Skip to main content

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:

  1. Ensured that you have all the necessary components in place to verify ZK Passport proofs on your chain.
  2. Deployed the TD3QueryProofVerifier contract on your chain to handle proof verification.
  3. Integrated verification logic into your own contract to validate proof public signals.

Prerequisites

Step 1: Add the Rarimo Passport Contracts library to your project

The contracts are available in the @rarimo/passport-contracts npm package:

npm install @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:

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:

  1. Your DApp calls the getProof endpoint from verificator-svc.
  2. Receive both pubSignals and the proof from the service:
{
"pubSignals": ["12345", "67890", "..."],
"proof": {
"pi_a": ["0x123...", "0x456..."],
"pi_b": [["0x789...", "0xabc..."], ["0xdef...", "0x123..."]],
"pi_c": ["0x456...", "0x789..."]
}
}
  1. Call the execute method with following parameters: registrationRoot, currentDate, userPayload and proof.

Conclusion

With these steps:

  1. Set up the ZK Passport Registry state replication on your chain if needed.
  2. Deploy the TD3QueryProofVerifier contract to facilitate ZK proof verification.
  3. 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.