Skip to main content

Tutorial: Using Polygon ID State Replication in a DApp

In this tutorial, we will build the DApp that mints an NFT if the user was born before some date. Given an issuer anchored to Polygon, we can replicate its state to another chain(in our case, Ethereum) to use zero-knowledge proofs in a DApp.

User flow

  1. The front end creates the QR code that contains the proof request and shows it on the page for the user.
  1. The user can scan this QR code with his Polygon ID Wallet and generate proof that he possesses valid credentials.
  1. After that, the relayer will be called to replicate the state from Polygon to Ethereum.
  1. The user can submit the proof with his MetaMask and mint the SBT.

DApp creation

Our simple DApp will consist of 2 parts:

  • front-end, which contains a QR-code for the Polygon ID Wallet, that contains a request for a zero-knowledge proof that the user was born before 2000/01/01;
  • the verifier and SBT smart contracts;

Smart contracts

Let's start by writing a simple set of contracts allowing the user to provide proof (credentials) that he was born before 2000/01/01.

To do that, we need to implement:

  • VerifiedSBT: a basic SBT contract;
  • QueryVerifier: contract that verifies the proof and calls the SBT contract to mint the token on successful verification;

These contracts should be upgradable, but we omit this part now for simplicity's sake.

VerifiedSBT

Let's start by writing our SBT contract. Our goal is to mint an SBT on successful age verification. Here is the template of the contract that we should fill (we will use OpenZeppelin ERC721 contracts, don't forget to clone them):

/contracts/VerifiedSBT.sol
pragma solidity 0.8.16;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract VerifiedSBT is ERC721Enumerable {

address public verifier; // the address of our verifier contract
uint256 public nextTokenId; // nonce that should be increment with every newly minted SBT
string public tokensURI; // token URI as in NFTs

// may be left as it is, or you can additionally ask for a tokensURI and set it in the constructor
constructor() ERC721("Name", "Symbol") {}

// modifier, which makes the mint function callable only by the verifier contract
modifier onlyVerifier() {}

// getter function returns current `nextTokenId`
function getNextTokenId() view external returns (uint256) {}

// sets the verifier
function setVerifier(address newVerifier_) external {}

// sets the tokensURI
function setTokensURI(string calldata newTokensURI_) external {}

// main function that mints the token
function mint(address recipientAddr_) external onlyVerifier() {}

// hook that should be modified so the tokens can't be transferred
function _beforeTokenTransfer(address from,address to, uint256 tokenId) internal override {}

}

The provided functions are empty, so we need to fill them in:

  • onlyVerifier() – checks whether the sender is the same as the verifier variable and proceeds in such a case;
  • getNextTokenId() – returns the next token id;
  • setVerifier(...), setTokensURI(...) – set the corresponding value to the provided one;
  • mint(...) – mints the token to the provided address, with the next token id (hint: call the ERC721 parent function _mint);
  • _beforeTokenTransfer(...) – restricts the user from transferring the token;

The final contract should look like this:

/contracts/VerifiedSBT.sol
pragma solidity 0.8.16;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract VerifiedSBT is
IVerifiedSBT,
ERC721Enumerable
{
address public verifier;
uint256 public nextTokenId;
string public tokensURI;

constructor() ERC721("Name", "Symbol") {}

modifier onlyVerifier() {
require(msg.sender == verifier, "VerifiedSBT: only verifier can call this function");
_;
}

function setVerifier(address newVerifier_) external onlyOwner {
verifier = verifier_;
}

function getNextTokenId() view external returns (uint256){
return nextTokenId;
}

function setTokensURI(string calldata newTokensURI_) external {
tokensURI = tokensURI_;
}

function mint(address recipientAddr_) external override onlyVerifier {
_mint(recipientAddr_, nextTokenId++);
}

//You can modify this hook so the token won't be burnable by removing the `to_ == address(0)` requirement.
function _beforeTokenTransfer(
address from_,
address to_,
uint256 firstTokenId_,
uint256 batchSize_
) internal override {
require(
from_ == address(0) || to_ == address(0),
"VerifiedSBT: token transfers are not allowed"
);

super._beforeTokenTransfer(from_, to_, firstTokenId_, batchSize_);
}
}

We will also need the interface for the VerifiedSBT contract:

/contracts/intefraces/IVerifiedSBT.sol
pragma solidity 0.8.16;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";

interface IVerifiedSBT {

/**
* @notice Function for updating the address of the verifier's contract
* @dev Only contract OWNER can call this function
* @param newVerifier_ the new verifier contract address
*/
function setVerifier(address newVerifier_) external;

/**
* @notice Function for updating the tokens URI string
* @dev Only contract OWNER can call this function
* @param newTokensURI_ the new tokens URI string
*/
function setTokensURI(string calldata newTokensURI_) external;

/**
* @notice Function for minting new tokens
* @dev Only the verifier contract can call this function
* @param recipientAddr_ the address of the token recipient
*/
function mint(address recipientAddr_) external;

/**
* @notice Function that returns the next token ID
* @return The next token ID
*/
function getNextTokenId() external view returns (uint256);
}

For the full implementation, see VerifiedSBT.sol at the GitHub.

QueryVerifier

Now, we need to write a QueryVerifier that will verify the proof and call the VerifiedSBT contract to mint the SBT for the user. Additionally, it will store the mapping of addresses to identifiers that are verified and vice versa (identifiers to addresses). We need both mappings so that one address can pass the verification only once and one identity can be verified only by one address.

We don't need to implement the main verify(...) function because our contract inherits iden3 ZKPVerifier, which already has it and does all the jobs. QueryVerifier has two hooks - _beforeProofSubmit(...) and _afterProofSubmit(...), which are called before and after proof verification, respectively. We will override them and add our SBT-minting logic to them. So here is the template for our QueryVerifier contract:

/contracts/QueryVerifier.sol
pragma solidity 0.8.16;

import "@iden3/contracts/verifiers/ZKPVerifier.sol";
import "@iden3/contracts/lib/GenesisUtils.sol";
import "@iden3/contracts/interfaces/ICircuitValidator.sol";

import "./interfaces/IVerifiedSBT.sol";

contract QueryVerifier is ZKPVerifier {
// the request ID, which will be used in the proofs
// we should specify at least one request ID for our proofs
uint256 public constant AGE_VERIFY_REQUEST_ID = 1;

// our sbtContract that we created before
IVerifiedSBT public sbtContract;

// mapping of addresses to users' identities
mapping(address => uint256) public addressToUserId;

// mapping of users' identities to addresses
mapping (uint256 => address) public userIdToAddress;

// sets the destination of sbtContract
function setSBTContract(address sbtContract_) external {}

function isUserVerified(uint256 userId_) public view returns (bool) {}

// hook that is executed before proof verification and does some security checks
function _beforeProofSubmit(
uint64,
uint256[] memory inputs_,
ICircuitValidator
) internal override {}

// hook that is executed after proof verification and performs the business logic
function _afterProofSubmit(
uint64,
uint256[] memory inputs_,
ICircuitValidator
) internal override {}

}

Functions should have the following functionality:

  • setSBTContract(...) – sets the SBT contract instance;
  • isUserVerified(...) – returns the boolean, whether the provided identity is verified;
  • _beforeProofSubmit(...) – checks whether the identity from inputs (inputs_[1] in our case) is verified and whether the msg.sender hasn't verified any identity yet. Returns if any of the requirements are not met;
  • _afterProofSubmit(...) – fills both mappings (with userID and msg.sender) and calls the SBT contract to mint the token for the sender;

We want to "bind" addresses to user identities and vice versa so that one address can't prove multiple identities and one identity can't be associated with two or more addresses.

The final version of the contract should look like this:

/contracts/QueryVerifier.sol
pragma solidity 0.8.16;

import "@iden3/contracts/verifiers/ZKPVerifier.sol";
import "@iden3/contracts/lib/GenesisUtils.sol";
import "@iden3/contracts/interfaces/ICircuitValidator.sol";

import "./interfaces/IQueryVerifier.sol";
import "./interfaces/IVerifiedSBT.sol";

contract QueryVerifier is ZKPVerifier {
uint256 public constant AGE_VERIFY_REQUEST_ID = 1;

IVerifiedSBT public sbtContract;

mapping(address => uint256) public addressToUserId;

mapping (uint256 => address) public userIdToAddress;

function isUserVerified(uint256 userId_) public view returns (bool) {
return userIdToAddress[userId_] != address(0);
}

function _beforeProofSubmit(
uint64,
uint256[] memory inputs_,
ICircuitValidator
) internal override {
require(
// what is inside inputs_ depends on the credentials scheme
!isUserVerified(inputs_[1]),
"Identity with this identifier has already been verified"
);
require(
addressToUserId[msg.sender] == 0,
"Current address has already been used to verify another identity"
);
}

function _afterProofSubmit(
uint64,
uint256[] memory inputs_,
ICircuitValidator
) internal override {
uint256 tokenId_ = sbtContract.nextTokenId();
uint256 userId_ = inputs_[1];

userIdToAddress[userId_] = msg.sender;
addressToUserId[msg.sender] = userId_;

sbtContract.mint(msg.sender);

}
}

For the full implementation, see QueryVerifier.sol at the GitHub.

Deployment

You can deploy currently created contracts by yourself for testing. The order in which contracts are deployed is not important, but remember to set contract variables in each contract (i.e., verifier in the VerifiedSBT and sbtContract in QueryVerifier).

But, if you want to use this integration for production purposes, we recommend you clone from our GitHub and extend contracts with your business logic. To deploy these contracts, do the following:

  1. Create the .env file and fill it, following the example in .env.example;
  2. Fill in the config file deploy/data/config.json. It has the following structure:
    deploy/data/config.json
    {
    "validatorContractInfo": {
    "validatorAddr": "",
    "isSigValidator": "true"
    },
    "stateContractInfo": {
    "stateAddr": "0x134B1...07a4",
    "stateInitParams": {
    "signer": "0xda323...afa6",
    "sourceStateContract": "0x134B1...07a4",
    "chainName": "Sepolia"
    }
    },
    "poseidonFacade": "0x1702a...1AF5"
    }
    Deploying new contracts is enough to leave the fields with addresses empty while filling in the fields with init values.
  3. To deploy, run npm run deploy-<network>, where network is the network name from the hardhat.config.ts file. In case you need to deploy contracts locally, run the following two commands in different terminals:
    npm run private-network # terminal 1
    npm run deploy-local # terminal 2
  4. The command to generate bindings is:
    npm run generate-types

Front-end

We will use the following tech stack:

For the full front-end implementation, see GitHub.

Let's go through the core business logic. To request a zero-knowledge proof, we need to:

  1. Create a proof request in the JSON format.
  2. After that, the proxy service will be called to get a unique verification identifier and a JWT. We will use them to fetch the proof later. We need the proxy service because our DApp is a single-page application that can't handle callbacks on its own. For the full proxy service implementation, see GitHub.
  3. Wrap the proof request in the React QRCode component that displays the JSON struct as a QR code. After scanning this QR code, the user's wallet will send the response (proof in JWZ format) using the callbackUrl to the proxy service.
  4. Fetch the proof from the proxy service by providing the verification_id and the JWT obtained earlier (getJWZ() function).

The proof request JSON contains the following information:

  • id – identifier stored on the wallet SDK;
  • thid – id of the message thread;
  • from – from where the authentication request comes, i.e., the identifier of the identity from which a Verifier requests proof (VITE_REQUEST_BUILD_SENDER in environment files);
  • typ – iden3comm Media Type, i.e., the file format for the type field. (For example, JSON);
  • type – a type of iden3comm Protocol Message; type of request; it could be an auth request, proof request, or a credential offer;
  • body, that consists of:
    • reason – reason of authentication (it could be age verification or simply a test flow);
    • message – message to be signed; can be left blank;
    • callbackUrl – URI to which requested information is sent (proxy service endpoint);
    • scope – information related to the proof request and the requirements to be fulfilled by the proof generated and shared from mobile. It is in the form of an array of proofs that the SDK generates. We will specify the requirements for the proof here, namely – in the query subsection.

See Polygon ID Wallet SDK Docs (Query-based Request) for more details.

The final code may look like this:

src/contexts/ZkpContext/helpers/general.ts
import { config } from '@config'
import { v4 as uuidv4 } from 'uuid'

import { api } from '@/api'
import {
CLAIM_TYPES_MAP_OFF_CHAIN,
CLAIM_TYPES_MAP_ON_CHAIN,
} from '@/contexts/ZkpContext/consts'
import { ClaimTypes } from '@/contexts/ZkpContext/enums'

export const createRequestOnChain = (
reason: string,
message: string,
sender: string,
callbackUrl: string,
) => {
const uuid = uuidv4()

return {
id: uuid,
thid: uuid,
from: sender,
typ: 'application/iden3comm-plain-json',
type: 'https://iden3-communication.io/authorization/1.0/request',
body: {
reason: reason,
// message: message,
callbackUrl: callbackUrl,
scope: [],
},
}
}

export const buildRequestOnChain = async (
callbackBaseUrl: string,
claimType: ClaimTypes, // we have defined this enum in another file. It's equal to a string 'KYCAgeCredential' in this demo.
) => {
const { data } = await api.get<{
verification_id: string
jwt: string
}>('/integrations/verify-proxy/v1/public/verify/request')

const request = createRequestOnChain(
'SBT airdrop',
'',
config.REQUEST_BUILD_SENDER,
`${callbackBaseUrl}/integrations/verify-proxy/v1/public/verify/callback/${data.verification_id}`,
)

return {
request: {
...request,
id: data.verification_id,
thid: data.verification_id,
body: {
...request.body,
scope: [CLAIM_TYPES_MAP_ON_CHAIN[claimType]], //We have defined the scope in another file as mapping to the claim types
},
},
jwtToken: data.jwt,
}
}

export const getJWZ = async (jwtToken: string, verificationId: string) => {
const { data } = await api.get<{
jwz: string
}>(`/integrations/verify-proxy/v1/public/verify/response/${verificationId}`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
},
})

return data.jwz
}

We need to specify the scope for the proof, namely the following information:

  • id – unique request id;
  • circuitId – ID of the ZK-circuit used for generating the proof;
  • query:
    • allowedIssuers – credentials only from these issuers will be used for zero-knowledge proof generation (["*"] means all issuers are valid);
    • type – credential type;
    • context – JSON-LD url of credential type context;
    • credentialSubject – credential subject of W3C credential;

You can provide other information as well, see Iden3 communication Docs for details.

The scope we will use may look like this:

src/contexts/ZkpContext/consts/general.ts
import { ClaimTypes } from '@/contexts/ZkpContext/enums'

export const CLAIM_TYPES_CHECKS_VALUES_MAP: Record<ClaimTypes, unknown> = {
[ClaimTypes.KYCAgeCredential]: '2002.01.01',
}

/* ... */

export const CLAIM_TYPES_MAP_ON_CHAIN: Record<ClaimTypes, unknown> = {

// the scope for our KYCAgeCredential type
[ClaimTypes.KYCAgeCredential]: {
id: 1,
circuitId: 'credentialAtomicQueryMTPV2OnChain',
query: {
allowedIssuers: ['*'],
context:
'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json-ld/kyc-v3.json-ld',
credentialSubject: {
birthday: {
$lt: +String(
CLAIM_TYPES_CHECKS_VALUES_MAP[ClaimTypes.KYCAgeCredential],
).replaceAll('.', ''),
},
},
type: ClaimTypes.KYCAgeCredential,
},
},
}

Let's wrap it in the React's QRCode component:

src/pages/AuthProof/AuthProof.tsx
import { FC, HTMLAttributes } from 'react'
import QRCode from 'react-qr-code'
import { useEffectOnce } from 'react-use'

import { Loader } from '@/common'
import { useZkpContext } from '@/contexts'

type Props = HTMLAttributes<HTMLDivElement>

const AuthProof: FC<Props> = () => {
const { isPending, proveRequest, createProveRequest } = useZkpContext()

// proveRequest is created when mounting
useEffectOnce(() => {
// calls buildRequestOnChain(...) with callbackUrl and claim type under the hood
createProveRequest()
})

return (
/* ... */
<div className='auth-proof__card-qr-wrp'>
<QRCode className='auth-proof__card-qr' value={proveRequest} />
</div>
/* ... */
)

After getting a JWZ from the getJWZ(...) function, we can submit our proof to the on-chain contract, but it may not be accepted if the state hasn't been replicated yet. We should call the relayer to ensure that the state exists on the destination chain:

src/contexts/ZkpContext/ZkpContext.tsx
  const isClaimStateValid = useCallback(
async (claimStateHex: string) => {
try {
const { data } = await fetcher.post<{
tx: string
}>(`${config.RARIMO_CORE_API_URL}/integrations/relayer/state/relay`, {
body: {
hash: claimStateHex,
chain: RELAYER_RELAY_CHAIN_NAMES[config.DEFAULT_CHAIN],
},
})

if (!data?.tx) throw new Error('tx is not defined')

await waitTx(data?.tx)

return false
} catch (error) {
return handleStateValidatingError(error)
}
},
[handleStateValidatingError, waitTx],
)

const isGistStateValid = useCallback(
async (gistStateHash: string) => {
try {
const { data } = await fetcher.post<{
tx: string
}>(`${config.RARIMO_CORE_API_URL}/integrations/relayer/gist/relay`, {
body: {
hash: gistStateHash,
chain: RELAYER_RELAY_CHAIN_NAMES[config.DEFAULT_CHAIN],
},
})

if (!data?.tx) throw new Error('tx is not defined')

await waitTx(data?.tx)

return false
} catch (error) {
return handleStateValidatingError(error)
}
},
[handleStateValidatingError, waitTx],
)

Finally, after transiting the state and getting the JWZ, we can submit the proof:

src/pages/AuthConfirmation/AuthConfirmation.tsx
/* ... */

const submitZkp = useCallback(async () => {
setIsSubmitting(true)

try {
if (!jwzToken) throw new TypeError('ZKP is not defined')

const zkProofPayload = JSON.parse(jwzToken.getPayload())

const zkProof = zkProofPayload.body.scope[0] as ZKProof

const txBody = getProveIdentityTxBody(
'1',
zkProof.pub_signals.map(el => BigInt(el)),
[zkProof.proof.pi_a[0], zkProof.proof.pi_a[1]],
[
[zkProof.proof.pi_b[0][1], zkProof.proof.pi_b[0][0]],
[zkProof.proof.pi_b[1][1], zkProof.proof.pi_b[1][0]],
],
[zkProof.proof.pi_c[0], zkProof.proof.pi_c[1]],
)

const tx = await provider?.signAndSendTx?.({
to: config?.[
`QUERY_VERIFIER_CONTRACT_ADDRESS_${selectedChainToPublish}`
],
...txBody,
})

verificationSuccessTx.set((tx as EthTransactionResponse).transactionHash)

navigate(RoutesPaths.authSuccess)
} catch (error) {
ErrorHandler.process(error)
}

setIsSubmitting(false)
}, [
getProveIdentityTxBody,
jwzToken,
navigate,
provider,
selectedChainToPublish,
verificationSuccessTx,
])

/* ... */

Now you're ready to use the Polygon ID State Replication with Rarimo!

If you want to tweak or expand this example, we recommend you clone front-end and smart contracts from the GitHub, make the changes you need and use it further.