diff --git a/docs/products/polygon-id-state-replication/tutorial-verifying-polygon-id-credentials-in-ethereum-dapp.mdx b/docs/products/polygon-id-state-replication/tutorial-verifying-polygon-id-credentials-in-ethereum-dapp.mdx index 6a4c0bcb..eebe2e38 100644 --- a/docs/products/polygon-id-state-replication/tutorial-verifying-polygon-id-credentials-in-ethereum-dapp.mdx +++ b/docs/products/polygon-id-state-replication/tutorial-verifying-polygon-id-credentials-in-ethereum-dapp.mdx @@ -1 +1,597 @@ -# Tutorial: Verifying Polygon ID Credentials in Ethereum DApp +--- +title: "Tutorial: Using Polygon ID State Replication in a DApp" +--- + +# 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. + + ![Proof request QR-code](/img/polygon-id-integration-qr-code.png) + +1. The user can scan this QR code with his Polygon ID Wallet and generate proof that he possesses valid credentials. + + ![Proof generated, connect wallet to proceed](/img/polygon-id-integration-generated.png) + +1. After that, the relayer will be called to replicate the state from Polygon to Ethereum. + + ![Performing state transition](/img/polygon-id-integration-relay.png) + +1. The user can submit the proof with his MetaMask and mint the SBT. + + ![Verified and received an SBT](/img/polygon-id-integration-sbt.jpg) + +## 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](https://github.com/OpenZeppelin/openzeppelin-contracts/tree/v4.4.0/contracts/token/ERC721/extensions) contracts, don't forget to clone them): + +```solidity title="/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: + +```solidity title="/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: +```solidity title="/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](https://github.com/rarimo/polygonid-integration-contracts/blob/master/contracts/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: + +```solidity title="/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: + +```solidity title="/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](https://github.com/rarimo/polygonid-integration-contracts/blob/master/contracts/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](https://github.com/rarimo/polygonid-integration-contracts/tree/master) 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`; +1. Fill in the config file `deploy/data/config.json`. + It has the following structure: + ```json title="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. +1. To deploy, run `npm run deploy-`, 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: + ```bash + npm run private-network # terminal 1 + npm run deploy-local # terminal 2 + ``` +1. The command to generate bindings is: + ```bash + npm run generate-types + ``` + + +### Front-end + +We will use the following tech stack: +- [React](https://github.com/facebook/react/releases) +- [Vite.js](https://github.com/vitejs/vite/releases) +- [Distributed Lab web-kit](https://github.com/distributed-lab/web-kit/tree/main) +- [crypto](https://github.com/iden3/js-crypto), [jwz](https://github.com/iden3/js-jwz), [core](https://github.com/iden3/js-iden3-core), [jsonld-merklization](https://github.com/iden3/js-jsonld-merklization), [merkletree](https://github.com/iden3/js-merkletree) iden3 libraries from +- [Yarn](https://github.com/yarnpkg) as a package manager + + +For the full front-end implementation, see [GitHub](https://github.com/rarimo/web-client-polygonid/tree/main). + +Let's go through the core business logic. For a start, we'll write a function, that creates the proof request in JSON format. +It will be wrapped in React's `QRCode` component that displays a QR code. +After scanning this QR code, the user's wallet will send the response (proof in JWZ format) using the `callbackUrl`. +The request should contain 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, and response is received; + - `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. + +You can provide other information as well. See [Polygon ID Wallet-SDK Docs](https://devs.polygonid.com/docs/wallet/wallet-sdk/polygonid-sdk/iden3comm/auth-requests/) for more details. + +The final code may look like this: + +```ts title="src/contexts/ZkpContext/ZkpContext.tsx" +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]], + }, + }, + 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 +} +``` + +Let's wrap it in the React's `QRCode` component: + +```ts title="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 + +const AuthProof: FC = () => { + const { isPending, proveRequest, createProveRequest } = useZkpContext() + + // proveRequest is created when mounting + useEffectOnce(() => { + // calls buildRequestOnChain(...) with callbackUrl and claim type under the hood + createProveRequest() + }) + + return ( + /* ... */ +
+ +
+ /* ... */ + ) +``` + +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 wasn't replicated yet. We should call the relayer to ensure that the state exists on the destination chain: + +```ts title="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: + +```ts title="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](https://github.com/rarimo/web-client-polygonid/tree/main) and [smart contracts](https://github.com/rarimo/polygonid-integration-contracts) from the GitHub, make the changes you need and use it further. \ No newline at end of file diff --git a/static/img/polygon-id-integration-sbt.jpg b/static/img/polygon-id-integration-sbt.jpg new file mode 100644 index 00000000..727eae74 Binary files /dev/null and b/static/img/polygon-id-integration-sbt.jpg differ