[Aztec Noir + Scroll] ZK development made easy

Ahmed Castro - Nov 8 '23 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ..
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {};
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
    }
]
Enter fullscreen mode Exit fullscreen mode

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.

Noir WASM Proving on Scroll Sepolia Testnet

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.

. . . . . . . . . . . . . . . . . . . . . . . . .