diff --git a/.github/workflows/web-starter-playwright.yml b/.github/workflows/web-starter-playwright.yml index c8b76721..8d1476c8 100644 --- a/.github/workflows/web-starter-playwright.yml +++ b/.github/workflows/web-starter-playwright.yml @@ -69,6 +69,21 @@ jobs: run: | yarn test:e2e + - name: Install JS dependencies (next) + working-directory: web-starter/web/nextjs + run: | + yarn install + + - name: Install Playwright Browsers (next) + working-directory: web-starter/web/nextjs + run: | + yarn playwright install --with-deps + + - name: Run Playwright tests (next) + working-directory: web-starter/web/nextjs + run: | + yarn test:e2e + - name: Create issue on failure (nightly) if: failure() && github.event_name == 'schedule' uses: actions/github-script@v6 diff --git a/web-starter/web/nextjs/.gitignore b/web-starter/web/nextjs/.gitignore new file mode 100644 index 00000000..83089071 --- /dev/null +++ b/web-starter/web/nextjs/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +dist/ +.env* +yarn.lock +npm-debug.log* +.DS_Store \ No newline at end of file diff --git a/web-starter/web/nextjs/README.md b/web-starter/web/nextjs/README.md new file mode 100644 index 00000000..9ef1a63b --- /dev/null +++ b/web-starter/web/nextjs/README.md @@ -0,0 +1,19 @@ +# Introduction + +A simple Noir circuit with browser proving with bb.js +This is a Next.js version, similar to the vite and webpack examples. + +Tested with Noir 1.0.0-beta.6, bb 0.84.0, and Next.js 14. + +## Setup + +```bash +(cd ../../circuits && ./build.sh) +yarn +``` + +## Run + +```bash +yarn dev +``` \ No newline at end of file diff --git a/web-starter/web/nextjs/app/globals.css b/web-starter/web/nextjs/app/globals.css new file mode 100644 index 00000000..0d1f697a --- /dev/null +++ b/web-starter/web/nextjs/app/globals.css @@ -0,0 +1,45 @@ +body { + font-family: Arial, sans-serif; + background: #f8f8f8; + margin: 0; + padding: 0; +} + +.container, main { + max-width: 600px; + margin: 40px auto; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + padding: 32px; +} + +h1 { + font-size: 2rem; + margin-bottom: 1.5rem; +} + +button { + padding: 0.5rem 1.5rem; + font-size: 1rem; + border-radius: 4px; + border: none; + background: #0070f3; + color: #fff; + cursor: pointer; + margin-bottom: 1rem; +} + +button:disabled { + background: #aaa; + cursor: not-allowed; +} + +#result { + background: #f4f4f4; + border-radius: 4px; + padding: 1rem; + min-height: 2rem; + margin-top: 1rem; + font-family: monospace; +} \ No newline at end of file diff --git a/web-starter/web/nextjs/app/layout.tsx b/web-starter/web/nextjs/app/layout.tsx new file mode 100644 index 00000000..eb0cc2f2 --- /dev/null +++ b/web-starter/web/nextjs/app/layout.tsx @@ -0,0 +1,12 @@ +import './globals.css'; +import type { ReactNode } from 'react'; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/web-starter/web/nextjs/app/page.tsx b/web-starter/web/nextjs/app/page.tsx new file mode 100644 index 00000000..bbcc4382 --- /dev/null +++ b/web-starter/web/nextjs/app/page.tsx @@ -0,0 +1,10 @@ +import ProofComponent from './proof-component'; + +export default function HomePage() { + return ( +
+

Noir UH Starter (Next.js)

+ +
+ ); +} \ No newline at end of file diff --git a/web-starter/web/nextjs/app/proof-component.tsx b/web-starter/web/nextjs/app/proof-component.tsx new file mode 100644 index 00000000..6c970779 --- /dev/null +++ b/web-starter/web/nextjs/app/proof-component.tsx @@ -0,0 +1,54 @@ +"use client"; +import { useState } from "react"; +import { UltraHonkBackend } from "@aztec/bb.js"; +import circuit from "../../../circuits/target/noir_uh_starter.json"; +import { Noir } from "@noir-lang/noir_js"; + +export default function ProofComponent() { + const [result, setResult] = useState(""); + const [loading, setLoading] = useState(false); + + async function generateProof() { + setLoading(true); + setResult((prev) => prev + "Generating proof...\n\n"); + try { + const noir = new Noir(circuit as any); + const honk = new UltraHonkBackend((circuit as any).bytecode, { + threads: 8, // This will only work if SharedArrayBuffer is enabled (see next.config.mjs) + }); + const inputs = { x: 3, y: 3 }; + const { witness } = await noir.execute(inputs); + const { proof, publicInputs } = await honk.generateProof(witness); + setResult((prev) => prev + "Proof: " + proof + "\n\n"); + setResult((prev) => prev + "Public inputs: " + publicInputs + "\n\n"); + const verified = await honk.verifyProof({ proof, publicInputs }); + setResult((prev) => prev + "Verified: " + verified + "\n\n"); + + // Send proof to server for server-side verification + setResult((prev) => prev + "Verifying on server...\n\n"); + const response = await fetch("/api/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proof, publicInputs }), + }); + const serverResult = await response.json(); + setResult( + (prev) => prev + "Server verified: " + serverResult.verified + "\n\n" + ); + } catch (error) { + setResult((prev) => prev + "Error: " + error + "\n\n"); + } + setLoading(false); + } + + return ( +
+ +
+ {result} +
+
+ ); +} diff --git a/web-starter/web/nextjs/next-env.d.ts b/web-starter/web/nextjs/next-env.d.ts new file mode 100644 index 00000000..3cd7048e --- /dev/null +++ b/web-starter/web/nextjs/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web-starter/web/nextjs/next.config.mjs b/web-starter/web/nextjs/next.config.mjs new file mode 100644 index 00000000..447ed872 --- /dev/null +++ b/web-starter/web/nextjs/next.config.mjs @@ -0,0 +1,32 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config) => { + config.experiments = { + // This is required for @aztec/bb.js as it imports wasm files + asyncWebAssembly: true, + layers: true, + }; + return config; + }, + // These headers enable SharedArrayBuffer which is required for running + // @aztec/bb.js wasm in multiple threads. + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/web-starter/web/nextjs/package.json b/web-starter/web/nextjs/package.json new file mode 100644 index 00000000..f7513dbb --- /dev/null +++ b/web-starter/web/nextjs/package.json @@ -0,0 +1,25 @@ +{ + "name": "nextjs-noir", + "type": "module", + "dependencies": { + "@aztec/bb.js": "0.84.0", + "@noir-lang/noir_js": "1.0.0-beta.6", + "next": "^15.3.4", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.53.0", + "@types/node": "^22.10.1", + "@types/react": "^18.2.62", + "@types/react-dom": "^18.2.19", + "typescript": "^5.8.3" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test:e2e": "playwright test" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/web-starter/web/nextjs/pages/api/verify.ts b/web-starter/web/nextjs/pages/api/verify.ts new file mode 100644 index 00000000..96e3b1eb --- /dev/null +++ b/web-starter/web/nextjs/pages/api/verify.ts @@ -0,0 +1,83 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { UltraHonkBackend, BarretenbergVerifier } from '@aztec/bb.js'; +import { promises as fs } from 'fs'; +import path from 'path'; + +// Load circuit data at module level (outside the handler) +let circuit: any = null; +let honk: UltraHonkBackend | null = null; + +async function initializeCircuit() { + if (!circuit) { + const circuitPath = path.resolve(process.cwd(), '../../circuits/target/noir_uh_starter.json'); + const circuitData = await fs.readFile(circuitPath, 'utf-8'); + circuit = JSON.parse(circuitData); + honk = new UltraHonkBackend(circuit.bytecode, { + threads: 8, + + // By default, bb.js downloads CRS files to ~/.bb-crs. For serverless environments where + // this path isn't writable, configure an alternate path (e.g. /tmp) using crsPath option + // crsPath: `/tmp` + }); + } +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + try { + // Initialize circuit if not already done + await initializeCircuit(); + + const { proof, publicInputs } = req.body; + + if (!proof || !publicInputs) { + return res.status(400).json({ + error: 'Missing proof or publicInputs', + verified: false + }); + } + + if (!honk) { + throw new Error('Backend not initialized'); + } + + // Convert proof to Uint8Array if it's not already + let proofArray: Uint8Array; + if (proof instanceof Uint8Array) { + proofArray = proof; + } else if (Array.isArray(proof)) { + proofArray = new Uint8Array(proof); + } else if (typeof proof === 'object' && proof !== null) { + // Handle serialized Uint8Array (object with numeric keys) + const values = Object.values(proof) as number[]; + proofArray = new Uint8Array(values); + } else { + throw new Error('Invalid proof format'); + } + + const publicInputsArray = Array.isArray(publicInputs) ? publicInputs : [publicInputs]; + + const verified = await honk.verifyProof({ + proof: proofArray, + publicInputs: publicInputsArray + }); + + return res.status(200).json({ + verified, + message: verified ? 'Proof verified successfully' : 'Proof verification failed' + }); + } catch (error) { + console.error('Server-side verification error:', error); + return res.status(500).json({ + error: String(error), + verified: false + }); + } +} \ No newline at end of file diff --git a/web-starter/web/nextjs/playwright.config.cjs b/web-starter/web/nextjs/playwright.config.cjs new file mode 100644 index 00000000..1595ca0f --- /dev/null +++ b/web-starter/web/nextjs/playwright.config.cjs @@ -0,0 +1,22 @@ +// playwright.config.js +// @ts-check +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +const config = { + webServer: { + command: 'yarn dev', + port: 3000, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://localhost:3000', + headless: true, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], +}; + +module.exports = config; \ No newline at end of file diff --git a/web-starter/web/nextjs/tests/proof-verification.spec.ts b/web-starter/web/nextjs/tests/proof-verification.spec.ts new file mode 100644 index 00000000..ad23171e --- /dev/null +++ b/web-starter/web/nextjs/tests/proof-verification.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, Page } from '@playwright/test'; + +// Next.js version +test('proof verification works in the browser', async ({ page }: { page: Page }) => { + await page.goto('/'); + await page.click('#generateProofBtn'); + // Wait for the result to contain 'Verified:' with increased timeout + let resultText = ''; + try { + await expect(page.locator('#result')).toContainText('Verified:', { timeout: 30000 }); + resultText = await page.locator('#result').innerText(); + } catch (e) { + // Debug: print the current contents of #result if the check fails + resultText = await page.locator('#result').innerText(); + console.log('DEBUG: #result contents at failure:', resultText); + throw e; + } + // Check that the result contains 'Verified: true' (or similar) + expect(resultText).toMatch(/Verified:\s*(true|1)/i); +}); + +test('proof is correctly verified server side using Pages Router', async () => { + // Import dependencies for proof generation + const { UltraHonkBackend } = await import('@aztec/bb.js'); + const { Noir } = await import('@noir-lang/noir_js'); + + // Import circuit data + const circuit = await import('../../../circuits/target/noir_uh_starter.json', { with: { type: 'json' } }); + + // Prepare inputs matching the circuit + const noir = new Noir(circuit.default as any); + const honk = new UltraHonkBackend(circuit.default.bytecode, { threads: 8 }); + const inputs = { x: 3, y: 3 }; + const { witness } = await noir.execute(inputs); + const { proof, publicInputs } = await honk.generateProof(witness); + + // POST to the Pages Router API endpoint + const res = await fetch('http://localhost:3000/api/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ proof, publicInputs }) + }); + const data = await res.json(); + expect(data.verified).toBe(true); +}); \ No newline at end of file diff --git a/web-starter/web/nextjs/tsconfig.json b/web-starter/web/nextjs/tsconfig.json new file mode 100644 index 00000000..f29133dd --- /dev/null +++ b/web-starter/web/nextjs/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "allowJs": true, + "strict": true, + "skipLibCheck": true, + "jsx": "preserve", + "incremental": true, + "outDir": "./dist", + "baseUrl": ".", + "noEmit": true, + "isolatedModules": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.json", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +}