使用 Noir 的前端证明
教程代码可在此处查看
本指南演示如何开发一款由 Noir 支持前端生成证明、并通过 Kurier 验证的 NextJS 应用。我们将从零编写简单 Noir 电路,再用 bbjs 在客户端生成证明。
首先用命令创建 NextJS 应用:
npx create-next-app@latest
创建过程中会遇到以下选项:
What is your project named? my-appWould you like to use TypeScript? No / Yes # Select yesWould you like to use ESLint? No / Yes # Select yesWould you like to use Tailwind CSS? No / Yes # Select yesWould you like your code inside a `src/` directory? No / Yes # Select yesWould you like to use App Router? (recommended) No / Yes # Select noWould you like to use Turbopack for `next dev`? No / Yes # Select yesWould you like to customize the import alias (`@/*` by default)? No / Yes # Select noWhat import alias would you like configured? @/*
接着安装依赖 axios、@noir-lang/noir_js 与 @aztec/bb.js,先进入项目目录再执行 npm 安装:
cd your-project-name
npm i axios @noir-lang/[email protected] @aztec/[email protected]
在 IDE 中打开 NextJS 应用,查看目录结构。我们将在 public 目录中创建新的 Noir 项目,执行:
cd public
nargo new multiply
multiply 的 src 目录下有 main.nr。不深讲 DSL,仅提供必要代码:简单电路接收 x、y、z,约束 z === (x*y)。将文件内容替换为:
fn main(x: Field, y: Field, z: pub Field) {assert(z == x*y);}
然后编译项目,进入 multiply 目录使用 nargo:
cd multiply
nargo compile
此时 public 目录应类似:

在项目根目录创建 .env 存放 Kurier API key,后续用于验证:
API_KEY = "向 Horizen Labs 获取的 API Key"
接下来在 NextJS 创建后端 API,接收证明产物并调用 Kurier 验证,供前端使用。于 api 子目录新建 kurier.ts。
首先引入 axios、bbjs、aztecjs 等依赖。然后创建处理函数,接受 POST 请求,读取请求体中的证明产物进行验证;声明 Kurier 的 API_URL。还需编写 concatenatePublicInputsAndProof() 将 Ultraplonk 证明格式化为 Kurier 所需格式。
在提交验证前需注册 verification key(每个 proof 类型一次即可,可降低验证成本)。注册完成后调用 Kurier API 提交 proof 并轮询状态;收到 IncludedInBlock 事件即向前端返回交易数据。可参考Kurier 教程。
import axios from "axios";import { NextApiRequest, NextApiResponse } from "next";import { Buffer } from "buffer";const API_URL = "https://api-testnet.kurier.xyz/api/v1";export default async function handler(req: NextApiRequest, res: NextApiResponse){if (req.method !== 'POST') {return res.status(405).json({ error: 'Method not allowed' });}try{const proofUint8 = new Uint8Array(Object.values(req.body.proof));const params = {"proofType": "ultraplonk","vkRegistered": false,"proofOptions": {"numberOfPublicInputs": 1},"proofData": {"proof": Buffer.from(concatenatePublicInputsAndProof(req.body.publicInputs, proofUint8)).toString("base64"),"vk": req.body.vk}}const requestResponse = await axios.post(`${API_URL}/submit-proof/${process.env.API_KEY}`, params)console.log(requestResponse.data)if(requestResponse.data.optimisticVerify != "success"){console.error("Proof verification, check proof artifacts");return;}while(true){const jobStatusResponse = await axios.get(`${API_URL}/job-status/${process.env.API_KEY}/${requestResponse.data.jobId}`);if(jobStatusResponse.data.status === "IncludedInBlock"){console.log("Job Included in Block successfully");res.status(200).json(jobStatusResponse.data);return;}else{console.log("Job status: ", jobStatusResponse.data.status);console.log("Waiting for job to finalize...");await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for 5 seconds before checking again}}}catch(error){console.log(error)}}function hexToUint8Array(hex: any) {if (hex.startsWith('0x')) hex = hex.slice(2);if (hex.length % 2 !== 0) hex = '0' + hex;const bytes = new Uint8Array(hex.length / 2);for (let i = 0; i < bytes.length; i++) {bytes[i] = parseInt(hex.substr(i * 2, 2), 16);}return bytes;}function concatenatePublicInputsAndProof(publicInputsHex: any, proofUint8: any) {const publicInputBytesArray = publicInputsHex.flatMap((hex: any) =>Array.from(hexToUint8Array(hex)));const publicInputBytes = new Uint8Array(publicInputBytesArray);console.log(publicInputBytes.length, proofUint8.length)import axios from "axios";import { NextApiRequest, NextApiResponse } from "next";import { Buffer } from "buffer";import fs from "fs";import path from "path";export default async function handler(req: NextApiRequest, res: NextApiResponse){if (req.method !== 'POST') {return res.status(405).json({ error: 'Method not allowed' });}try{const proofUint8 = new Uint8Array(Object.values(req.body.proof));if(fs.existsSync(path.join(process.cwd(), "public", "multiply", "vkey.json")) === false) {await registerVk(req.body.vk);await new Promise(resolve => setTimeout(resolve, 5000));}const vk = fs.readFileSync(path.join(process.cwd(),"public","multiply","vkey.json"),"utf-8");const params = {"proofType": "ultraplonk","vkRegistered": true,"proofOptions": {"numberOfPublicInputs": 1},"proofData": {"proof": Buffer.from(concatenatePublicInputsAndProof(req.body.publicInputs, proofUint8)).toString("base64"),"vk": JSON.parse(vk).vkHash || JSON.parse(vk).meta.vkHash,}}const requestResponse = await axios.post(`${API_URL}/submit-proof/${process.env.API_KEY}`, params)console.log(requestResponse.data)if(requestResponse.data.optimisticVerify != "success"){console.error("Proof verification, check proof artifacts");return;}while(true){try{const jobStatusResponse = await axios.get(`${API_URL}/job-status/${process.env.API_KEY}/${requestResponse.data.jobId}`);console.log("Job Status: ", jobStatusResponse.data);if(jobStatusResponse.data.status === "IncludedInBlock"){console.log("Job Included in Block successfully");res.status(200).json(jobStatusResponse.data);return;}else{console.log("Job status: ", jobStatusResponse.data.status);console.log("Waiting for job to finalize...");await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for 5 seconds before checking again}} catch (error: any) {if(error.response.status === 503){console.log("Service Unavailable, retrying...");await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for 5 seconds before retrying}}}}catch(error){console.log(error)}}function hexToUint8Array(hex: any) {if (hex.startsWith('0x')) hex = hex.slice(2);if (hex.length % 2 !== 0) hex = '0' + hex;const bytes = new Uint8Array(hex.length / 2);for (let i = 0; i < bytes.length; i++) {bytes[i] = parseInt(hex.substr(i * 2, 2), 16);}return bytes;}function concatenatePublicInputsAndProof(publicInputsHex: any, proofUint8: any) {const publicInputBytesArray = publicInputsHex.flatMap((hex: any) =>Array.from(hexToUint8Array(hex)));const publicInputBytes = new Uint8Array(publicInputBytesArray);console.log(publicInputBytes.length, proofUint8.length)const newProof = new Uint8Array(publicInputBytes.length + proofUint8.length);newProof.set(publicInputBytes, 0);newProof.set(proofUint8, publicInputBytes.length);return newProof;}async function registerVk(vk: any){const API_URL = "https://api-testnet.kurier.xyz/api/v1";const params = {proofType: "ultraplonk",vk: vk,proofOptions: {"numberOfPublicInputs": 1},};try{const res = await axios.post(`${API_URL}/register-vk/${process.env.API_KEY}`,params)console.log(res)fs.writeFileSync(path.join(process.cwd(), "public", "multiply", "vkey.json"),JSON.stringify(res.data));}catch(error: any) {console.log(error.response)fs.writeFileSync(path.join(process.cwd(), "public", "multiply", "vkey.json"),JSON.stringify(error.response.data));}}
在 src/pages 下创建 components 子目录用于存放新组件,并在其中新建 proof.tsx。

下面细看 proof 组件。先从 react 引入 useState,从 bbjs 引入 UltraPlonkBackend,从 noir_js 引入 Noir。声明多个 state 维护应用状态,再编写 handleProofGeneration(),使用 UltraPlonkBackend.generateProof() 结合产物与输入生成 groth16 证明,然后调用之前的 Kurier 后端 API 验证并更新状态。return 中渲染所需 UI。
"use client";import { useState } from "react";import { UltraPlonkBackend } from "@aztec/bb.js";import { abi, Noir } from "@noir-lang/noir_js";export default function ProofComponent() {const [x, setX] = useState("");const [y, setY] = useState("");const [result, setResult] = useState("");const [isLoading, setIsLoading] = useState(false);const [proofResult, setProofResult] = useState(null);const [errorMsg, setErrorMsg] = useState("");const [verificationStatus, setVerificationStatus] = useState("");const [txHash, setTxHash] = useState<string | null>(null);const handleGenerateProof = async () => {setIsLoading(true);setProofResult(null);setErrorMsg("");setVerificationStatus("");setTxHash(null);try {const circuit_json = await fetch("/multiply/target/multiply.json");const noir_data = await circuit_json.json();const input = {x: x,y: y,z: result,};const noir = new Noir({bytecode: noir_data.bytecode,abi: noir_data.abi as any,});const execResult = await noir.execute(input);console.log("Witness Generated:", execResult);const plonk = new UltraPlonkBackend(noir_data.bytecode, { threads: 2 });const { proof, publicInputs } = await plonk.generateProof(execResult.witness);const vk = await plonk.getVerificationKey();setProofResult({proof: "0x" + Buffer.from(proof).toString("hex"),publicInputs,});// Send to backend for verificationconst res = await fetch("/api/kurier", {method: "POST",headers: {"Content-Type": "application/json",},body: JSON.stringify({proof: proof,publicInputs: publicInputs,vk: Buffer.from(vk).toString("base64"),}),});const data = await res.json();if (res.ok) {setVerificationStatus("✅ Proof verified successfully!");if (data.txHash) {setTxHash(data.txHash);}} else {setVerificationStatus("❌ Proof verification failed.");}} catch (error) {console.error("Error generating proof or verifying:", error);setErrorMsg("❌ Error generating or verifying proof. Please check your inputs and try again.");} finally {setIsLoading(false);}};return (<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4"><h1 className="text-4xl font-bold mb-6">zkVerify Noir NextJS</h1>{/* Inputs */}<div className="flex flex-col space-y-4 w-64 mb-6"><inputtype="number"placeholder="Enter value x"value={x}onChange={(e) => setX(e.target.value)}className="px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"/><inputtype="number"placeholder="Enter value y"value={y}onChange={(e) => setY(e.target.value)}className="px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"/><inputtype="number"placeholder="Enter value x * y"value={result}onChange={(e) => setResult(e.target.value)}className="px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"/></div>{/* Generate Proof Button */}<buttononClick={handleGenerateProof}disabled={isLoading}className={`${isLoading? "bg-gray-400 cursor-not-allowed": "bg-green-600 hover:bg-green-700"} text-white font-semibold px-6 py-2 rounded-lg`}>{isLoading ? "Processing..." : "Generate Proof"}</button>{/* Loading */}{isLoading && (<div className="mt-6 text-blue-600 font-semibold">Working on it, please wait...</div>)}{/* Error Message */}{errorMsg && (<div className="mt-6 text-red-600 font-medium">{errorMsg}</div>)}{/* Verification Result */}{verificationStatus && (<div className="mt-4 text-lg font-medium text-blue-700">{verificationStatus}</div>)}{/* TX Hash */}{txHash && (<div className="mt-2 text-blue-800 underline"><ahref={`https://zkverify-testnet.subscan.io/extrinsic/${txHash}`}target="_blank"rel="noopener noreferrer">🔗 View on Subscan (txHash: {txHash.slice(0, 10)}...)</a></div>)}{/* Output */}{proofResult && (<div className="mt-8 bg-white shadow-md p-4 rounded-lg w-full max-w-xl"><h2 className="text-xl font-bold mb-2 text-green-700">✅ Proof Generated</h2><pre className="text-sm overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(proofResult, null, 2)}</pre></div>)}</div>);}
打开 index.tsx 引入 proof 组件,替换为:
import Image from "next/image";import { Geist, Geist_Mono } from "next/font/google";import ProofComponent from "./components/proof";const geistSans = Geist({variable: "--font-geist-sans",subsets: ["latin"],});const geistMono = Geist_Mono({variable: "--font-geist-mono",subsets: ["latin"],});export default function Home() {return (<><ProofComponent /></>);}
至此 NextJS 应用已就绪,运行:
npm run dev
在终端提供的地址访问,界面如下:

输入任意 x、y、result(满足 result = x*y),点击 Generate Proof,生成后显示在按钮下方:

生成后会自动通过 Kurier 提交验证并显示 “working on it”。验证通过后会返回 txHash,可点击在 Explorer 查看。
