Skip to main content
Version: dev

Building a web app with NoirJS

NoirJS is a set of packages meant to work both in a browser and a server environment. In this tutorial, we will build a simple web app using them. From here, you should get an idea on how to proceed with your own Noir projects!

You can find the complete app code for this guide here.

Setup

note

Feel free to use whatever versions, just keep in mind that Nargo and the NoirJS packages are meant to be in sync. For example, Nargo 0.19.x matches [email protected], etc.

In this guide, we will be pinned to 0.19.4.

Before we start, we want to make sure we have Node and Nargo installed.

We start by opening a terminal and executing node --version. If we don't get an output like v20.10.0, that means node is not installed. Let's do that by following the handy nvm guide.

As for Nargo, we can follow the the Nargo guide to install it. If you're lazy, just paste this on a terminal and run noirup:

curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash

Easy enough. Onwards!

Our project

ZK is a powerful technology. An app that doesn't reveal one of the inputs to anyone is almost unbelievable, yet Noir makes it as easy as a single line of code.

In fact, it's so simple that it comes nicely packaged in nargo. Let's do that!

Nargo

Run:

nargo new circuit

And... That's about it. Your program is ready to be compiled and run.

To compile, let's cd into the circuit folder to enter our project, and call:

nargo compile

This compiles our circuit into json format and add it to a new target folder.

info

At this point in the tutorial, your folder structure should look like this:

.
└── circuit <---- our working directory
├── Nargo.toml
├── src
│ └── main.nr
└── target
└── circuit.json

Node and Vite

If you want to explore Nargo, feel free to go on a side-quest now and follow the steps in the getting started guide. However, we want our app to run on the browser, so we need Vite.

Vite is a powerful tool to generate static websites. While it provides all kinds of features, let's just go barebones with some good old vanilla JS.

To do this this, go back to the previous folder (cd ..) and create a new vite project by running npm create vite and choosing "Vanilla" and "Javascript".

You should see vite-project appear in your root folder. This seems like a good time to cd into it and install our NoirJS packages:

npm i @noir-lang/[email protected] @noir-lang/[email protected]
info

At this point in the tutorial, your folder structure should look like this:

.
└── circuit
└── ...etc...
└── vite-project <---- our working directory
└── ...etc...

Some cleanup

npx create vite is amazing but it creates a bunch of files we don't really need for our simple example. Actually, let's just delete everything except for index.html, main.js and package.json. I feel lighter already.

my heart is ready for you, noir.js

HTML

Our app won't run like this, of course. We need some working HTML, at least. Let's open our broken-hearted index.html and replace everything with this code snippet:

<!DOCTYPE html>
<head>
<style>
.outer {
display: flex;
justify-content: space-between;
width: 100%;
}
.inner {
width: 45%;
border: 1px solid black;
padding: 10px;
word-wrap: break-word;
}
</style>
</head>
<body>
<script type="module" src="/main.js"></script>
<h1>Noir app</h1>
<div class="input-area">
<input id="guessInput" type="number" placeholder="Enter your guess" />
<button id="submitGuess">Submit Guess</button>
</div>
<div class="outer">
<div id="logs" class="inner"><h2>Logs</h2></div>
<div id="results" class="inner"><h2>Proof</h2></div>
</div>
</body>
</html>

It could be a beautiful UI... Depending on which universe you live in.

Some good old vanilla Javascript

Our love for Noir needs undivided attention, so let's just open main.js and delete everything (this is where the romantic scenery becomes a bit creepy).

Start by pasting in this boilerplate code:

const setup = async () => {
await Promise.all([
import("@noir-lang/noirc_abi").then(module =>
module.default(new URL("@noir-lang/noirc_abi/web/noirc_abi_wasm_bg.wasm", import.meta.url).toString())
),
import("@noir-lang/acvm_js").then(module =>
module.default(new URL("@noir-lang/acvm_js/web/acvm_js_bg.wasm", import.meta.url).toString())
)
]);
}

function display(container, msg) {
const c = document.getElementById(container);
const p = document.createElement('p');
p.textContent = msg;
c.appendChild(p);
}

document.getElementById('submitGuess').addEventListener('click', async () => {
try {
// here's where love happens
} catch(err) {
display("logs", "Oh 💔 Wrong guess")
}
});

The display function doesn't do much. We're simply manipulating our website to see stuff happening. For example, if the proof fails, it will simply log a broken heart 😢

As for the setup function, it's just a sad reminder that dealing with wasm on the browser is not as easy as it should. Just copy, paste, and forget.

info

At this point in the tutorial, your folder structure should look like this:

.
└── circuit
└── ...same as above
└── vite-project
├── main.js
├── package.json
└── index.html

You'll see other files and folders showing up (like package-lock.json, node_modules) but you shouldn't have to care about those.

Some NoirJS

We're starting with the good stuff now. If you've compiled the circuit as described above, you should have a json file we want to import at the very top of our main.js file:

import circuit from '../circuit/target/circuit.json';

Noir is backend-agnostic. We write Noir, but we also need a proving backend. That's why we need to import and instantiate the two dependencies we installed above: BarretenbergBackend and Noir. Let's import them right below:

import { BarretenbergBackend } from '@noir-lang/backend_barretenberg';
import { Noir } from '@noir-lang/noir_js';

And instantiate them inside our try-catch block:

// try {
const backend = new BarretenbergBackend(circuit);
const noir = new Noir(circuit, backend);
// }
note

For the remainder of the tutorial, everything will be happening inside the try block

Our app

Now for the app itself. We're capturing whatever is in the input when people press the submit button. Just add this:

const x = parseInt(document.getElementById('guessInput').value);
const input = { x, y: 2 };

Now we're ready to prove stuff! Let's feed some inputs to our circuit and calculate the proof:

await setup(); // let's squeeze our wasm inits here

display('logs', 'Generating proof... ⌛');
const proof = await noir.generateFinalProof(input);
display('logs', 'Generating proof... ✅');
display('results', proof.proof);

You're probably eager to see stuff happening, so go and run your app now!

From your terminal, run npm run dev. If it doesn't open a browser for you, just visit localhost:5173. You should now see the worst UI ever, with an ugly input.

Getting Started 0

Now, our circuit says fn main(x: Field, y: pub Field). This means only the y value is public, and it's hardcoded above: input = { x, y: 2 }. In other words, you won't need to send your secretx to the verifier!

By inputting any number other than 2 in the input box and clicking "submit", you should get a valid proof. Otherwise the proof won't even generate correctly. By the way, if you're human, you shouldn't be able to understand anything on the "proof" box. That's OK. We like you, human ❤️.

Verifying

Time to celebrate, yes! But we shouldn't trust machines so blindly. Let's add these lines to see our proof being verified:

display('logs', 'Verifying proof... ⌛');
const verification = await noir.verifyFinalProof(proof);
if (verification) display('logs', 'Verifying proof... ✅');

You have successfully generated a client-side Noir web app!

coded app without math knowledge

Further Reading

You can see how noirjs is used in a full stack Next.js hardhat application in the noir-starter repo here. The example shows how to calculate a proof in the browser and verify it with a deployed Solidity verifier contract from noirjs.

You should also check out the more advanced examples in the noir-examples repo, where you'll find reference usage for some cool apps.