SEI ZK Series Part 6 - Implementing zk proof in a sei blockchainΒ dapp

Implementing Zero-Knowledge Proofs in a dApp on Sei

Introduction

This tutorial will guide you through implementing a zero-knowledge proof system in a dApp on the Sei blockchain. We'll use Hardhat for development and deployment, leveraging Sei's EVM compatibility to create a privacy-preserving age verification system.

Prerequisites

Before starting, ensure you have:

  • Node.js (v14 or higher)

  • npm or yarn

  • Basic understanding of:

    • React.js

    • Solidity

    • Web3.js or ethers.js

    • Basic cryptography concepts

  • A Sei wallet with testnet SEI tokens (for testing)

Project Setup

  1. First, let's create a new project:
mkdir sei-zk-dapp
cd sei-zk-dapp
npm init -y
  1. Install necessary dependencies:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @openzeppelin/hardhat-upgrades dotenv
npm install ethers@5.7.2 @openzeppelin/contracts circomlib snarkjs @metamask/detect-provider
npm install --save-dev @nomiclabs/hardhat-ethers @nomiclabs/hardhat-waffle ethereum-waffle chai
  1. Initialize Hardhat with Sei configuration:
npx hardhat init
  1. Create a hardhat.config.js file:
require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
require("dotenv").config();

module.exports = {
  solidity: {
    version: "0.8.22",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  networks: {
    seitestnet: {
      url: "https://evm-rpc-testnet.sei.io",
      chainId: 713715,
      accounts: [process.env.PRIVATE_KEY],
      gasPrice: 20000000000,
    },
    seimainnet: {
      url: "https://evm-rpc.sei.io",
      chainId: 713715,
      accounts: [process.env.PRIVATE_KEY],
      gasPrice: 20000000000,
    },
  },
  paths: {
    sources: "./contracts",
    tests: "./test",
    cache: "./cache",
    artifacts: "./artifacts",
  },
};
  1. Create a .env file:
PRIVATE_KEY=your_private_key_here
SEI_RPC_URL=https://evm-rpc-testnet.sei.io

Project Structure

sei-zk-dapp/
β”œβ”€β”€ contracts/
β”‚   β”œβ”€β”€ AgeVerifier.sol
β”‚   └── Verifier.sol
β”œβ”€β”€ circuits/
β”‚   └── ageProof.circom
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ deploy.ts
β”‚   └── verify.ts
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ AgeVerification.tsx
β”‚   β”‚   └── WalletConnect.tsx
β”‚   β”œβ”€β”€ utils/
β”‚   β”‚   β”œβ”€β”€ zkProof.ts
β”‚   β”‚   └── web3.ts
β”‚   └── App.tsx
└── test/
    └── AgeVerifier.test.ts

Step 1: Creating the Smart Contract

Create contracts/AgeVerifier.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "./Verifier.sol";

contract AgeVerifier is Initializable, Ownable, UUPSUpgradeable {
    Verifier public verifier;

    // Mapping to store verified addresses
    mapping(address => bool) public verifiedAddresses;

    // Event emitted when verification is successful
    event AgeVerified(address indexed user);

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address _verifier) public initializer {
        __Ownable_init();
        __UUPSUpgradeable_init();
        verifier = Verifier(_verifier);
    }

    function verifyAge(
        uint[2] memory a,
        uint[2][2] memory b,
        uint[2] memory c,
        uint[3] memory input
    ) public returns (bool) {
        // Verify the proof
        require(verifier.verifyProof(a, b, c, input), "Invalid proof");

        // Mark address as verified
        verifiedAddresses[msg.sender] = true;

        // Emit event
        emit AgeVerified(msg.sender);

        return true;
    }

    function isVerified(address user) public view returns (bool) {
        return verifiedAddresses[user];
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

Step 2: Creating the Deployment Script

Create scripts/deploy.ts:

import { ethers, upgrades } from "hardhat";

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying contracts with the account:", deployer.address);

  // Deploy Verifier contract first
  const Verifier = await ethers.getContractFactory("Verifier");
  const verifier = await Verifier.deploy();
  await verifier.deployed();
  console.log("Verifier deployed to:", verifier.address);

  // Deploy AgeVerifier as upgradeable contract
  const AgeVerifier = await ethers.getContractFactory("AgeVerifier");
  const ageVerifier = await upgrades.deployProxy(
    AgeVerifier,
    [verifier.address],
    {
      initializer: "initialize",
      kind: "uups",
    }
  );
  await ageVerifier.deployed();

  console.log("AgeVerifier proxy deployed to:", await ageVerifier.getAddress());
  console.log(
    "Implementation address:",
    await upgrades.erc1967.getImplementationAddress(
      await ageVerifier.getAddress()
    )
  );
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Step 3: ZK Proof Generation Implementation

Create the proof generation utility file (frontend/src/utils/zkProof.ts):

import { groth16 } from "snarkjs";
import { ethers } from "ethers";

// Define the input interface for proof generation
interface ProofInput {
  birthYear: number;
  birthMonth: number;
  birthDay: number;
  currentYear: number;
  currentMonth: number;
  currentDay: number;
}

// Define the proof output interface
interface ProofOutput {
  proof: {
    pi_a: [string, string];
    pi_b: [[string, string], [string, string]];
    pi_c: [string, string];
  };
  publicSignals: string[];
}

// Cache for circuit and proving key
let circuitCache: ArrayBuffer | null = null;
let provingKeyCache: ArrayBuffer | null = null;

/**
 * Loads the circuit and proving key, with caching
 */
async function loadCircuitAndKey() {
  if (!circuitCache) {
    const circuitResponse = await fetch("/circuits/ageProof.wasm");
    circuitCache = await circuitResponse.arrayBuffer();
  }

  if (!provingKeyCache) {
    const provingKeyResponse = await fetch("/circuits/ageProof.zkey");
    provingKeyCache = await provingKeyResponse.arrayBuffer();
  }

  return {
    circuit: circuitCache,
    provingKey: provingKeyCache,
  };
}

/**
 * Validates the input data for the proof
 */
function validateInput(input: ProofInput): string | null {
  const now = new Date();
  const birthDate = new Date(
    input.birthYear,
    input.birthMonth - 1,
    input.birthDay
  );

  // Check if birth date is valid
  if (birthDate > now) {
    return "Birth date cannot be in the future";
  }

  // Check if birth date is reasonable (e.g., not more than 150 years ago)
  const minYear = now.getFullYear() - 150;
  if (input.birthYear < minYear) {
    return "Birth year seems invalid";
  }

  // Validate month and day
  if (input.birthMonth < 1 || input.birthMonth > 12) {
    return "Invalid month";
  }
  if (input.birthDay < 1 || input.birthDay > 31) {
    return "Invalid day";
  }

  return null;
}

/**
 * Converts the proof to the format expected by the smart contract
 */
function formatProofForContract(proof: any): {
  a: [string, string];
  b: [[string, string], [string, string]];
  c: [string, string];
} {
  return {
    a: [proof.pi_a[0], proof.pi_a[1]],
    b: [
      [proof.pi_b[0][1], proof.pi_b[0][2]],
      [proof.pi_b[1][1], proof.pi_b[1][2]],
    ],
    c: [proof.pi_c[0], proof.pi_c[1]],
  };
}

/**
 * Generates a zero-knowledge proof for age verification
 */
export async function generateProof(input: ProofInput): Promise<ProofOutput> {
  try {
    // Validate input
    const validationError = validateInput(input);
    if (validationError) {
      throw new Error(validationError);
    }

    // Load circuit and proving key
    const { circuit, provingKey } = await loadCircuitAndKey();

    // Prepare the input for the circuit
    const circuitInput = {
      birthYear: input.birthYear,
      birthMonth: input.birthMonth,
      birthDay: input.birthDay,
      currentYear: input.currentYear,
      currentMonth: input.currentMonth,
      currentDay: input.currentDay,
    };

    // Generate the proof
    const { proof, publicSignals } = await groth16.fullProve(
      circuitInput,
      circuit,
      provingKey
    );

    // Format the proof for the smart contract
    const formattedProof = formatProofForContract(proof);

    return {
      proof: formattedProof,
      publicSignals: publicSignals.map((signal: any) =>
        ethers.BigNumber.from(signal).toString()
      ),
    };
  } catch (error: any) {
    console.error("Proof generation failed:", error);
    throw new Error(
      `Failed to generate proof: ${error.message || "Unknown error"}`
    );
  }
}

/**
 * Verifies a proof locally (useful for testing)
 */
export async function verifyProofLocally(
  proof: ProofOutput["proof"],
  publicSignals: string[]
): Promise<boolean> {
  try {
    const verificationKey = await fetch("/circuits/verification_key.json");
    const vKey = await verificationKey.json();

    return await groth16.verify(vKey, publicSignals, proof);
  } catch (error) {
    console.error("Local verification failed:", error);
    return false;
  }
}

/**
 * Utility function to check if a proof is valid for the current date
 */
export function isProofValidForCurrentDate(proof: ProofOutput): boolean {
  const now = new Date();
  const proofDate = new Date(
    parseInt(proof.publicSignals[0]), // currentYear
    parseInt(proof.publicSignals[1]) - 1, // currentMonth
    parseInt(proof.publicSignals[2]) // currentDay
  );

  // Check if the proof is from today
  return (
    proofDate.getFullYear() === now.getFullYear() &&
    proofDate.getMonth() === now.getMonth() &&
    proofDate.getDate() === now.getDate()
  );
}

/**
 * Utility function to estimate gas cost for proof verification
 */
export async function estimateVerificationGas(
  contract: ethers.Contract,
  proof: ProofOutput["proof"],
  publicSignals: string[]
): Promise<ethers.BigNumber> {
  try {
    const gasEstimate = await contract.estimateGas.verifyAge(
      proof.a,
      proof.b,
      proof.c,
      publicSignals
    );
    return gasEstimate;
  } catch (error) {
    console.error("Gas estimation failed:", error);
    throw new Error("Failed to estimate gas cost");
  }
}

Step 4: Creating the Frontend Components

Update src/utils/web3.ts to work with Sei:

import { ethers } from "ethers";
import detectEthereumProvider from "@metamask/detect-provider";
import AgeVerifier from "../artifacts/contracts/AgeVerifier.sol/AgeVerifier.json";

const SEI_CHAIN_ID = "713715"; // Sei testnet chain ID
const SEI_RPC_URL = "https://evm-rpc-testnet.sei.io";

export async function setupWeb3() {
  const provider = await detectEthereumProvider();

  if (!provider) {
    throw new Error("Please install MetaMask!");
  }

  // Request account access
  await (window as any).ethereum.request({ method: "eth_requestAccounts" });

  // Check if we're on Sei network
  const chainId = await (window as any).ethereum.request({
    method: "eth_chainId",
  });

  if (chainId !== SEI_CHAIN_ID) {
    try {
      await (window as any).ethereum.request({
        method: "wallet_switchEthereumChain",
        params: [{ chainId: `0x${parseInt(SEI_CHAIN_ID).toString(16)}` }],
      });
    } catch (switchError: any) {
      // If Sei network is not added to MetaMask
      if (switchError.code === 4902) {
        await (window as any).ethereum.request({
          method: "wallet_addEthereumChain",
          params: [
            {
              chainId: `0x${parseInt(SEI_CHAIN_ID).toString(16)}`,
              chainName: "Sei Testnet",
              nativeCurrency: {
                name: "SEI",
                symbol: "SEI",
                decimals: 18,
              },
              rpcUrls: [SEI_RPC_URL],
              blockExplorerUrls: ["https://testnet.sei.io"],
            },
          ],
        });
      }
    }
  }

  const ethersProvider = new ethers.providers.Web3Provider(provider);
  const signer = ethersProvider.getSigner();

  const contractAddress = process.env.REACT_APP_CONTRACT_ADDRESS;
  const contract = new ethers.Contract(
    contractAddress!,
    AgeVerifier.abi,
    signer
  );

  return {
    provider: ethersProvider,
    signer,
    contract,
    account: await signer.getAddress(),
  };
}

Step 5: Deploying to Sei Testnet

  1. Make sure you have testnet SEI tokens in your wallet

  2. Deploy the contracts:

npx hardhat run scripts/deploy.ts --network seitestnet
  1. After deployment, update your .env file with the deployed contract addresses:
REACT_APP_CONTRACT_ADDRESS=your_deployed_contract_address

Step 6: Testing

Create test/AgeVerifier.test.ts:

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";

describe("AgeVerifier", function () {
  let ageVerifier: any;
  let verifier: any;
  let owner: SignerWithAddress;
  let user: SignerWithAddress;

  beforeEach(async function () {
    [owner, user] = await ethers.getSigners();

    // Deploy Verifier
    const Verifier = await ethers.getContractFactory("Verifier");
    verifier = await Verifier.deploy();
    await verifier.deployed();

    // Deploy AgeVerifier
    const AgeVerifier = await ethers.getContractFactory("AgeVerifier");
    ageVerifier = await upgrades.deployProxy(AgeVerifier, [verifier.address], {
      initializer: "initialize",
    });
    await ageVerifier.deployed();
  });

  describe("Verification", function () {
    it("Should verify age proof correctly", async function () {
      // Mock proof data (in real scenario, this would come from the circuit)
      const a = [ethers.BigNumber.from("1"), ethers.BigNumber.from("2")];
      const b = [
        [ethers.BigNumber.from("3"), ethers.BigNumber.from("4")],
        [ethers.BigNumber.from("5"), ethers.BigNumber.from("6")],
      ];
      const c = [ethers.BigNumber.from("7"), ethers.BigNumber.from("8")];
      const input = [
        ethers.BigNumber.from("9"),
        ethers.BigNumber.from("10"),
        ethers.BigNumber.from("11"),
      ];

      // Mock verifier to return true
      await verifier.setMockVerifyResult(true);

      await expect(ageVerifier.connect(user).verifyAge(a, b, c, input))
        .to.emit(ageVerifier, "AgeVerified")
        .withArgs(user.address);

      expect(await ageVerifier.isVerified(user.address)).to.be.true;
    });
  });
});

Run the tests:

npx hardhat test

Frontend Implementation

Let's create a complete React frontend for our ZK proof dApp. We'll use TypeScript and modern React practices.

  1. First, create a new React application in the project:
npx create-react-app frontend --template typescript
cd frontend
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion
npm install ethers@5.7.2 @metamask/detect-provider
  1. Create the main App component (frontend/src/App.tsx):
import React, { useState, useEffect } from "react";
import {
  ChakraProvider,
  Box,
  Container,
  VStack,
  Heading,
} from "@chakra-ui/react";
import { ethers } from "ethers";
import WalletConnect from "./components/WalletConnect";
import AgeVerification from "./components/AgeVerification";
import { setupWeb3 } from "./utils/web3";

function App() {
  const [account, setAccount] = useState<string>("");
  const [contract, setContract] = useState<ethers.Contract | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const init = async () => {
      try {
        const { contract, account } = await setupWeb3();
        setContract(contract);
        setAccount(account);
      } catch (error) {
        console.error("Failed to initialize web3:", error);
      } finally {
        setIsLoading(false);
      }
    };

    init();
  }, []);

  return (
    <ChakraProvider>
      <Box minH="100vh" bg="gray.50" py={10}>
        <Container maxW="container.md">
          <VStack spacing={8} align="stretch">
            <Heading textAlign="center" mb={8}>
              Sei ZK Age Verification
            </Heading>

            <WalletConnect
              account={account}
              onConnect={async () => {
                const { contract, account } = await setupWeb3();
                setContract(contract);
                setAccount(account);
              }}
            />

            {contract && account && (
              <AgeVerification contract={contract} account={account} />
            )}
          </VStack>
        </Container>
      </Box>
    </ChakraProvider>
  );
}

export default App;
  1. Create the WalletConnect component (frontend/src/components/WalletConnect.tsx):
import React from "react";
import { Box, Button, Text, useToast } from "@chakra-ui/react";

interface WalletConnectProps {
  account: string;
  onConnect: () => Promise<void>;
}

const WalletConnect: React.FC<WalletConnectProps> = ({
  account,
  onConnect,
}) => {
  const toast = useToast();

  const handleConnect = async () => {
    try {
      await onConnect();
      toast({
        title: "Wallet Connected",
        status: "success",
        duration: 3000,
      });
    } catch (error) {
      toast({
        title: "Connection Failed",
        description:
          "Please make sure MetaMask is installed and you are on the Sei network",
        status: "error",
        duration: 5000,
      });
    }
  };

  return (
    <Box p={5} shadow="md" borderWidth="1px" borderRadius="lg" bg="white">
      {account ? (
        <Text>
          Connected: {account.slice(0, 6)}...{account.slice(-4)}
        </Text>
      ) : (
        <Button colorScheme="blue" onClick={handleConnect} isLoading={false}>
          Connect Wallet
        </Button>
      )}
    </Box>
  );
};

export default WalletConnect;
  1. Create the AgeVerification component (frontend/src/components/AgeVerification.tsx):
import React, { useState, useEffect } from "react";
import {
  Box,
  Button,
  FormControl,
  FormLabel,
  Input,
  VStack,
  Text,
  useToast,
  Progress,
  Alert,
  AlertIcon,
} from "@chakra-ui/react";
import { ethers } from "ethers";
import { generateProof } from "../utils/zkProof";

interface AgeVerificationProps {
  contract: ethers.Contract;
  account: string;
}

const AgeVerification: React.FC<AgeVerificationProps> = ({
  contract,
  account,
}) => {
  const [birthDate, setBirthDate] = useState("");
  const [isVerifying, setIsVerifying] = useState(false);
  const [isVerified, setIsVerified] = useState(false);
  const [verificationStatus, setVerificationStatus] = useState<
    "idle" | "verifying" | "success" | "error"
  >("idle");
  const toast = useToast();

  useEffect(() => {
    checkVerificationStatus();
  }, [contract, account]);

  const checkVerificationStatus = async () => {
    try {
      const verified = await contract.isVerified(account);
      setIsVerified(verified);
    } catch (error) {
      console.error("Failed to check verification status:", error);
    }
  };

  const handleVerification = async () => {
    if (!birthDate) {
      toast({
        title: "Error",
        description: "Please enter your birth date",
        status: "error",
        duration: 3000,
      });
      return;
    }

    try {
      setIsVerifying(true);
      setVerificationStatus("verifying");

      // Get current date
      const now = new Date();
      const currentYear = now.getFullYear();
      const currentMonth = now.getMonth() + 1;
      const currentDay = now.getDate();

      // Parse birth date
      const birth = new Date(birthDate);
      const birthYear = birth.getFullYear();
      const birthMonth = birth.getMonth() + 1;
      const birthDay = birth.getDate();

      // Generate proof
      const { proof, publicSignals } = await generateProof({
        birthYear,
        birthMonth,
        birthDay,
        currentYear,
        currentMonth,
        currentDay,
      });

      // Verify on-chain
      const tx = await contract.verifyAge(
        [proof.pi_a[0], proof.pi_a[1]],
        [
          [proof.pi_b[0][0], proof.pi_b[0][1]],
          [proof.pi_b[1][0], proof.pi_b[1][1]],
        ],
        [proof.pi_c[0], proof.pi_c[1]],
        publicSignals
      );

      // Wait for transaction confirmation
      await tx.wait();

      // Update verification status
      await checkVerificationStatus();
      setVerificationStatus("success");

      toast({
        title: "Verification Successful",
        description: "Your age has been verified on the blockchain",
        status: "success",
        duration: 5000,
      });
    } catch (error: any) {
      console.error("Verification failed:", error);
      setVerificationStatus("error");

      toast({
        title: "Verification Failed",
        description: error.message || "Please try again",
        status: "error",
        duration: 5000,
      });
    } finally {
      setIsVerifying(false);
    }
  };

  return (
    <Box p={5} shadow="md" borderWidth="1px" borderRadius="lg" bg="white">
      <VStack spacing={4} align="stretch">
        <Text fontSize="xl" fontWeight="bold">
          Age Verification
        </Text>

        {isVerified ? (
          <Alert status="success">
            <AlertIcon />
            Your age has been verified on the Sei blockchain
          </Alert>
        ) : (
          <>
            <FormControl isRequired>
              <FormLabel>Birth Date</FormLabel>
              <Input
                type="date"
                value={birthDate}
                onChange={(e) => setBirthDate(e.target.value)}
                disabled={isVerifying}
              />
            </FormControl>

            {verificationStatus === "verifying" && (
              <Progress size="xs" isIndeterminate />
            )}

            <Button
              colorScheme="blue"
              onClick={handleVerification}
              isLoading={isVerifying}
              loadingText="Verifying..."
              disabled={!birthDate || isVerifying}
            >
              Verify Age
            </Button>

            {verificationStatus === "error" && (
              <Alert status="error">
                <AlertIcon />
                Verification failed. Please try again.
              </Alert>
            )}
          </>
        )}
      </VStack>
    </Box>
  );
};

export default AgeVerification;
  1. Create a styles file (frontend/src/styles/theme.ts):
import { extendTheme } from "@chakra-ui/react";

const theme = extendTheme({
  styles: {
    global: {
      body: {
        bg: "gray.50",
      },
    },
  },
  components: {
    Button: {
      defaultProps: {
        colorScheme: "blue",
      },
    },
  },
});

export default theme;
  1. Update the index file (frontend/src/index.tsx):
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import theme from "./styles/theme";
import { ChakraProvider } from "@chakra-ui/react";

ReactDOM.render(
  <React.StrictMode>
    <ChakraProvider theme={theme}>
      <App />
    </ChakraProvider>
  </React.StrictMode>,
  document.getElementById("root")
);
  1. Add environment variables (frontend/.env):
REACT_APP_CONTRACT_ADDRESS=your_deployed_contract_address
REACT_APP_SEI_RPC_URL=https://evm-rpc-testnet.sei.io
REACT_APP_SEI_CHAIN_ID=713715
  1. Start the frontend development server:
cd frontend
npm start

To use the frontend:

  1. Deploy the contracts to Sei testnet

  2. Update the .env file with contract addresses

  3. Start the development server

  4. Connect your MetaMask wallet

  5. Switch to Sei testnet

  6. Enter your birth date

  7. Submit for verification

Resources

Conclusion

This tutorial has walked you through implementing a zero-knowledge proof system on a Sei blockchain dapp. You've learned how to:

  • Set up a Hardhat project for Sei

  • Deploy upgradeable contracts

  • Implement ZK proofs

  • Create a frontend structure

  • Test and verify your implementation

Happy building on Sei!

Subscribe to 4undRaiser
Receive the latest updates directly to yourΒ inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.