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.
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)
mkdir sei-zk-dapp
cd sei-zk-dapp
npm init -y
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
npx hardhat init
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",
},
};
.env
file:PRIVATE_KEY=your_private_key_here
SEI_RPC_URL=https://evm-rpc-testnet.sei.io
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
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
{}
}
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);
});
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");
}
}
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(),
};
}
Make sure you have testnet SEI tokens in your wallet
Deploy the contracts:
npx hardhat run scripts/deploy.ts --network seitestnet
.env
file with the deployed contract addresses:REACT_APP_CONTRACT_ADDRESS=your_deployed_contract_address
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
Let's create a complete React frontend for our ZK proof dApp. We'll use TypeScript and modern React practices.
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
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;
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;
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;
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;
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")
);
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
cd frontend
npm start
To use the frontend:
Deploy the contracts to Sei testnet
Update the .env
file with contract addresses
Start the development server
Connect your MetaMask wallet
Switch to Sei testnet
Enter your birth date
Submit for verification
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!