ZK is a very promising technology that unlocks scalability and on-chain privacy. Last few months, ZK dev tools have been improved significantly. This means that it's now possible for us, developers, to create ZK DApps with better user experience. In this Demo we'll create a very simple ZK DApp with privacy enabled where we'll prove that X
and Y
are different numbers but without revealing X
.
On this guide we'll use Scroll Sepolia Testnet for on chain verification because it's very responsive, cheaper, and easy to use. If you want to use other chain I'll explain what exact changes would be needed. Also, we'll use Noir as a Circuit DSL. Noir offers good WASM support out of the box (needed for browsing proving) and ECDSA support (needed for anonymising, for example, a Metamask account). I'm very excited for Noir's ECDSA support so we'll cover that on a future guide so remember to subscribe to this blog and also on youtube.
tl;dr?
Check the live demo or github repo.
Before we start
For this tutorial you will need Metamask or other wallet of your choice, with Scroll Sepolia funds that you can get from a Sepolia faucet and then bridge them to L2 using the Scroll Sepolia bridge. Alternatively, you can use a Scroll Sepolia Faucet to get funds directly on L2.
Step 1. Install Nargo
In order to create Noir circuits you will need Nargo. On this guide we'll be using Nargo v17
so make sure you install that specific version. You can install it by running the following commands.
On Linux:
mkdir -p $HOME/.nargo/bin && \
curl -o $HOME/.nargo/bin/nargo-x86_64-unknown-linux-gnu.tar.gz -L https://github.com/noir-lang/noir/releases/download/v0.17.0/nargo-x86_64-unknown-linux-gnu.tar.gz && \
tar -xvf $HOME/.nargo/bin/nargo-x86_64-unknown-linux-gnu.tar.gz -C $HOME/.nargo/bin/ && \
echo 'export PATH=$PATH:$HOME/.nargo/bin' >> ~/.bashrc && \
source ~/.bashrc
On MAC:
mkdir -p $HOME/.nargo/bin && \
curl -o $HOME/.nargo/bin/nargo-x86_64-apple-darwin.tar.gz -L https://github.com/noir-lang/noir/releases/download/v0.17.0/nargo-x86_64-apple-darwin.tar.gz && \
tar -xvf $HOME/.nargo/bin/nargo-x86_64-apple-darwin.tar.gz -C $HOME/.nargo/bin/ && \
echo '\nexport PATH=$PATH:$HOME/.nargo/bin' >> ~/.zshrc && \
source ~/.zshrc
For more installation alternatives check out the official documentation.
Step 2. Deploy the verifier
Create a demo circuit, compile it, generate the solidity verifier and finally go back to the original directory.
nargo new circuit
cd circuit
nargo compile
nargo codegen-verifier
cd ..
The solidity vierifer should now be located at circuit/contract/circuit/plonk_vk.sol
. Deploy it and then deploy the following contract by passing the verifier address you just deployed as constructor parameter.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.4;
interface IUltraVerifier {
function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool);
}
contract VerificationCounter
{
uint public verifyCount;
IUltraVerifier ultraVerifier;
constructor(address ultraVerifierAddress)
{
ultraVerifier = IUltraVerifier(ultraVerifierAddress);
}
function sendProof(bytes calldata _proof, bytes32[] calldata _publicInputs) public
{
ultraVerifier.verify(_proof, _publicInputs);
verifyCount+=1;
}
}
Step 3. Setup the frontend
Let's start by setting up our package.json
file and installing the dependencies by running npm install
.
package.json
{
"name": "verification-counter",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "vite --open",
"build": "vite build",
"preview": "vite preview"
},
"author": "",
"license": "ISC",
"dependencies": {
"@noir-lang/backend_barretenberg": "^0.17.0",
"@noir-lang/noir_js": "^0.17.0"
},
"devDependencies": {
"rollup-plugin-copy": "^3.5.0",
"vite": "^4.5.0"
}
}
Next, we setup the vite config file needed to run the web server.
vite.config.js
import { defineConfig } from 'vite';
import copy from 'rollup-plugin-copy';
import fs from 'fs';
import path from 'path';
const wasmContentTypePlugin = {
name: 'wasm-content-type-plugin',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url.endsWith('.wasm')) {
res.setHeader('Content-Type', 'application/wasm');
const newPath = req.url.replace('deps', 'dist');
const targetPath = path.join(__dirname, newPath);
const wasmContent = fs.readFileSync(targetPath);
return res.end(wasmContent);
}
next();
});
},
};
export default defineConfig(({ command }) => {
if (command === 'serve') {
return {
plugins: [
copy({
targets: [{ src: 'node_modules/**/*.wasm', dest: 'node_modules/.vite/dist' }],
copySync: true,
hook: 'buildStart',
}),
command === 'serve' ? wasmContentTypePlugin : [],
],
};
}
return {};
});
All the frontend interactions will be done via the following html file.
index.html
<!DOCTYPE html>
<body>
<h1>Aztec Noir - Scroll Demo</h1>
<h2><i>Prove that X and Y are not equal</i></h2>
<input id="connect_button" type="button" value="Connect" onclick="connectWallet()" style="display: none"></input>
<p id="account_address" style="display: none"></p>
<p id="web3_message"></p>
<p id="contract_state"></p>
x: <input type="input" value="" id="_x"></input>
y: <input type="input" value="" id="_y"></input>
<input type="button" value="Send Poof" onclick="_sendProof()"></input>
<p id="public_input"></p>
<p id="proof"></p>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/web3/1.3.5/web3.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/ethereumjs/browser-builds/dist/ethereumjs-tx/ethereumjs-tx-1.3.3.min.js"></script>
<script type="module" src="/app.js"></script>
</body>
<script>
function _sendProof()
{
_x = document.getElementById("_x").value
_y = document.getElementById("_y").value
sendProof(_x, _y)
}
</script>
</html>
And all the web3 and zk logic on the following javascript file.
Notice you have to change VERIFIERCOUTNERADDRESS
to the VerifierCounter contract you just launched. Also, if you want to use this frontend on a chain other than Scroll Sepolia you just have to change the NETWORK_ID
variable from 534351
to the id of your desired chain.
app.js
import { BarretenbergBackend } from '@noir-lang/backend_barretenberg';
import { Noir } from '@noir-lang/noir_js';
import circuit from './circuit/target/circuit.json';
const NETWORK_ID = 534351
const MY_CONTRACT_ADDRESS = "VERIFIERCOUTNERADDRESS"
const MY_CONTRACT_ABI_PATH = "./json_abi/VerificationCounter.json"
var my_contract
var accounts
var web3
function metamaskReloadCallback() {
window.ethereum.on('accountsChanged', (accounts) => {
document.getElementById("web3_message").textContent="Se cambió el account, refrescando...";
window.location.reload()
})
window.ethereum.on('networkChanged', (accounts) => {
document.getElementById("web3_message").textContent="Se el network, refrescando...";
window.location.reload()
})
}
const getWeb3 = async () => {
return new Promise((resolve, reject) => {
if(document.readyState=="complete")
{
if (window.ethereum) {
const web3 = new Web3(window.ethereum)
window.location.reload()
resolve(web3)
} else {
reject("must install MetaMask")
document.getElementById("web3_message").textContent="Error: Please connect to Metamask";
}
}else
{
window.addEventListener("load", async () => {
if (window.ethereum) {
const web3 = new Web3(window.ethereum)
resolve(web3)
} else {
reject("must install MetaMask")
document.getElementById("web3_message").textContent="Error: Please install Metamask";
}
});
}
});
};
const getContract = async (web3, address, abi_path) => {
const response = await fetch(abi_path);
const data = await response.json();
const netId = await web3.eth.net.getId();
var contract = new web3.eth.Contract(
data,
address
);
return contract
}
async function loadDapp() {
metamaskReloadCallback()
document.getElementById("web3_message").textContent="Please connect to Metamask"
var awaitWeb3 = async function () {
web3 = await getWeb3()
web3.eth.net.getId((err, netId) => {
if (netId == NETWORK_ID) {
var awaitContract = async function () {
my_contract = await getContract(web3, MY_CONTRACT_ADDRESS, MY_CONTRACT_ABI_PATH)
document.getElementById("web3_message").textContent="You are connected to Metamask"
onContractInitCallback()
web3.eth.getAccounts(function(err, _accounts){
accounts = _accounts
if (err != null)
{
console.error("An error occurred: "+err)
} else if (accounts.length > 0)
{
onWalletConnectedCallback()
document.getElementById("account_address").style.display = "block"
} else
{
document.getElementById("connect_button").style.display = "block"
}
});
};
awaitContract();
} else {
document.getElementById("web3_message").textContent="Please connect to Scroll Sepolia";
}
});
};
awaitWeb3();
}
async function connectWallet() {
await window.ethereum.request({ method: "eth_requestAccounts" })
accounts = await web3.eth.getAccounts()
onWalletConnectedCallback()
}
window.connectWallet=connectWallet;
const onContractInitCallback = async () => {
var verifyCount = await my_contract.methods.verifyCount().call()
var contract_state = "verifyCount: " + verifyCount
document.getElementById("contract_state").textContent = contract_state;
}
const onWalletConnectedCallback = async () => {
}
document.addEventListener('DOMContentLoaded', async () => {
loadDapp()
});
const sendProof = async (x, y) => {
const backend = new BarretenbergBackend(circuit);
const noir = new Noir(circuit, backend);
const input = { x: x, y: y };
document.getElementById("web3_message").textContent="Generating proof... ⌛"
var proof = await noir.generateFinalProof(input);
document.getElementById("web3_message").textContent="Generating proof... ✅"
proof = "0x" + ethereumjs.Buffer.Buffer.from(proof.proof).toString('hex')
y = ethereumjs.Buffer.Buffer.from([y]).toString('hex')
y = "0x" + "0".repeat(64-y.length) + y
document.getElementById("public_input").textContent = "public input: " + y
document.getElementById("proof").textContent = "proof: " + proof
const result = await my_contract.methods.sendProof(proof, [y])
.send({ from: accounts[0], gas: 0, value: 0 })
.on('transactionHash', function(hash){
document.getElementById("web3_message").textContent="Executing...";
})
.on('receipt', function(receipt){
document.getElementById("web3_message").textContent="Success."; })
.catch((revertReason) => {
console.log("ERROR! Transaction reverted: " + revertReason.receipt.transactionHash)
});
}
window.sendProof=sendProof;
Finally, don't forget to add your JSON ABI on the following file.
json_abi/VerificationCounter.json
[
{
"inputs": [
{
"internalType": "bytes",
"name": "_proof",
"type": "bytes"
},
{
"internalType": "bytes32[]",
"name": "_publicInputs",
"type": "bytes32[]"
}
],
"name": "sendProof",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "ultraVerifierAddress",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "verifyCount",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
]
You should be ready to launch the server now.
Step 4. Submit proofs!
Start the server by running npm start
. Now you will be able to submit proofs as long as x
and y
are not equal. The contract on chain will count every successful proof submitted.
For reference, check the official Noir documentation.
Thanks for watching this guide!
Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.