Building a web app with Noir and Barretenberg
NoirJS is a Typescript package meant to work both in a browser and a server environment.
In this tutorial, we will combine NoirJS with Aztec's Barretenberg backend to build a simple web app. 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.
Dependencies
Before we start, we want to make sure we have Node installed. For convenience (and speed), we can just install Bun as our package manager, and Node will work out-of-the-box:
curl -fsSL https://bun.sh/install | bash
Let's go barebones. Doing the bare minimum is not only simple, but also allows you to easily adapt it to almost any frontend framework.
Barebones means we can immediately start with the dependencies even on an empty folder 😈:
bun i @noir-lang/noir_wasm@1.0.0-beta.0 @noir-lang/noir_js@1.0.0-beta.0 @aztec/bb.js@0.63.1
Wait, what are these dependencies?
noir_wasm
is thewasm
version of the Noir compiler. Although most developers prefer to usenargo
for compiling, there's nothing wrong withnoir_wasm
. We likenoir_wasm
.noir_js
is the main Noir package. It will execute our program, and generate the witness that will be sent to the backend.bb.js
is the Typescript interface for Aztec's Barretenberg proving backend. It also uses thewasm
version in order to run on the browser.
In this guide, we will install versions pinned to 1.0.0-beta.0. These work with Barretenberg version 0.63.1, so we are using that one version too. Feel free to try with older or later versions, though!
Setting up our Noir program
ZK is a powerful technology. An app that reveals computational correctness but doesn't reveal some of its inputs is almost unbelievable, yet Noir makes it as easy as a single line of code.
It's not just you. We also enjoy syntax highlighting. Check out the Language Server
All you need is a main.nr
and a Nargo.toml
file. You can follow the noirup installation and just run noirup -v 1.0.0-beta.0
, or just create them by hand:
mkdir -p circuit/src
touch circuit/src/main.nr circuit/Nargo.toml
To make our program interesting, let's give it a real use-case scenario: Bob wants to prove he is older than 18, without disclosing his age. Open main.nr
and write:
fn main(age: u8) {
assert(age >= 18);
}
This program accepts a private input called age, and simply proves this number is higher than 18. But to run this code, we need to give the compiler a Nargo.toml
with at least a name and a type:
[package]
name = "circuit"
type = "bin"
This is all that we need to get started with Noir.
Setting up our app
Remember when apps only had one html
and one js
file? Well, that's enough for Noir webapps. Let's create them:
touch index.html index.js
And add something useful to our HTML file:
<!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="/index.js"></script>
<h1>Noir app</h1>
<div class="input-area">
<input id="age" type="number" placeholder="Enter age" />
<button id="submit">Submit Age</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. In any case, we're using some scary CSS to make two boxes that will show cool things on the screen.
As for the JS, real madmen could just console.log
everything, but let's say we want to see things happening (the true initial purpose of JS... right?). Here's some boilerplate for that. Just paste it in index.js
:
const show = (id, content) => {
const container = document.getElementById(id);
container.appendChild(document.createTextNode(content));
container.appendChild(document.createElement("br"));
};
document.getElementById("submit").addEventListener("click", async () => {
try {
// noir goes here
} catch {
show("logs", "Oh 💔");
}
});
At this point in the tutorial, your folder structure should look like this:
.
└── circuit
└── src
└── main.nr
Nargo.toml
index.js
package.json
index.html
...etc
Compile compile compile
Finally we're up for something cool. But before we can execute a Noir program, we need to compile it into ACIR: an abstract representation. Here's where noir_wasm
comes in.
noir_wasm
expects a filesystem so it can resolve dependencies. While we could use the public
folder, let's just import those using the nice ?url
syntax provided by vite. At the top of the file:
import { compile, createFileManager } from "@noir-lang/noir_wasm"
import main from "./circuit/src/main.nr?url";
import nargoToml from "./circuit/Nargo.toml?url";
Compiling on the browser is common enough that createFileManager
already gives us a nice in-memory filesystem we can use. So all we need to compile is fetching these files, writing them to our filesystem, and compile. Add this function:
export async function getCircuit() {
const fm = createFileManager("/");
const { body } = await fetch(main);
const { body: nargoTomlBody } = await fetch(nargoToml);
fm.writeFile("./src/main.nr", body);
fm.writeFile("./Nargo.toml", nargoTomlBody);
return await compile(fm);
}
As you can imagine, with node
it's all conveniently easier since you get native access to fs
...
Some more JS
We're starting with the good stuff now. We want to execute our circuit to get the witness, and then feed that witness to Barretenberg. Luckily, both packages are quite easy to work with. Let's import them at the top of the file:
import { UltraHonkBackend } from '@aztec/bb.js';
import { Noir } from '@noir-lang/noir_js';
And instantiate them inside our try-catch block:
// try {
const { program } = await getCircuit();
const noir = new Noir(program);
const backend = new UltraHonkBackend(program.bytecode);
// }
WASMs are not always easy to work with. In our case, vite
likes serving them with the wrong MIME type. There are different fixes but we found the easiest one is just YOLO instantiating the WASMs manually. Paste this at the top of the file, just below the other imports, and it will work just fine:
import initNoirC from "@noir-lang/noirc_abi";
import initACVM from "@noir-lang/acvm_js";
import acvm from "@noir-lang/acvm_js/web/acvm_js_bg.wasm?url";
import noirc from "@noir-lang/noirc_abi/web/noirc_abi_wasm_bg.wasm?url";
await Promise.all([initACVM(fetch(acvm)), initNoirC(fetch(noirc))]);
Executing and proving
Now for the app itself. We're capturing whatever is in the input when people press the submit button. Inside our try
block, let's just grab that input and get its value. Noir will gladly execute it, and give us a witness:
const age = document.getElementById("age").value;
show("logs", "Generating witness... ⏳");
const { witness } = await noir.execute({ age });
show("logs", "Generated witness... ✅");
For the remainder of the tutorial, everything will be happening inside the try
block
Now we're ready to prove stuff! Let's feed some inputs to our circuit and calculate the proof:
show("logs", "Generating proof... ⏳");
const proof = await backend.generateProof(witness);
show("logs", "Generated proof... ✅");
show("results", proof.proof);
Our program is technically done . You're probably eager to see stuff happening! To serve this in a convenient way, we can use a bundler like vite
by creating a vite.config.js
file:
touch vite.config.js
vite
helps us with a little catch: bb.js
in particular uses top-level awaits which aren't supported everywhere. So we can add this to the vite.config.js
to make the bundler optimize them:
export default { optimizeDeps: { esbuildOptions: { target: "esnext" } } };
This should be enough for vite. We don't even need to install it, just run:
bunx vite
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.
Now, our circuit requires a private input fn main(age: u8)
, and fails if it is less than 18. Let's see if it works. Submit any number above 18 (as long as it fits in 8 bits) and 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:
show('logs', 'Verifying proof... ⌛');
const isValid = await backend.verifyProof(proof);
show("logs", `Proof is ${isValid ? "valid" : "invalid"}... ✅`);
You have successfully generated a client-side Noir web app!
Next steps
At this point, you have a working ZK app that works on the browser. Actually, it works on a mobile phone too!
If you want to continue learning by doing, here are some challenges for you:
- Install nargo and write Noir tests
- Change the circuit to accept a public input as the cutoff age. It could be different depending on the purpose, for example!
- Enjoy Noir's Rust-like syntax and write a struct
Country
that implements a traitMinAge
with a methodget_min_age
. Then, make a structPerson
have anu8
as its age and a country of typeCountry
. You can pass aperson
in JS just like a JSON objectperson: { age, country: { min_age: 18 }}
The world is your stage, just have fun with ZK! 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.
Check out other starters, tools, or just cool projects in the awesome noir repository.