[Zero-knowledge proofs](https://en.wikipedia.org/wiki/Zero-knowledge_proof) let a user prove they know something (a secret code, a credential, ownership) without ever revealing the secret itself on Rootstock. This hands-on tutorial teaches you how to use **[Noir](https://noir-lang.org/)** (a developer-friendly ZK DSL - Domain Specific Language) to build a **Secret NFT Club**: users get an exclusive membership only by proving they know the secret password, the password never appears on-chain or in the browser console. ## What You'll Build A privacy-preserving membership system where: - Users prove they know a secret password without revealing it - The proof is verified on-chain using zero-knowledge cryptography - Members are able to join the club upon successful verification - The password never appears in transactions, logs, or browser console **Privacy guarantee:** Even if someone inspects all blockchain data, they cannot determine the secret password. ## Prerequisites - Node.js β₯ 18 - [Rust](https://rust-lang.org/tools/install/) (for Noir toolchain) - MetaMask wallet with tRBTC on Rootstock Testnet ([Get tRBTC from Faucet](https://faucet.rootstock.io/)) - Basic knowledge of Solidity and React/Next.js :::warning[Note] π¨ Windows Users: Noir (nargo, bb) isnβt natively supported on Windows. Please install and run Noir inside WSL (Windows Subsystem for Linux) using Ubuntu 24.04.. π¨ ::: ## Part 1: Setup & Circuit Development ### Step 1: Install Noir (Nargo CLI) We'll use `nargo version = 1.0.0-beta.3`. ```bash curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash noirup -v 1.0.0-beta.3 ``` Verify installation: ```bash nargo --version # Should output: nargo version = 1.0.0-beta.3 ``` ### Step 2: Install Barretenberg Backend [Barretenberg](https://github.com/AztecProtocol/barretenberg) is the proving backend that generates and verifies zero-knowledge proofs. We use it for key operations such as generating proofs, producing and checking verification keys, and generating the verifier smart contract. Without Barretenberg, our dApp wouldnβt be able to let users prove they know the clubβs secret code privately, without ever revealing the code itself. ```bash curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/refs/heads/master/barretenberg/bbup/install | bash bbup -v 0.82.2 ``` Verify: Make sure to open a new terminal to verify your installation if you get the error `bb command not found` ```bash bb --version # Should output: v0.82.2 ``` ### Step 3: Create the ZK Circuit Create a new Noir project: ```bash nargo new secret_club cd secret_club ``` Replace `src/main.nr` with this circuit: ```rust use std::hash::pedersen_hash; fn main(secret: Field, public_hash: pub Field) { let computed_hash = pedersen_hash([secret]); assert(computed_hash == public_hash); } ``` **What this does:** - Takes a `secret` (private input - never revealed) - Takes a `public_hash` (public input - visible to everyone) - Computes [Pedersen hash](https://iden3-docs.readthedocs.io/en/latest/iden3_repos/research/publications/zkproof-standards-workshop-2/pedersen-hash/pedersen.html) of the secret - Asserts they match (proof succeeds only if user knows the correct secret) Compile the circuit: ```bash nargo compile ``` This creates `target/secret_club.json` containing the compiled circuit. ### Step 4: Compute the Secret Hash **Critical Step:** We need to calculate the Pedersen hash of our secret password before deployment. This hash will be public and stored in the smart contract. #### Convert Your Password to Field Element Convert your secret password to a Field element using SHA256 (recommended for uniform distribution): ```bash echo -n "supersecret2025" | sha256sum | awk '{print "0x"$1}' ``` Expected Output: ``` 0x04e94fe643fe9000c83dd91f0be27855aa2cd791a3dfc1e05775749e89f4693e ``` #### Now let's compute the Pedersen Hash To compute the pedersen hash, we'll slightly modify our `main.nr` We're adding a println to print the perderson hash and we're writing a test to output this hash to the console. ```rust use std::hash::pedersen_hash; fn main(secret: Field, public_hash: pub Field) { let computed_hash = pedersen_hash([secret]); println(computed_hash); // we added this line to print the perderson hash assert(computed_hash == public_hash); } #[test] fn test_main() { main( 0x04e94fe643fe9000c83dd91f0be27855aa2cd791a3dfc1e05775749e89f4693e, 0x3, // this is just a placeholder for the public hash which will cause the test to fail, but we will get the perderson hash logged to the console ); } ``` Then in your terminal, run the command ```bash nargo test --show-output ``` Look for the **test_main stdout** in the output - this is your Pedersen hash! Example output: ``` --- test_main stdout --- 0x297fad8a9bc7f877e7ae8ab582a32a16ec2d11cc57cd77ecab97d2c775fa29e8 ------------------------ ``` **Save this hash!** You'll need it for: - Smart contract deployment - Frontend configuration - Testing Before we can proceed to run the `nargo execute` command, we need to generate a `Prover.toml` file. This file holds the witness values (i.e. the `secret` and the `public_hash`). To generate it, we start by running: ```bash nargo check ``` Running `nargo check` creates a new `Prover.toml` file, prefilled based on the inputs defined in the main function of our `main.nr` circuit: ```toml public_hash = "" secret = "" ``` Now we can fill in these fields with our actual witness values β the hashed `secret` (for example, the SHA-256 hash of 'supersecret2025') and the `public_hash` (the corresponding Pedersen hash): ```toml public_hash = "0x297fad8a9bc7f877e7ae8ab582a32a16ec2d11cc57cd77ecab97d2c775fa29e8" secret = "0x04e94fe643fe9000c83dd91f0be27855aa2cd791a3dfc1e05775749e89f4693e" ``` Once the `Prover.toml` file is filled, you can proceed to compile and execute the circuit: ```bash nargo compile nargo execute ``` These commands generate the `secret_club.json` and `secret_club.gz` files, which we will use moving forward. :::warning[Note] π¨π¨ Always delete the files in the `target` folder when you change your circuit or inputs to ensure a clean setup. Whenever the circuit changes, you must also regenerate and replace the verifier smart contract in your Solidity project. π¨π¨ ::: ### Step 5: Generate the Solidity Verifier Modern Noir uses Barretenberg to generate the Solidity verifier: ```bash # Generate verification key bb write_vk --oracle_hash keccak -b ./target/secret_club.json -o ./target # Generate Solidity verifier contract bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol ``` This creates `Verifier.sol` in the `./target/Verifier.sol`. The vk is embedded into this contract, enabling Rootsock to check proofs generated for your circuit. ## Part 2: Smart Contract Development ### Step 6: Create the Secret NFT Club Contract #### Setup Hardhat Install dependencies: Make sure to run these commands in the existing `secret_club` folder. ```bash mkdir smart-contracts cd smart-contracts npx hardhat --init ``` You should see somethig like this below; ```bash β smart-contracts git:(main) npx hardhat --init βββββ βββββ βββ βββ βββ ββββββ βββββ βββββ ββββ ββββ ββββ ββββββββ ββββ ββββ ββββββ ββββββββ βββββββ ββββββββ ββββββ βββββββ βββ ββββ βββββββββββ ββββββββββββββββββ ββββββββ βββββββββ βββββββββββββββ βββββ βββββββββββ βββββββ ββββ βββ ββββ ββββ ββββ ββββ βββββββ ββββ βββββββ ββββ ββββ ββββββββ ββββ ββββ ββββ ββββ ββββ ββββββββ ββββ βββ βββ ββββ βββββ ββββββββββββββ βββββ βββββββββ ββββ ββββββββββββββ βββββββ ββββββββ βββββ βββββ βββββββ βββββ βββββββ ββββ βββββ βββββββ βββββ ββββββ π· Welcome to Hardhat v3.0.16 π· β Which version of Hardhat would you like to use? Β· hardhat-3 β Where would you like to initialize the project? Please provide either a relative or an absolute path: Β· ./ β What type of project would you like to initialize? Β· mocha-ethers β¨ Template files copied β¨ β You need to install the necessary dependencies using the following command: npm install --save-dev "hardhat@^3.0.16" "@nomicfoundation/hardhat-toolbox-mocha-ethers@^3.0.1" "@nomicfoundation/hardhat-ethers@^4.0.2" "@nomicfoundation/hardhat-ignition@^3.0.5" "@types/chai@^4.2.0" "@types/chai-as-promised@^8.0.1" "@types/mocha@>=10.0.10" "@types/node@^22.8.5" "chai@^5.1.2" "ethers@^6.14.0" "forge-std@foundry-rs/forge-std#v1.9.4" "mocha@^11.0.0" "typescript@~5.8.0" Do you want to run it now? (Y/n) Β· true npm install --save-dev "hardhat@^3.0.16" "@nomicfoundation/hardhat-toolbox-mocha-ethers@^3.0.1" "@nomicfoundation/hardhat-ethers@^4.0.2" "@nomicfoundation/hardhat-ignition@^3.0.5" "@types/chai@^4.2.0" "@types/chai-as-promised@^8.0.1" "@types/mocha@>=10.0.10" "@types/node@^22.8.5" "chai@^5.1.2" "ethers@^6.14.0" "forge-std@foundry-rs/forge-std#v1.9.4" "mocha@^11.0.0" "typescript@~5.8.0" npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. npm warn deprecated glob@7.1.7: Glob versions prior to v9 are no longer supported added 275 packages, and audited 276 packages in 57s 71 packages are looking for funding run `npm fund` for details found 0 vulnerabilities β¨ Dependencies installed β¨ Give Hardhat a star on Github if you're enjoying it! βοΈβ¨ https://github.com/NomicFoundation/hardhat β smart-contracts git:(main) β ``` You can delete all the existing template files in the `contracts` folder, i.e. the `Counter.sol` and the `Counter.t.sol` files. Then, create a new contract; `contracts/SecretNFTClub.sol`: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; interface IVerifier { function verify( bytes calldata _proof, bytes32[] calldata _publicInputs ) external view returns (bool); } contract SecretNFTClub { IVerifier public immutable verifier; bytes32 public immutable secretHash; mapping(address => bool) public hasJoined; mapping(address => uint256) public memberTokenId; uint256 private _nextTokenId; event MemberJoined(address indexed member, uint256 indexed tokenId); error AlreadyMember(); error InvalidProof(); constructor(bytes32 _secretHash, address _verifier) { secretHash = _secretHash; verifier = IVerifier(_verifier); } function join(bytes calldata proof) external { if (hasJoined[msg.sender]) revert AlreadyMember(); // Prepare public inputs (just the secret hash) bytes32[] memory publicInputs = new bytes32[](1); publicInputs[0] = secretHash; // Verify the zero-knowledge proof if (!verifier.verify(proof, publicInputs)) revert InvalidProof(); // Proof verified! Grant membership uint256 tokenId = _nextTokenId++; hasJoined[msg.sender] = true; memberTokenId[msg.sender] = tokenId; emit MemberJoined(msg.sender, tokenId); } function isMember(address account) external view returns (bool) { return hasJoined[account]; } function totalMembers() external view returns (uint256) { return _nextTokenId; } } ``` Make sure to also copy the the `Verifier` contract from `./target/Verifier.sol` into your smart-contracts directory in a new file `contracts/Verifier.sol`. This contract will also be deployed and will be used on our `SecretNFTClub` contract to verify a proof. **Design decisions:** - Simple mapping-based membership (more gas-efficient than ERC721) - Immutable verifier and hash (gas optimization + security) - Custom errors (saves gas over require strings) - Events for off-chain tracking ## Part 3: Deployment ### Step 7: Deploy to Rootstock Testnet :::info[Note] These details can be configured on your software wallet(i.e. Metamask, Rabby), you can see this page: [Configure MetaMask Wallet for Rootstock](https://dev.rootstock.io/dev-tools/wallets/metamask/) ::: **Rootstock Testnet Details:** | Parameter | Value | | -------------- | ------------------------------------------- | | RPC URL | `https://public-node.testnet.rsk.co` | | Chain ID | `31` | | Currency | tRBTC | | Block Explorer | `https://rootstock-testnet.blockscout.com/` | | Faucet | `https://faucet.rootstock.io` | Then, we'll need to install `dotenv` for the environment variables we're going to be using in the `hardhat.config.ts`. We'll do that by running the command: ```bash npm install dotenv ``` Configure `hardhat.config.ts`: ```javascript dotenv.config(); export default defineConfig({ plugins: [hardhatToolboxMochaEthersPlugin], solidity: { profiles: { default: { version: "0.8.28", }, production: { version: "0.8.28", settings: { optimizer: { enabled: true, runs: 200, }, }, }, }, }, networks: { hardhatMainnet: { type: "edr-simulated", chainType: "l1", }, hardhatOp: { type: "edr-simulated", chainType: "op", }, sepolia: { type: "http", chainType: "l1", url: configVariable("SEPOLIA_RPC_URL"), accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], }, rootstock: { type: "http", url: process.env.ROOTSTOCK_TESTNET_RPC_URL!, accounts: [process.env.WALLET_KEY!], }, }, }); ``` Create `.env`: ```bash WALLET_KEY=your_private_key_here ROOTSTOCK_TESTNET_RPC_URL=your_rootstock_testnetrpc_url_here ``` #### Deployment Script Create `scripts/deploy.js`: ```javascript const { ethers, networkName } = await network.connect(); async function main() { console.log("π Deploying to Rootstock Testnet...\n"); // Deploy HonkVerifier console.log("π Deploying HonkVerifier..."); const Verifier = await ethers.getContractFactory("HonkVerifier"); const verifier = await Verifier.deploy(); await verifier.waitForDeployment(); const verifierAddress = await verifier.getAddress(); console.log("β HonkVerifier deployed:", verifierAddress); // IMPORTANT: Replace wnvdjfbjfnejrfg rewhi gfignrkndknfdkwnkdnfjwfn owj nrwb grujwbzzzZZith YOUR computed Pedersen hash from Step 4 const SECRET_HASH = "0x297fad8a9bc7f877e7ae8ab582a32a16ec2d11cc57cd77ecab97d2c775fa29e8"; // Deploy SecretNFTClub console.log("\nπ Deploying SecretNFTClub..."); const Club = await ethers.getContractFactory("SecretNFTClub"); const club = await Club.deploy(SECRET_HASH, verifierAddress); await club.waitForDeployment(); const clubAddress = await club.getAddress(); console.log("β SecretNFTClub deployed:", clubAddress); // Summary console.log("\n" + "=".repeat(50)); console.log("π DEPLOYMENT SUMMARY"); console.log("=".repeat(50)); console.log("Verifier: ", verifierAddress); console.log("Club: ", clubAddress); console.log("Secret Hash: ", SECRET_HASH); console.log("Network: ", "Rootstock Testnet"); console.log( "Explorer: ", `https://explorer.testnet.rootstock.io/address/${clubAddress}` ); console.log("=".repeat(50)); // Save addresses for frontend fs.writeFileSync( "deployment.json", JSON.stringify( { verifier: verifierAddress, club: clubAddress, secretHash: SECRET_HASH, network: "rootstock", }, null, 2 ) ); console.log("\nβ Addresses saved to deployment.json"); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); }); ``` Deploy: ```bash npx hardhat run scripts/deploy.js --build-profile production --network rootstock ``` This command runs your deployment script using the [hardhat `production` build profile](https://hardhat.org/docs/guides/writing-contracts/build-profiles#choosing-a-build-profile) and deploys the contracts to the **Rootstock testnet** network. Expected output: ``` π Deploying to Rootstock Testnet... π Deploying HonkVerifier... β UltraVerifier deployed: 0x1234... π Deploying SecretNFTClub... β SecretNFTClub deployed: 0x5678... ``` ##### Deployment Summary This script takes care of deploying two smart contracts `HonkVerifier` and `SecretNFTClub` to the Rootstock Testnet. Once the deployment is complete, it provides a helpful summary so you can quickly confirm everything went as expected. ##### What the summary shows After both contracts are successfully deployed, the script prints out: - The contract address of the `HonkVerifier` - The contract address of the `SecretNFTClub` - The secret hash used during verification - The network name (Rootstock Testnet) - A direct explorer link to view the `SecretNFTClub` contract on the **Rootstock Testnet** This makes it easy to immediately verify the deployment and locate your contracts on-chain. ##### Saving deployment details In addition to logging the details to the console, the script also saves all relevant deployment information to a deployment.json file. This file can be reused by your frontend or other scripts to interact with the deployed contracts without hardcoding addresses or configuration values. ##### Why this matters The deployment summary serves as a quick checkpoint: it confirms a successful deployment, gives you instant access to important contract details, and creates a reliable reference for future development, testing, or debugging. ## Part 4: Frontend Integration ### Step 8: Setup Frontend Project Create a new Vite + React project: :::info[Note] You can open a new terminal tab and run these commands to begin implementing the frontend. ::: ```bash npm create vite@latest secret-club-frontend -- --template react cd secret-club-frontend ``` Make sure to run these commands in the existing `secret_club` folder. Install dependencies: ```bash npm install @noir-lang/noir_js@1.0.0-beta.3 @aztec/bb.js@0.82.0 ethers ``` ### Step 9: Create the Join Club Component Create `src/JoinClub.jsx`: ```jsx // Initialize WASM modules await Promise.all([initACVM(fetch(acvm)), initNoirC(fetch(noirc))]); const CLUB_ABI = [ "function join(bytes proof) external", "function isMember(address) view returns (bool)", "function totalMembers() view returns (uint256)", "event MemberJoined(address indexed member, uint256 indexed tokenId)", ]; export default function JoinClub() { const [status, setStatus] = useState("Ready"); const [loading, setLoading] = useState(false); const [account, setAccount] = useState(null); const [isMember, setIsMember] = useState(false); const [totalMembers, setTotalMembers] = useState(0); useEffect(() => { checkConnection(); loadMembershipInfo(); }, [account]); async function checkConnection() { if (typeof window.ethereum !== "undefined") { try { const accounts = await window.ethereum.request({ method: "eth_accounts", }); if (accounts.length > 0) { setAccount(accounts[0]); } } catch (error) { console.error("Error checking connection:", error); } } } async function loadMembershipInfo() { if (!account) return; try { const provider = new ethers.BrowserProvider(window.ethereum); const club = new ethers.Contract(deploymentInfo.club, CLUB_ABI, provider); const code = await provider.getCode(deploymentInfo.club); console.log("Codeeeeeeee", code); const memberStatus = await club.isMember(account); console.log("Membership status", memberStatus); setIsMember(memberStatus); const total = await club.totalMembers(); console.log("total members", total); setTotalMembers(Number(total)); } catch (error) { console.error("Error loading membership:", error); } } async function connectWallet() { if (typeof window.ethereum === "undefined") { alert("Please install MetaMask!"); return; } const targetChainId = "0x1f"; try { const accounts = await window.ethereum.request({ method: "eth_requestAccounts", }); setAccount(accounts[0]); // Check if on correct network const currentChainId = await window.ethereum.request({ method: "eth_chainId", }); if (currentChainId !== targetChainId) { try { // Try switching first await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainId }], }); } catch (switchError) { // Error 4902 = chain not added to MetaMask if (switchError.code === 4902) { // Add the Rootstock Testnet chain await window.ethereum.request({ method: "wallet_addEthereumChain", params: [ { chainId: targetChainId, chainName: "Rootstock Testnet", nativeCurrency: { name: "tRBTC", symbol: "tRBTC", decimals: 18, }, rpcUrls: ["https://public-node.testnet.rsk.co"], blockExplorerUrls: [ "https://rootstock-testnet.blockscout.com/", ], }, ], }); } else { console.error("Failed to switch chain:", switchError); } } } } catch (error) { console.error("Error connecting wallet:", error); alert("Failed to connect wallet"); } } async function joinClub() { if (!account) { await connectWallet(); return; } try { setLoading(true); // Step 1: Get secret from user const secret = prompt("Enter the secret password:"); if (!secret) { setStatus("Cancelled"); return; } setStatus("Converting password to Field element..."); // Step 2: Convert string to Field using SHA256 const secretBytes = new TextEncoder().encode(secret); const hashBuffer = await crypto.subtle.digest("SHA-256", secretBytes); const secretField = "0x" + Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); setStatus("Initializing ZK backend (first time: ~10-15s)..."); // Step 3: Initialize Noir backend const noir = new Noir(circuit); const backend = new UltraHonkBackend(circuit.bytecode); setStatus("Generating zero-knowledge proof..."); // Step 4: Generate proof const { witness } = await noir.execute({ secret: secretField, public_hash: deploymentInfo.secretHash, }); const proof = await backend.generateProof(witness, { keccak: true }); setStatus("Proof generated! Submitting to blockchain..."); // Step 5: Submit to smart contract const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner(); const club = new ethers.Contract(deploymentInfo.club, CLUB_ABI, signer); const tx = await club.join(proof.proof); setStatus("Transaction submitted! Waiting for confirmation..."); const receipt = await tx.wait(); setStatus("β Success! You're now a member!"); // Refresh membership status await loadMembershipInfo(); console.log("Transaction:", receipt.hash); } catch (error) { console.error("Error:", error); if (error.message.includes("AlreadyMember")) { setStatus("β You're already a member!"); } else if (error.message.includes("InvalidProof")) { setStatus("β Wrong password! Proof verification failed."); } else { setStatus(`β Error: ${error.message}`); } } finally { setLoading(false); } } return (
Prove you know the secret password using Zero-Knowledge Proofs
Connected: {account.slice(0, 6)}...{account.slice(-4)}
{status}
How it works: