Zero-Knowledge Proofs on Rootstock with Noir
Zero-knowledge proofs let a user prove they know something (a secret code, a credential, ownership) without ever revealing the secret itself. On Rootstock — the Bitcoin-secured, EVM-compatible smart contract chain — this unlocks real privacy while preserving Bitcoin-level security.
This hands-on tutorial teaches you how to use Noir (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 (for Noir toolchain)
- MetaMask wallet with tRBTC on Rootstock Testnet (Get tRBTC from Faucet)
- Basic knowledge of Solidity and React/Next.js
🚨 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.
curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash
noirup -v 1.0.0-beta.3
Verify installation:
nargo --version
# Should output: nargo version = 1.0.0-beta.3
Step 2: Install Barretenberg Backend
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.
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
bb --version
# Should output: v0.82.2
Step 3: Create the ZK Circuit
Create a new Noir project:
nargo new secret_club
cd secret_club
Replace src/main.nr with this circuit:
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 of the secret
- Asserts they match (proof succeeds only if user knows the correct secret)
Compile the circuit:
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):
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.
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
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:
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:
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):
public_hash = "0x297fad8a9bc7f877e7ae8ab582a32a16ec2d11cc57cd77ecab97d2c775fa29e8"
secret = "0x04e94fe643fe9000c83dd91f0be27855aa2cd791a3dfc1e05775749e89f4693e"
Once the Prover.toml file is filled, you can proceed to compile and execute the circuit:
nargo compile
nargo execute
These commands generate the secret_club.json and secret_club.gz files, which we will use moving forward.
🚨🚨 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:
# 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.
mkdir smart-contracts
cd smart-contracts
npx hardhat --init
You should see somethig like this below;
➜ 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:
// 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