diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2961a16..cd9bd10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,26 @@ jobs: - name: Run ENS conformance vectors run: zig build vector-test + noir: + name: Noir verify / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: "0.16.0" + - name: Run unit tests with Barretenberg linked + # Fetches the pinned Barretenberg v5.2.0 static library for the runner + # (lazy dependency, sha256-pinned in build.zig.zon) and runs the noir + # unit + vector tests on top of the regular unit tests. + run: zig build test -Dnoir=true + fmt: name: Format check runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cced88..e25add1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,9 @@ zig build # Run tests zig build test +# Run tests with the opt-in Noir/Barretenberg verifier linked (see README) +zig build test -Dnoir=true + # Check formatting zig fmt --check src/ tests/ @@ -38,7 +41,7 @@ Layer 2: Encoding (-> primitives) rlp.zig, abi_encode.zig, abi_decode.zig, abi_types.zig Layer 3: Crypto (-> primitives) - keccak.zig, secp256k1.zig, signature.zig + keccak.zig, secp256k1.zig, signature.zig, kzg.zig, noir.zig Layer 4: Types (-> primitives, encoding, crypto) transaction.zig, receipt.zig, block.zig, log.zig, access_list.zig, blob.zig @@ -88,7 +91,12 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) for PR titles o - Keep functions focused and small - Add doc comments (`///`) to public functions - Prefer comptime over runtime where possible -- this is a core design principle -- No external dependencies -- everything builds on Zig's standard library +- No external dependencies in the default build -- it uses only Zig's standard + library plus the C sources vendored under `src/crypto/`. The one exception is + opt-in and default-off: `-Dnoir=true` fetches a pinned Barretenberg release + archive as a lazy package dependency (see + `src/crypto/barretenberg/VENDOR.md`). A new dependency that the default build + would resolve needs discussion first. ## Reporting Issues diff --git a/README.md b/README.md index b7c745b..85967a3 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ cd examples && zig build && ./zig-out/bin/01_derive_address |-------|---------|-------------| | **Primitives** | `primitives`, `uint256`, `hex` | Address, Hash, Bytes32, u256, hex encoding | | **Encoding** | `rlp`, `abi_encode`, `abi_decode`, `abi_types` | RLP and ABI encoding/decoding | -| **Crypto** | `secp256k1`, `signer`, `signature`, `keccak`, `eip155`, `kzg` | ECDSA signing (RFC 6979), Keccak-256, EIP-155, EIP-4844 KZG | +| **Crypto** | `secp256k1`, `signer`, `signature`, `keccak`, `eip155`, `kzg`, `noir` | ECDSA signing (RFC 6979), Keccak-256, EIP-155, EIP-4844 KZG, Noir UltraHonk verification (opt-in) | | **Types** | `transaction`, `receipt`, `block`, `blob`, `access_list` | Legacy, EIP-2930, EIP-1559, EIP-4844 transactions | | **Accounts** | `mnemonic`, `hd_wallet` | BIP-32/39/44 HD wallets and mnemonic generation | | **Transport** | `http_transport`, `ws_transport`, `sse_transport`, `json_rpc`, `provider`, `subscription`, `ws_client` | HTTP, WebSocket, and SSE transports; resilient WS client with auto-reconnect | @@ -306,6 +306,7 @@ cd examples && zig build && ./zig-out/bin/01_derive_address | secp256k1 ECDSA signing (RFC 6979, EIP-2 low-S) | Complete | | Transaction types (Legacy, EIP-2930, EIP-1559, EIP-4844) | Complete | | EIP-4844 KZG (blob commitments/proofs, vendored c-kzg-4844 + blst) | Complete | +| Noir UltraHonk proof verification (opt-in `-Dnoir`, Barretenberg v5.2.0) | Complete | | EIP-155 replay protection | Complete | | EIP-191 personal message signing | Complete | | EIP-712 typed structured data signing | Complete | @@ -365,9 +366,53 @@ cd examples && zig build && ./zig-out/bin/01_derive_address ```bash zig build test # Unit tests +zig build test -Dnoir=true # Unit tests + Noir/Barretenberg verification tests (fetches the static library) zig build integration-test # Integration tests (requires Anvil) ``` +## Noir proof verification (opt-in) + +`eth.noir` verifies Noir UltraHonk proofs offline through Barretenberg's C +ABI. It is off by default so the default build stays pure Zig + vendored C; +enable it with `-Dnoir=true` (or `.noir = true` in your `b.dependency` args). +That flag fetches the pinned Barretenberg **v5.2.0** static library for your +host as a lazy, hash-pinned package dependency and links it with libc++. No +C++ sources are vendored. + +- Supported targets: aarch64 and x86_64 macOS, x86_64 Linux, aarch64 Linux. + Windows is out of scope. +- Verified: proofs written by `bb prove` (bb 5.2.0 / nargo 1.0.0-beta.25) in + the poseidon2 (default, `noir-recursive`) and keccak (`evm`) flavors, with + or without ZK. `Settings.fromVerifierTarget` mirrors `bb --verifier_target` + for those. IPA-accumulating rollup proofs (`noir-rollup`) are not supported: + they need a 32768-point Grumpkin CRS that `init` does not install. Proving + and VK generation are not included. +- CRS: verification needs only the BN254 G1 generator and the trusted-setup + G2 element, both embedded (192 bytes); nothing is downloaded at runtime. + Barretenberg's CRS is first-writer-wins per process, so `noir.init` installs + a verification-only CRS once and it cannot be enlarged afterwards. + +```zig +const noir = eth.noir; +try noir.init(); +const ok = try noir.verify( + allocator, + vk_bytes, // bb `vk` file + try noir.fieldsFromBytes(public_inputs_bytes), // bb `public_inputs` file + try noir.fieldsFromBytes(proof_bytes), // bb `proof` file + .fromVerifierTarget(.evm), +); +``` + +Only `true` means verified. `false` means Barretenberg returned a verdict of +`verified=false` (wrong proof, wrong public-input value, wrong size, flavor +mismatch); `error.ProofRejected` means it rejected the inputs before or during +deserialization (non-canonical field, point off the curve, public-input count +that disagrees with the VK, VK that does not deserialize). Both mean "not +verified", so callers must treat the error as a rejection rather than an +internal fault; `verifyDiag` exposes Barretenberg's message. See +`src/crypto/barretenberg/VENDOR.md` for the pinned assets and hashes. + ## Benchmarks One command to run the full comparison (requires Zig, Rust, Python 3): diff --git a/build.zig b/build.zig index d27e359..76d2a77 100644 --- a/build.zig +++ b/build.zig @@ -4,6 +4,14 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // Opt-in Noir UltraHonk verification (src/noir.zig). Off by default: the + // Barretenberg static library is a lazy dependency that is only fetched and + // linked when this is set, and the noir tests are only collected then. + const noir = b.option(bool, "noir", "Link Barretenberg v5.2.0 and enable eth.noir (UltraHonk proof verification)") orelse false; + const build_options = b.addOptions(); + build_options.addOption(bool, "noir", noir); + const build_options_module = build_options.createModule(); + // Main library module const eth_module = b.addModule("eth", .{ .root_source_file = b.path("src/root.zig"), @@ -14,6 +22,7 @@ pub fn build(b: *std.Build) void { addXkcp(b, eth_module, target); addSecp256k1(b, eth_module); addKzg(b, eth_module); + addNoir(b, eth_module, target, noir, build_options_module); // Unit tests. Root the test artifact at src/root.zig so its test block // (which direct-imports every module file) actually collects and runs the @@ -31,6 +40,7 @@ pub fn build(b: *std.Build) void { addXkcp(b, unit_test_module, target); addSecp256k1(b, unit_test_module); addKzg(b, unit_test_module); + addNoir(b, unit_test_module, target, noir, build_options_module); const unit_tests = b.addTest(.{ .root_module = unit_test_module, }); @@ -39,6 +49,24 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_unit_tests.step); + // Noir/Barretenberg interop vectors (tests/vectors/noir/, embedded from + // tests/). A separate test binary, and part of `zig build test` only when + // -Dnoir=true links the library. + if (noir) { + const noir_vector_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/noir_vectors_test.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "eth", .module = eth_module }, + }, + }), + }); + const run_noir_vector_tests = b.addRunArtifact(noir_vector_tests); + test_step.dependOn(&run_noir_vector_tests.step); + } + // Install the unit-test binary so coverage tooling (kcov) can run it // out-of-band: `zig build install-test` writes it to zig-out/bin/test. const install_unit_tests = b.addInstallArtifact(unit_tests, .{}); @@ -112,6 +140,7 @@ pub fn build(b: *std.Build) void { addXkcp(b, bench_module, target); addSecp256k1(b, bench_module); addKzg(b, bench_module); + addNoir(b, bench_module, target, noir, build_options_module); const bench_exe = b.addExecutable(.{ .name = "bench", @@ -318,3 +347,67 @@ fn addKzg(b: *std.Build, module: *std.Build.Module) void { .flags = ckzg_flags, }); } + +/// Expose the `noir` build option to src/root.zig and, when it is set, link the +/// pinned Barretenberg v5.2.0 static library for the target. +/// +/// The library is a lazy package dependency (build.zig.zon), one archive per +/// supported host: nothing is fetched unless `-Dnoir=true`, and only the +/// archive for the target being built. It is C++ (libc++ ABI, exceptions kept +/// inside the library), so libc++ and libc are linked with it. See +/// src/crypto/barretenberg/VENDOR.md for hashes, license and update steps. +fn addNoir( + b: *std.Build, + module: *std.Build.Module, + target: std.Build.ResolvedTarget, + enabled: bool, + build_options_module: *std.Build.Module, +) void { + module.addImport("build_options", build_options_module); + if (!enabled) return; + + const os = target.result.os.tag; + const arch = target.result.cpu.arch; + const dep_name: []const u8 = if (os == .macos and arch == .aarch64) + "barretenberg_arm64_darwin" + else if (os == .macos and arch == .x86_64) + "barretenberg_amd64_darwin" + else if (os == .linux and arch == .x86_64) + "barretenberg_amd64_linux" + else if (os == .linux and arch == .aarch64) + "barretenberg_arm64_linux" + else { + // No release archive for this target. Report it as an ordinary build + // error instead of aborting the runner while the graph is still being + // built: the failure is attached to the module's object-file input, so + // it fires exactly when something that needs Barretenberg is built and + // never for `zig build --help` or a step that does not link it. + const fail = b.addFail(b.fmt( + "-Dnoir=true is not supported for {s}-{s}; Barretenberg v5.2.0 static " ++ + "libraries are wired up for aarch64-macos, x86_64-macos, x86_64-linux " ++ + "and aarch64-linux only", + .{ @tagName(arch), @tagName(os) }, + )); + const unreachable_object = b.addWriteFiles(); + unreachable_object.step.dependOn(&fail.step); + module.addObjectFile(unreachable_object.add("noir-unsupported-target.o", "")); + return; + }; + + // Returns null (after scheduling the fetch) the first time the archive is + // needed; the build runner re-executes once it is available. + const dep = b.lazyDependency(dep_name, .{}) orelse return; + module.addObjectFile(dep.path("libbb-external.a")); + module.link_libcpp = true; + module.link_libc = true; + + if (os.isDarwin()) { + // Strong C11 aligned_alloc so the archive's weak, hidden definition of + // that libc-named symbol is not bound to libSystem's strict version by + // Zig's Mach-O linker. See the comment in the file. + module.addCSourceFile(.{ + .file = b.path("src/crypto/barretenberg/aligned_alloc_macos.c"), + .flags = &.{"-O2"}, + }); + } +} diff --git a/build.zig.zon b/build.zig.zon index d4eed87..c839a4e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,32 @@ .version = "0.9.1", // x-release-please-version .fingerprint = 0xd0f21900fa26f179, .minimum_zig_version = "0.16.0", - .dependencies = .{}, + .dependencies = .{ + // Barretenberg v5.2.0 prebuilt static libraries (Apache-2.0), one per + // supported host. Lazy: fetched only when building with -Dnoir=true, + // and only the entry build.zig selects for the target. Hashes and the + // update procedure are recorded in src/crypto/barretenberg/VENDOR.md. + .barretenberg_arm64_darwin = .{ + .url = "https://github.com/AztecProtocol/barretenberg/releases/download/v5.2.0/barretenberg-static-arm64-darwin.tar.gz", + .hash = "N-V-__8AAKAlJwOvxF9WMTWZ6_g3tK6kxoN1XqeG0j1bIxfK", + .lazy = true, + }, + .barretenberg_amd64_darwin = .{ + .url = "https://github.com/AztecProtocol/barretenberg/releases/download/v5.2.0/barretenberg-static-amd64-darwin.tar.gz", + .hash = "N-V-__8AAEi5uATDH1uT4EsKM7BxD_qgy01S6bvSVntg5M_O", + .lazy = true, + }, + .barretenberg_amd64_linux = .{ + .url = "https://github.com/AztecProtocol/barretenberg/releases/download/v5.2.0/barretenberg-static-amd64-linux.tar.gz", + .hash = "N-V-__8AABCloQVNS5sP_eXpnSbYWfq2xQBd4DQuiwYOKmDL", + .lazy = true, + }, + .barretenberg_arm64_linux = .{ + .url = "https://github.com/AztecProtocol/barretenberg/releases/download/v5.2.0/barretenberg-static-arm64-linux.tar.gz", + .hash = "N-V-__8AAB5oGQRtvwJEs6U1kH632yGzq9QqF7aGtv8LDL-y", + .lazy = true, + }, + }, .paths = .{ "build.zig", "build.zig.zon", diff --git a/src/crypto/barretenberg/VENDOR.md b/src/crypto/barretenberg/VENDOR.md new file mode 100644 index 0000000..14f3cd9 --- /dev/null +++ b/src/crypto/barretenberg/VENDOR.md @@ -0,0 +1,126 @@ +# Barretenberg dependency (Noir UltraHonk verification) + +Unlike c-kzg and blst, Barretenberg is not vendored as source. It is a C++20 +code base whose static library is about 50 MB per target, so `src/noir.zig` +consumes the prebuilt release archives as lazy Zig package dependencies +declared in `build.zig.zon`. Nothing is downloaded, linked or tested unless +the build is invoked with `-Dnoir=true`, and then only the archive for the +target being built is fetched (sha256-pinned through Zig's package hash). + +## Barretenberg + +- Source: https://github.com/AztecProtocol/aztec-packages (directory + `barretenberg/`); releases are published from the mirror + https://github.com/AztecProtocol/barretenberg/releases +- Pinned release: **v5.2.0** (2026-08-17) +- License: **Apache-2.0** (`barretenberg/LICENSE` in aztec-packages). The + archives are consumed unmodified; this repository stays MIT. +- Paired Noir toolchain: nargo **1.0.0-beta.25** (the `noir-repo` submodule at + tag v5.2.0 is exactly that release). The test vectors under + `tests/vectors/noir/` were produced with this pairing. + +Each archive contains a single file, `libbb-external.a`, and no headers. Only +the C symbols `bbapi` and `bbfree` are used; the wire format they speak +(msgpack `NamedUnion` commands) is implemented in `src/noir/msgpack.zig` and +`src/noir.zig`. + +| build.zig.zon name | Release asset | Upstream SHA-256 (release digest) | Zig package hash | +|------------------------------|--------------------------------------------|--------------------------------------------------------------------|----------------------------------------------------| +| `barretenberg_arm64_darwin` | `barretenberg-static-arm64-darwin.tar.gz` | `71e4e6c904cede582374d2aaf61530090b2d7f949943648835585a931c25e054` | `N-V-__8AAKAlJwOvxF9WMTWZ6_g3tK6kxoN1XqeG0j1bIxfK` | +| `barretenberg_amd64_darwin` | `barretenberg-static-amd64-darwin.tar.gz` | `5923ae204ecee4aa53476447e7d4fd6fb6361f993dd20425ed4df9c533951f95` | `N-V-__8AAEi5uATDH1uT4EsKM7BxD_qgy01S6bvSVntg5M_O` | +| `barretenberg_amd64_linux` | `barretenberg-static-amd64-linux.tar.gz` | `3726c437ef017d6a0c0d9b5f0e854197e364bc33bf7e7ccd20aee8cac7c12e6b` | `N-V-__8AABCloQVNS5sP_eXpnSbYWfq2xQBd4DQuiwYOKmDL` | +| `barretenberg_arm64_linux` | `barretenberg-static-arm64-linux.tar.gz` | `12e4db24d1f6e6bbbaec360ff8eeab2d4665afd8f40b1302e4ddae6a4065d098` | `N-V-__8AAB5oGQRtvwJEs6U1kH632yGzq9QqF7aGtv8LDL-y` | + +The Zig package hash covers the unpacked contents (the `.a` file), so the +build fails on any content change even if the URL still resolves. The upstream +digest column is what GitHub reports for the release asset +(`gh api repos/AztecProtocol/barretenberg/releases/tags/v5.2.0 --jq +'.assets[] | "\(.name) \(.digest)"'`) and lets a reviewer cross-check the +package hash against the artifact Aztec published. + +Upstream also ships `arm64-ios`, `arm64-ios-sim`, Android and `amd64-windows` +archives; they are not wired up. Windows is out of scope (the library allocates +responses with `_aligned_malloc` there, so the `bbfree` contract differs). + +CI (`.github/workflows/ci.yml`, job "Noir verify") exercises `ubuntu-latest` +(x86_64-linux) and `macos-latest` (aarch64-macos). The `arm64-linux` and +`amd64-darwin` archives are therefore validated out-of-band: run the update +procedure's step 6 on those hosts (or under emulation) at every release bump +and record the result in the pull request. + +## Linking + +- The archive is linked as an object file input together with libc++ and libc + (`link_libcpp`, `link_libc` on the module). Exceptions are enabled inside the + library and stay inside it for every post-decode failure, which the library + reports as an `ErrorResponse`. +- macOS only: `aligned_alloc_macos.c` provides a strong C11 `aligned_alloc`. + The archive carries a weak hidden definition of that libc-named symbol and + Zig's Mach-O linker binds it to libSystem's strict implementation, which + returns NULL for the non-multiple-of-alignment sizes the library requests. + See the comment in that file. +- The archives were built by upstream with Zig's own bundled clang, not a + distribution toolchain: `strings libbb-external.a` on the amd64-linux archive + shows `clang version 20.1.2 (https://github.com/ziglang/zig-bootstrap + c6bc9398c72c7a63fe9420a9055dcfd1845bc266)`, and upstream's wrappers pin + glibc 2.35 for Linux. Zig's bundled libc++ links them cleanly. +- libc++ version delta: the archives were compiled against libc++ 20.1 headers + while Zig 0.16 links libc++ 21.1 (`_LIBCPP_VERSION 210100`). libc++ is + ABI-stable across these releases in its default configuration, and only the + C symbols `bbapi` and `bbfree` cross the boundary (C++ exceptions are caught + inside the library on every post-decode path), so no C++ type crosses it. + Re-check this delta on any release or toolchain bump. + +## CRS + +Verification needs only the BN254 G1 generator and the trusted-setup G2 +element `[x]_2`. Both are embedded as constants in `src/noir.zig`, copied from +`barretenberg/cpp/src/barretenberg/srs/factories/bn254_crs_data.hpp`, and the +G2 bytes are checked against the SHA-256 upstream pins in the same header. No +CRS file or download is involved. The BN254 CRS factory is first-writer-wins +per process (`init_bn254_mem_crs_factory` returns early if one is already +installed), so the one-point CRS installed by `noir.init` cannot be enlarged +later; proving support would have to change `init`. + +### Why IPA (rollup) proofs are not supported + +`ProofSystemSettings.ipa_accumulation` is part of the wire struct, but no +`VerifierTarget` sets it and `verify` cannot honor it. An IPA-accumulating +proof (`bb --verifier_target noir-rollup`) dispatches to `UltraFlavor, RollupIO`, +whose verifier calls `verify_ipa` and constructs a Grumpkin verifier +commitment key of `1 << CONST_ECCVM_LOG_N` = 32768 points. That reaches +`get_grumpkin_crs_factory()`, which throws unless a Grumpkin CRS was installed; +`noir.init` never sends `SrsInitGrumpkinSrs`, so such a request comes back as +`error.ProofRejected` with "You need to initialize the global CRS with a call +to init_grumpkin_crs_factory". + +Supporting it would mean shipping or fetching 32768 x 64 = 2 MB of Grumpkin +points, which cannot be an embedded constant like the 192-byte BN254 pair and +would give up the offline property for that mode. One thing makes it easier +than it looks: unlike the BN254 factory, `init_grumpkin_mem_crs_factory` has no +first-writer-wins guard, so a Grumpkin CRS can be installed later in the +process without disturbing the BN254 one already in place. + +## Update procedure + +1. Pick the new release tag on https://github.com/AztecProtocol/barretenberg/releases + and note the Noir version it pairs with (`barretenberg/bbup/bb-versions.json` + or the `noir-repo` submodule commit at that tag). +2. For each of the four targets run + `zig fetch --save=barretenberg_ https://github.com/AztecProtocol/barretenberg/releases/download//barretenberg-static-.tar.gz` + and then re-add `.lazy = true` to each entry in `build.zig.zon` (`zig fetch` + drops it). +3. Record the new upstream digests and package hashes in the table above and + bump the release tag in `src/noir.zig` (`BARRETENBERG_VERSION`). +4. Re-validate the wire schema against the new release: the `SrsInitSrs`, + `CircuitVerify`, `ProofSystemSettings` and response structs in + `bbapi/bbapi_srs.hpp`, `bbapi/bbapi_ultra_honk.hpp` and + `bbapi/bbapi_shared.hpp`, or `bb msgpack schema`. The library has no runtime + schema check and rejects requests with missing fields by terminating the + process, so every field must be present with the exact snake_case name. +5. Regenerate `tests/vectors/noir/` with the paired nargo/bb (see the README + there) and refresh the reference request bytes. +6. Run `zig build test -Dnoir=true` on every wired-up host (macOS arm64, + macOS x86_64, Linux x86_64, Linux aarch64), plus the default `make ci`, + before committing. CI covers only the first and third, so record the other + two in the pull request. diff --git a/src/crypto/barretenberg/aligned_alloc_macos.c b/src/crypto/barretenberg/aligned_alloc_macos.c new file mode 100644 index 0000000..13e88ee --- /dev/null +++ b/src/crypto/barretenberg/aligned_alloc_macos.c @@ -0,0 +1,56 @@ +/* + * Strong C11 `aligned_alloc` for macOS builds that link Barretenberg. + * + * Barretenberg's common/mem.hpp defines its own `aligned_alloc` (a + * posix_memalign wrapper) as an inline function in the global namespace on + * Apple targets. Because already declares the C11 function, that + * definition takes C linkage and is emitted into every archive member as a + * weak, hidden ("weak private external") symbol. Zig's self-hosted Mach-O + * linker resolves the archive's references to libSystem's exported + * `aligned_alloc` instead of to the archive's own definition; Apple's ld binds + * them to the archive copy. libSystem's implementation is strict C11 and + * returns NULL when `size` is not a multiple of `alignment`, and Barretenberg + * calls it with sizes such as aligned_alloc(64, 19) when packing msgpack + * responses, so the first `bbapi` call would dereference NULL. + * + * Defining a strong, exported `aligned_alloc` in the executable makes every + * reference bind here instead. The implementation rounds `size` up to a + * multiple of `alignment` (so it also satisfies strict C11 callers) and + * delegates to posix_memalign; the memory is released with free()/bbfree as + * usual. This file is compiled only for macOS targets and only when the build + * links Barretenberg (-Dnoir=true); see build.zig. + */ +#include +#include +#include +#include + +void *aligned_alloc(size_t alignment, size_t size) { + /* posix_memalign requires a power of two that is a multiple of sizeof(void *). */ + if (alignment == 0 || (alignment & (alignment - 1)) != 0) { + errno = EINVAL; + return NULL; + } + if (alignment < sizeof(void *)) { + alignment = sizeof(void *); + } + size_t remainder = size % alignment; + if (remainder != 0) { + size_t padding = alignment - remainder; + if (size > SIZE_MAX - padding) { + errno = ENOMEM; + return NULL; + } + size += padding; + } + if (size == 0) { + size = alignment; + } + void *ptr = NULL; + int rc = posix_memalign(&ptr, alignment, size); + if (rc != 0) { + errno = rc; + return NULL; + } + return ptr; +} diff --git a/src/noir.zig b/src/noir.zig new file mode 100644 index 0000000..15c5c99 --- /dev/null +++ b/src/noir.zig @@ -0,0 +1,672 @@ +//! Offline verification of Noir UltraHonk proofs through Barretenberg's C ABI. +//! +//! Opt-in: build with `-Dnoir=true`. That flag makes build.zig fetch the pinned +//! Barretenberg v5.2.0 `libbb-external.a` for the host target as a lazy +//! package dependency and link it together with libc++. Without the flag this +//! file is still part of the module, but every entry point that touches the +//! library fails to compile with a clear message when referenced, and nothing +//! here is linked or tested. Supported targets: arm64 and x86_64 macOS, +//! x86_64 Linux and arm64 Linux (the release assets that exist for those +//! hosts). +//! +//! What is verified: UltraHonk proofs written by `bb prove` (bb 5.2.0 paired +//! with nargo 1.0.0-beta.25) in the poseidon2 (`noir-recursive`, the CLI +//! default) and keccak (`evm`) oracle-hash flavors, with or without ZK. +//! IPA-accumulating rollup proofs (`bb --verifier_target noir-rollup`) are +//! NOT supported: they additionally need a 32768-point Grumpkin CRS that +//! `init` does not install (see `Settings.ipa_accumulation`). Proving, +//! verification-key computation and Solidity verifier generation are also not +//! part of this module. +//! +//! Usage: +//! ```zig +//! try noir.init(); +//! const proof = try noir.fieldsFromBytes(proof_file_bytes); // bb `proof` +//! const inputs = try noir.fieldsFromBytes(public_inputs_file_bytes); // bb `public_inputs` +//! const ok = try noir.verify(allocator, vk_file_bytes, inputs, proof, .fromVerifierTarget(.evm)); +//! ``` +//! +//! Wire protocol: `bbapi` takes one msgpack-encoded command and returns one +//! msgpack-encoded response (see noir/msgpack.zig). Requests are +//! `[[name, {field: value}]]`; responses `[name, {field: value}]`. The library +//! requires every struct field to be present and decodes requests outside its +//! exception guard, so the request encoders below emit exactly the canonical +//! bytes the library was confirmed to accept. The response decoder rejects +//! anything whose shape does not match the pinned v5.2.0 schema -- wrong +//! envelope, unknown response name, wrong field name or count, trailing bytes +//! -- as `error.UnexpectedResponse`, while accepting any msgpack encoding of +//! that shape (see noir/msgpack.zig on canonical form). +//! +//! CRS: verification needs only two trusted-setup elements, the BN254 G1 +//! generator and the G2 element `[x]_2`, both embedded below as constants +//! (192 bytes, taken from barretenberg's `srs/factories/bn254_crs_data.hpp`). +//! No CRS file is read and no network is used. Barretenberg's global CRS +//! factory is first-writer-wins for the lifetime of the process: the one-point +//! G1 CRS that `init` installs can never be replaced or enlarged afterwards, +//! so a future proving milestone must size the CRS in `init` rather than add a +//! second initializer. +//! +//! Concurrency: the library keeps unsynchronized per-process request state, +//! so every `bbapi` call is serialized behind one mutex. `init` is guarded by +//! an atomic once-flag and may be called from any number of threads. + +const std = @import("std"); +const build_options = @import("build_options"); +const msgpack = @import("noir/msgpack.zig"); +const runtime = @import("runtime.zig"); + +/// True when the build links Barretenberg (`-Dnoir=true`). +pub const enabled: bool = build_options.noir; + +/// Barretenberg release this module is pinned to. The msgpack schema used +/// below is validated against exactly this release; see +/// src/crypto/barretenberg/VENDOR.md for the update procedure. +pub const BARRETENBERG_VERSION = "v5.2.0"; + +// ============================================================================ +// Embedded CRS constants +// ============================================================================ + +/// BN254 G1 generator (x = 1, y = 2) in barretenberg's affine serialization: +/// `x || y`, each a 32-byte big-endian field element. This is the first G1 +/// element of the Aztec CRS and the only G1 point verification needs. +pub const BN254_G1_GENERATOR: [64]u8 = ([_]u8{0} ** 31) ++ [_]u8{1} ++ ([_]u8{0} ** 31) ++ [_]u8{2}; + +/// The BN254 trusted-setup G2 element `[x]_2` (128 bytes), verbatim from +/// barretenberg `srs/factories/bn254_crs_data.hpp` (`BN254_G2_ELEMENT_BYTES`). +pub const BN254_G2_ELEMENT: [128]u8 = .{ + 0x01, 0x18, 0xc4, 0xd5, 0xb8, 0x37, 0xbc, 0xc2, 0xbc, 0x89, 0xb5, 0xb3, 0x98, 0xb5, 0x97, 0x4e, + 0x9f, 0x59, 0x44, 0x07, 0x3b, 0x32, 0x07, 0x8b, 0x7e, 0x23, 0x1f, 0xec, 0x93, 0x88, 0x83, 0xb0, + 0x26, 0x0e, 0x01, 0xb2, 0x51, 0xf6, 0xf1, 0xc7, 0xe7, 0xff, 0x4e, 0x58, 0x07, 0x91, 0xde, 0xe8, + 0xea, 0x51, 0xd8, 0x7a, 0x35, 0x8e, 0x03, 0x8b, 0x4e, 0xfe, 0x30, 0xfa, 0xc0, 0x93, 0x83, 0xc1, + 0x22, 0xfe, 0xbd, 0xa3, 0xc0, 0xc0, 0x63, 0x2a, 0x56, 0x47, 0x5b, 0x42, 0x14, 0xe5, 0x61, 0x5e, + 0x11, 0xe6, 0xdd, 0x3f, 0x96, 0xe6, 0xce, 0xa2, 0x85, 0x4a, 0x87, 0xd4, 0xda, 0xcc, 0x5e, 0x55, + 0x04, 0xfc, 0x63, 0x69, 0xf7, 0x11, 0x0f, 0xe3, 0xd2, 0x51, 0x56, 0xc1, 0xbb, 0x9a, 0x72, 0x85, + 0x9c, 0xf2, 0xa0, 0x46, 0x41, 0xf9, 0x9b, 0xa4, 0xee, 0x41, 0x3c, 0x80, 0xda, 0x6a, 0x5f, 0xe4, +}; + +/// SHA-256 of `BN254_G2_ELEMENT`, pinned upstream as `BN254_G2_ELEMENT_SHA256` +/// in the same header. Checked by a unit test so a transcription error in the +/// constant above cannot go unnoticed. +pub const BN254_G2_ELEMENT_SHA256: [32]u8 = .{ + 0x01, 0x79, 0x7b, 0xfc, 0x4d, 0xe5, 0xa9, 0x6f, 0x0e, 0x51, 0x6a, 0x9e, 0xa4, 0x53, 0x7d, 0x18, + 0x78, 0x6d, 0xc3, 0x0c, 0xb9, 0x91, 0xac, 0xa4, 0x27, 0x4c, 0x95, 0x82, 0x2b, 0x69, 0xc3, 0x2f, +}; + +// ============================================================================ +// C ABI (libbb-external.a) +// ============================================================================ + +/// The single native entrypoint. `output_out`/`output_len_out` are in-out: on +/// entry they describe an optional caller-provided scratch buffer (we always +/// pass null/0), on return they describe the response, which the library +/// allocated and which must be released with `bbfree`. +extern fn bbapi(input_in: [*]const u8, input_len_in: usize, output_out: *?[*]u8, output_len_out: *usize) void; +extern fn bbfree(ptr: ?*anyopaque) void; + +// ============================================================================ +// Public types +// ============================================================================ + +pub const NoirError = error{ + /// `init` has not completed successfully in this process. + NotInitialized, + /// Barretenberg rejected the inputs before or during deserialization and + /// answered `CircuitVerify` with an `ErrorResponse`: a non-canonical field + /// element, a proof element that is not a curve point, a public-input + /// count that disagrees with the verification key, or a verification key + /// that does not deserialize. The message is available through + /// `verifyDiag`. Like `false`, this means "not verified"; it says the + /// library never got as far as a verdict, not that the proof was + /// well-formed. + ProofRejected, + /// Barretenberg answered `SrsInitSrs` with an `ErrorResponse`. + SrsInitFailed, + /// The response bytes do not match the pinned v5.2.0 schema (wrong + /// envelope, unknown response name, missing or extra fields, trailing + /// bytes). Indicates a library/binding version mismatch. + UnexpectedResponse, + /// `bbapi` returned no output buffer at all. + NoResponse, + OutOfMemory, + /// A request component exceeds the msgpack 32-bit length limit. + TooLong, +}; + +/// Oracle hash used by the prover's Fiat-Shamir transcript. Must match the +/// flavor the proof was generated with. Barretenberg also knows `starknet`, +/// but it is compiled out of the release static libraries. +pub const OracleHash = enum { + poseidon2, + keccak, +}; + +/// Mirror of Barretenberg's `ProofSystemSettings`; the field names and order +/// are the wire schema. Defaults match `bb` with no `--verifier_target`. +pub const Settings = struct { + /// Proof carries an IPA accumulator (rollup circuits). Part of the wire + /// struct, but `verify` cannot honor it: an IPA proof is checked against a + /// 32768-point Grumpkin CRS that `init` does not install, so Barretenberg + /// answers such a request with `error.ProofRejected` ("You need to + /// initialize the global CRS with a call to init_grumpkin_crs_factory"). + /// No `VerifierTarget` sets it; see src/crypto/barretenberg/VENDOR.md for + /// what supporting it would cost. + ipa_accumulation: bool = false, + oracle_hash_type: OracleHash = .poseidon2, + /// The proof was generated without blinding (`*-no-zk` targets). + disable_zk: bool = false, + /// Barretenberg's experimental Solidity-verifier layout; not used by any + /// `--verifier_target`, kept so the wire struct is complete. + optimized_solidity_verifier: bool = false, + + /// The `bb --verifier_target` values this module can verify. `default` is + /// what `bb` uses when the flag is absent (poseidon2, ZK, no IPA), which + /// is identical to `noir_recursive`. + /// + /// bb also accepts `noir-rollup`/`noir-rollup-no-zk` (IPA accumulation) + /// and `starknet`/`starknet-no-zk`. They are deliberately absent: the + /// rollup targets need a Grumpkin CRS `init` does not install (see + /// `ipa_accumulation`), and the starknet flavors are compiled out of the + /// release static libraries. + pub const VerifierTarget = enum { + default, + evm, + evm_no_zk, + noir_recursive, + noir_recursive_no_zk, + }; + + /// Same mapping as `bb`'s CLI applies to `--verifier_target`. + pub fn fromVerifierTarget(target: VerifierTarget) Settings { + return switch (target) { + .default, .noir_recursive => .{}, + .noir_recursive_no_zk => .{ .disable_zk = true }, + .evm => .{ .oracle_hash_type = .keccak }, + .evm_no_zk => .{ .oracle_hash_type = .keccak, .disable_zk = true }, + }; + } +}; + +/// Receives Barretenberg's error message when `verifyDiag` fails with +/// `error.ProofRejected`. Fixed-size so no allocator is involved. +pub const Diagnostics = struct { + pub const max_message_len = 256; + + buf: [max_message_len]u8 = undefined, + len: usize = 0, + + /// The last `ErrorResponse` message (possibly truncated), or empty. + pub fn message(self: *const Diagnostics) []const u8 { + return self.buf[0..self.len]; + } + + fn set(self: *Diagnostics, msg: []const u8) void { + const n = @min(msg.len, max_message_len); + @memcpy(self.buf[0..n], msg[0..n]); + self.len = n; + } +}; + +// ============================================================================ +// Lifecycle (process-global, init-once) +// ============================================================================ + +const State = enum(u8) { uninit, initializing, ready }; + +var init_state: std.atomic.Value(u8) = .init(@intFromEnum(State.uninit)); + +/// Serializes every `bbapi` call (the library's request state is a process +/// global with no lock of its own). The mutex only needs an `Io` for futex +/// waits on contention, so the library's blocking `Io` is used here; nothing +/// else about the call touches I/O. +var api_mutex: std.Io.Mutex = .init; + +fn loadState() State { + return @enumFromInt(init_state.load(.acquire)); +} + +/// Install the verification CRS (G1 generator + pinned G2) into Barretenberg. +/// Idempotent and thread-safe: the first caller performs the `SrsInitSrs` +/// call, concurrent callers wait for it, later callers return immediately. If +/// the winner fails, the state is rolled back so a later call can retry. +/// +/// Must complete before `verify`. The installed CRS holds a single G1 point, +/// which is sufficient for verification only (see the module docs on the +/// first-writer-wins constraint). +pub fn init() NoirError!void { + if (comptime !enabled) @compileError("eth.noir requires building with -Dnoir=true (links Barretenberg " ++ BARRETENBERG_VERSION ++ ")"); + + while (true) { + switch (loadState()) { + .ready => return, + .initializing => std.atomic.spinLoopHint(), + .uninit => { + if (init_state.cmpxchgStrong( + @intFromEnum(State.uninit), + @intFromEnum(State.initializing), + .acquire, + .acquire, + ) == null) break; + }, + } + } + + // We own initialization. Roll back on failure so another caller can retry. + errdefer init_state.store(@intFromEnum(State.uninit), .release); + + // The request is 242 bytes; encode it on the stack. + var stack: [1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&stack); + const request = try encodeSrsInitSrsRequest(fba.allocator(), &BN254_G1_GENERATOR, 1, &BN254_G2_ELEMENT); + + const response = try call(request); + defer response.deinit(); + try parseSrsInitResponse(response.bytes(), null); + + init_state.store(@intFromEnum(State.ready), .release); +} + +/// Whether `init` has completed successfully in this process. +pub fn isInitialized() bool { + return loadState() == .ready; +} + +// ============================================================================ +// Verification +// ============================================================================ + +/// Verify an UltraHonk proof against its verification key. +/// +/// - `vk`: the raw bytes of bb's `vk` output. +/// - `public_inputs` / `proof`: 32-byte big-endian field elements, exactly as +/// bb writes them; use `fieldsFromBytes` to view the raw files this way. +/// - `settings`: must match the flavor used at proving time +/// (`Settings.fromVerifierTarget`). +/// +/// Only `true` means verified. Both failure shapes mean "not verified", and +/// which one you get depends on how far Barretenberg got: +/// +/// - `false`: Barretenberg answered `verified=false` (a proof that does not +/// satisfy the circuit, a wrong public-input *value*, a wrong proof or VK +/// size, a flavor mismatch). +/// - `error.ProofRejected`: Barretenberg rejected the inputs before or during +/// deserialization (a non-canonical field element, a point that is not on +/// the curve, a public-input *count* that disagrees with the VK, a VK that +/// does not deserialize). `verifyDiag` exposes the library's message. +/// +/// Callers deciding whether to accept a proof must treat the error as a +/// rejection, not as an internal fault: which of the two an invalid input +/// produces is not a stable property of the input. +/// +/// `allocator` is used for one transient request buffer that is freed before +/// returning. +pub fn verify( + allocator: std.mem.Allocator, + vk: []const u8, + public_inputs: []const [32]u8, + proof: []const [32]u8, + settings: Settings, +) NoirError!bool { + return verifyDiag(allocator, vk, public_inputs, proof, settings, null); +} + +/// `verify` with an optional sink for Barretenberg's error message. +pub fn verifyDiag( + allocator: std.mem.Allocator, + vk: []const u8, + public_inputs: []const [32]u8, + proof: []const [32]u8, + settings: Settings, + diag: ?*Diagnostics, +) NoirError!bool { + if (comptime !enabled) @compileError("eth.noir requires building with -Dnoir=true (links Barretenberg " ++ BARRETENBERG_VERSION ++ ")"); + + if (diag) |d| d.len = 0; + // Guard in Zig: calling the library before the CRS is installed makes it + // throw internally, and we do not want to rely on how it reports that. + if (loadState() != .ready) return error.NotInitialized; + + const request = try encodeCircuitVerifyRequest(allocator, vk, public_inputs, proof, settings); + defer allocator.free(request); + + const response = try call(request); + defer response.deinit(); + return parseVerifyResponse(response.bytes(), diag); +} + +/// View a concatenation of 32-byte field elements (bb's `proof` and +/// `public_inputs` files) as a slice of fields, without copying. +pub fn fieldsFromBytes(bytes: []const u8) error{InvalidLength}![]const [32]u8 { + if (bytes.len % 32 != 0) return error.InvalidLength; + if (bytes.len == 0) return &[0][32]u8{}; + const fields: [*]const [32]u8 = @ptrCast(bytes.ptr); + return fields[0 .. bytes.len / 32]; +} + +// ============================================================================ +// Wire encoding (exposed for byte-for-byte tests against bb reference requests) +// ============================================================================ + +/// Encode `SrsInitSrs{points_buf, num_points, g2_point}` wrapped in the +/// single-argument tuple `bbapi` expects. Caller owns the result. +pub fn encodeSrsInitSrsRequest( + allocator: std.mem.Allocator, + points_buf: []const u8, + num_points: u32, + g2_point: *const [128]u8, +) msgpack.EncodeError![]u8 { + var enc = msgpack.Encoder.init(allocator); + errdefer enc.deinit(); + try enc.writeArrayHeader(1); // std::tuple + try enc.writeArrayHeader(2); // NamedUnion: [name, fields] + try enc.writeStr("SrsInitSrs"); + try enc.writeMapHeader(3); + try enc.writeStr("points_buf"); + try enc.writeBin(points_buf); + try enc.writeStr("num_points"); + try enc.writeUint(num_points); + try enc.writeStr("g2_point"); + try enc.writeBin(g2_point); + return enc.toOwnedSlice(); +} + +/// Encode `CircuitVerify{verification_key, public_inputs, proof, settings}` +/// wrapped in the single-argument tuple `bbapi` expects. Caller owns the result. +pub fn encodeCircuitVerifyRequest( + allocator: std.mem.Allocator, + vk: []const u8, + public_inputs: []const [32]u8, + proof: []const [32]u8, + settings: Settings, +) msgpack.EncodeError![]u8 { + var enc = msgpack.Encoder.init(allocator); + errdefer enc.deinit(); + try enc.writeArrayHeader(1); + try enc.writeArrayHeader(2); + try enc.writeStr("CircuitVerify"); + try enc.writeMapHeader(4); + try enc.writeStr("verification_key"); + try enc.writeBin(vk); + try enc.writeStr("public_inputs"); + try writeFields(&enc, public_inputs); + try enc.writeStr("proof"); + try writeFields(&enc, proof); + try enc.writeStr("settings"); + try enc.writeMapHeader(4); + try enc.writeStr("ipa_accumulation"); + try enc.writeBool(settings.ipa_accumulation); + try enc.writeStr("oracle_hash_type"); + try enc.writeStr(@tagName(settings.oracle_hash_type)); + try enc.writeStr("disable_zk"); + try enc.writeBool(settings.disable_zk); + try enc.writeStr("optimized_solidity_verifier"); + try enc.writeBool(settings.optimized_solidity_verifier); + return enc.toOwnedSlice(); +} + +/// `std::vector` encodes as an array of 32-byte bin values. +fn writeFields(enc: *msgpack.Encoder, fields: []const [32]u8) msgpack.EncodeError!void { + try enc.writeArrayHeader(fields.len); + for (fields) |*field| try enc.writeBin(field); +} + +// ============================================================================ +// Calling the library and decoding responses +// ============================================================================ + +/// A response buffer owned by Barretenberg. +const Response = struct { + ptr: [*]u8, + len: usize, + + fn bytes(self: Response) []const u8 { + return self.ptr[0..self.len]; + } + + fn deinit(self: Response) void { + bbfree(@ptrCast(self.ptr)); + } +}; + +fn call(request: []const u8) NoirError!Response { + const io = runtime.blockingIo(); + api_mutex.lockUncancelable(io); + defer api_mutex.unlock(io); + + var out: ?[*]u8 = null; + var out_len: usize = 0; + bbapi(request.ptr, request.len, &out, &out_len); + const ptr = out orelse return error.NoResponse; + return .{ .ptr = ptr, .len = out_len }; +} + +const Envelope = struct { + name: []const u8, + field_count: usize, +}; + +/// Read the `[name, {...}]` response envelope up to the map header. +fn readEnvelope(dec: *msgpack.Decoder) NoirError!Envelope { + const outer = dec.readArrayHeader() catch return error.UnexpectedResponse; + if (outer != 2) return error.UnexpectedResponse; + const name = dec.readStr() catch return error.UnexpectedResponse; + const field_count = dec.readMapHeader() catch return error.UnexpectedResponse; + return .{ .name = name, .field_count = field_count }; +} + +/// Read the single `message` field of an `ErrorResponse`, record it, and +/// return `err`. +fn readErrorResponse(dec: *msgpack.Decoder, envelope: Envelope, diag: ?*Diagnostics, err: NoirError) NoirError { + if (envelope.field_count != 1) return error.UnexpectedResponse; + const key = dec.readStr() catch return error.UnexpectedResponse; + if (!std.mem.eql(u8, key, "message")) return error.UnexpectedResponse; + const message = dec.readStr() catch return error.UnexpectedResponse; + if (!dec.finished()) return error.UnexpectedResponse; + if (diag) |d| d.set(message); + return err; +} + +/// Decode `["CircuitVerifyResponse", {"verified": bool}]` or an `ErrorResponse`. +fn parseVerifyResponse(bytes: []const u8, diag: ?*Diagnostics) NoirError!bool { + var dec = msgpack.Decoder.init(bytes); + const envelope = try readEnvelope(&dec); + if (std.mem.eql(u8, envelope.name, "ErrorResponse")) { + return readErrorResponse(&dec, envelope, diag, error.ProofRejected); + } + if (!std.mem.eql(u8, envelope.name, "CircuitVerifyResponse")) return error.UnexpectedResponse; + if (envelope.field_count != 1) return error.UnexpectedResponse; + const key = dec.readStr() catch return error.UnexpectedResponse; + if (!std.mem.eql(u8, key, "verified")) return error.UnexpectedResponse; + const verified = dec.readBool() catch return error.UnexpectedResponse; + if (!dec.finished()) return error.UnexpectedResponse; + return verified; +} + +/// Decode `["SrsInitSrsResponse", {"points_buf": bin}]` or an `ErrorResponse`. +fn parseSrsInitResponse(bytes: []const u8, diag: ?*Diagnostics) NoirError!void { + var dec = msgpack.Decoder.init(bytes); + const envelope = try readEnvelope(&dec); + if (std.mem.eql(u8, envelope.name, "ErrorResponse")) { + return readErrorResponse(&dec, envelope, diag, error.SrsInitFailed); + } + if (!std.mem.eql(u8, envelope.name, "SrsInitSrsResponse")) return error.UnexpectedResponse; + if (envelope.field_count != 1) return error.UnexpectedResponse; + const key = dec.readStr() catch return error.UnexpectedResponse; + if (!std.mem.eql(u8, key, "points_buf")) return error.UnexpectedResponse; + _ = dec.readBin() catch return error.UnexpectedResponse; + if (!dec.finished()) return error.UnexpectedResponse; +} + +// ============================================================================ +// Tests +// ============================================================================ +// +// The proof vectors and the byte-for-byte request comparisons live in +// tests/noir_vectors_test.zig (a separate test binary that embeds +// tests/vectors/noir/). The tests here cover the lifecycle guard, the +// once-flag under contention, the settings mapping, the embedded constants +// and the response decoder. They only run when the build links Barretenberg +// (root.zig gates the import on the `noir` build option). + +const testing = std.testing; + +// Must stay the first test in this file: it relies on no earlier test in this +// process having initialized the library, and the CRS cannot be uninstalled. +test "noir: verify before init returns NotInitialized without touching the library" { + try testing.expect(!isInitialized()); + try testing.expectError(error.NotInitialized, verify(testing.allocator, "", &.{}, &.{}, .{})); +} + +test "noir: init is idempotent under concurrent callers" { + const thread_count = 8; + const Worker = struct { + fn run(ok: *bool) void { + init() catch return; + ok.* = true; + } + }; + var ok: [thread_count]bool = @splat(false); + var threads: [thread_count]std.Thread = undefined; + var spawned: usize = 0; + for (0..thread_count) |i| { + threads[i] = std.Thread.spawn(.{}, Worker.run, .{&ok[i]}) catch break; + spawned += 1; + } + for (threads[0..spawned]) |t| t.join(); + for (ok[0..spawned]) |flag| try testing.expect(flag); + try testing.expect(isInitialized()); + // A later call is a no-op that still succeeds. + try init(); + try testing.expect(isInitialized()); +} + +test "noir: empty inputs after init are answered, not crashed on" { + try init(); + var diag: Diagnostics = .{}; + const result = verifyDiag(testing.allocator, "", &.{}, &.{}, .{}, &diag) catch |err| switch (err) { + error.ProofRejected => false, + else => return err, + }; + try testing.expect(!result); +} + +test "noir: Settings.fromVerifierTarget mirrors bb --verifier_target" { + const S = Settings; + try testing.expectEqual(S{}, S.fromVerifierTarget(.default)); + try testing.expectEqual(S{}, S.fromVerifierTarget(.noir_recursive)); + try testing.expectEqual(S{ .disable_zk = true }, S.fromVerifierTarget(.noir_recursive_no_zk)); + try testing.expectEqual(S{ .oracle_hash_type = .keccak }, S.fromVerifierTarget(.evm)); + try testing.expectEqual(S{ .oracle_hash_type = .keccak, .disable_zk = true }, S.fromVerifierTarget(.evm_no_zk)); + // Every target leaves the experimental Solidity layout off. + try testing.expect(!S.fromVerifierTarget(.evm).optimized_solidity_verifier); +} + +test "noir: no VerifierTarget requests IPA accumulation" { + // `init` installs a BN254 verification CRS only. An IPA proof is checked + // against a 32768-point Grumpkin CRS, which Barretenberg has no way to + // obtain here, so offering a rollup target would promise a mode that + // always fails. This pins that the gap cannot silently reopen: adding a + // rollup variant to `VerifierTarget` must fail this test (and come with a + // Grumpkin CRS in `init`). + inline for (@typeInfo(Settings.VerifierTarget).@"enum".fields) |field| { + const settings = Settings.fromVerifierTarget(@field(Settings.VerifierTarget, field.name)); + try testing.expect(!settings.ipa_accumulation); + try testing.expect(std.mem.indexOf(u8, field.name, "rollup") == null); + } + // The wire field itself stays, because it is part of the pinned schema. + try testing.expect(@hasField(Settings, "ipa_accumulation")); +} + +test "noir: embedded G2 element matches the upstream SHA-256 pin" { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(&BN254_G2_ELEMENT, &digest, .{}); + try testing.expectEqualSlices(u8, &BN254_G2_ELEMENT_SHA256, &digest); +} + +test "noir: embedded G1 generator is (1, 2)" { + try testing.expectEqual(@as(u8, 1), BN254_G1_GENERATOR[31]); + try testing.expectEqual(@as(u8, 2), BN254_G1_GENERATOR[63]); + var nonzero: usize = 0; + for (BN254_G1_GENERATOR) |b| nonzero += @intFromBool(b != 0); + try testing.expectEqual(@as(usize, 2), nonzero); +} + +test "noir: SrsInitSrs request has the documented shape and leaks nothing" { + const request = try encodeSrsInitSrsRequest(testing.allocator, &BN254_G1_GENERATOR, 1, &BN254_G2_ELEMENT); + defer testing.allocator.free(request); + try testing.expectEqual(@as(usize, 242), request.len); + // [[ "SrsInitSrs", { "points_buf": bin8(64) ... + const prefix = "\x91\x92\xaaSrsInitSrs\x83\xaapoints_buf\xc4\x40"; + try testing.expectEqualSlices(u8, prefix, request[0..prefix.len]); + // ... "num_points": 1, "g2_point": bin8(128) <128 bytes> ]] + const tail_key = "\xaanum_points\x01\xa8g2_point\xc4\x80"; + const tail_start = prefix.len + 64; + try testing.expectEqualSlices(u8, tail_key, request[tail_start .. tail_start + tail_key.len]); + try testing.expectEqualSlices(u8, &BN254_G2_ELEMENT, request[request.len - 128 ..]); +} + +test "noir: CircuitVerify request encodes settings by name and fields as bin32" { + const vk = [_]u8{0xab} ** 3; + const fields = [_][32]u8{ [_]u8{0x11} ** 32, [_]u8{0x22} ** 32 }; + const request = try encodeCircuitVerifyRequest(testing.allocator, &vk, fields[0..1], &fields, .fromVerifierTarget(.evm_no_zk)); + defer testing.allocator.free(request); + const expected = "\x91\x92\xadCircuitVerify\x84" ++ + "\xb0verification_key\xc4\x03\xab\xab\xab" ++ + "\xadpublic_inputs\x91\xc4\x20" ++ ("\x11" ** 32) ++ + "\xa5proof\x92\xc4\x20" ++ ("\x11" ** 32) ++ "\xc4\x20" ++ ("\x22" ** 32) ++ + "\xa8settings\x84" ++ + "\xb0ipa_accumulation\xc2" ++ + "\xb0oracle_hash_type\xa6keccak" ++ + "\xaadisable_zk\xc3" ++ + "\xbboptimized_solidity_verifier\xc2"; + try testing.expectEqualSlices(u8, expected, request); +} + +test "noir: fieldsFromBytes splits on 32-byte boundaries" { + const raw = [_]u8{7} ** 96; + const fields = try fieldsFromBytes(&raw); + try testing.expectEqual(@as(usize, 3), fields.len); + try testing.expectEqualSlices(u8, raw[32..64], &fields[1]); + try testing.expectEqual(@as(usize, 0), (try fieldsFromBytes("")).len); + try testing.expectError(error.InvalidLength, fieldsFromBytes(raw[0..33])); +} + +test "noir: response decoder accepts the pinned CircuitVerifyResponse shape" { + const yes = "\x92\xb5CircuitVerifyResponse\x81\xa8verified\xc3"; + try testing.expectEqual(@as(usize, 34), yes.len); + try testing.expect(try parseVerifyResponse(yes, null)); + const no = "\x92\xb5CircuitVerifyResponse\x81\xa8verified\xc2"; + try testing.expect(!try parseVerifyResponse(no, null)); +} + +test "noir: response decoder maps ErrorResponse to ProofRejected with the message" { + const err = "\x92\xadErrorResponse\x81\xa7message\xb3point not on curve!"; + var diag: Diagnostics = .{}; + try testing.expectError(error.ProofRejected, parseVerifyResponse(err, &diag)); + try testing.expectEqualStrings("point not on curve!", diag.message()); + // SrsInitSrs errors get their own tag. + try testing.expectError(error.SrsInitFailed, parseSrsInitResponse(err, null)); +} + +test "noir: response decoder rejects schema drift instead of guessing" { + var diag: Diagnostics = .{}; + // Unknown response name. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xa9Something\x80", &diag)); + // Right name, extra field. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xb5CircuitVerifyResponse\x82\xa8verified\xc3\xa5extra\xc0", &diag)); + // Right name, renamed field. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xb5CircuitVerifyResponse\x81\xa2ok\xc3", &diag)); + // Right name, wrong value type. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xb5CircuitVerifyResponse\x81\xa8verified\x01", &diag)); + // Trailing bytes. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xb5CircuitVerifyResponse\x81\xa8verified\xc3\xc0", &diag)); + // Truncated and empty. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x92\xb5CircuitVerifyResponse\x81\xa8verif", &diag)); + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("", &diag)); + // Envelope of the wrong arity. + try testing.expectError(error.UnexpectedResponse, parseVerifyResponse("\x91\xb5CircuitVerifyResponse", &diag)); + // SrsInitSrsResponse with a non-bin payload. + try testing.expectError(error.UnexpectedResponse, parseSrsInitResponse("\x92\xb2SrsInitSrsResponse\x81\xaapoints_buf\xa0", &diag)); + // A well-formed SrsInitSrsResponse. + try parseSrsInitResponse("\x92\xb2SrsInitSrsResponse\x81\xaapoints_buf\xc4\x00", &diag); +} diff --git a/src/noir/msgpack.zig b/src/noir/msgpack.zig new file mode 100644 index 0000000..d8ff28a --- /dev/null +++ b/src/noir/msgpack.zig @@ -0,0 +1,587 @@ +//! Minimal MessagePack subset used to talk to Barretenberg's `bbapi` C entrypoint. +//! +//! Only the families the bbapi request/response shapes need are implemented: +//! nil, bool, unsigned integers, str, bin, array and map (`skipValue` also +//! steps over int, float and ext values so an unexpected field can be skipped +//! deterministically). The encoder always emits the canonical shortest form, +//! exactly as msgpack-c does for these types; this matters because +//! Barretenberg v5.2.0 decodes requests outside its exception guard and a +//! malformed request terminates the host process rather than returning an +//! error. +//! +//! The decoder is strict about family and bounds, not about canonical form: +//! every typed read checks the head byte and returns `error.TypeMismatch` on +//! any other family, and every length is bounds-checked (`error.Truncated`), +//! so a schema change in a future library version surfaces as a Zig error +//! instead of undefined behavior. It does accept any encoding of the right +//! family, so a value written in a longer form than necessary (`str8` for a +//! short string, `map16` for a one-entry map) decodes normally. That is +//! deliberate: responses come from the linked library, and accepting a +//! non-minimal encoding of the expected shape cannot turn a rejection into an +//! acceptance. Only the encoder needs to be canonical. +//! +//! Spec: https://github.com/msgpack/msgpack/blob/master/spec.md + +const std = @import("std"); + +pub const EncodeError = std.mem.Allocator.Error || error{ + /// A string, binary blob, array or map is longer than 2^32 - 1 elements. + TooLong, +}; + +pub const DecodeError = error{ + /// The input ended inside a value. + Truncated, + /// The head byte does not belong to the family the caller asked for. + TypeMismatch, + /// The reserved head byte 0xc1 was encountered. + Reserved, + /// Nested containers deeper than `max_depth` while skipping a value. + TooDeep, +}; + +/// Maximum container nesting `Decoder.skipValue` will follow. +pub const max_depth: usize = 32; + +// ============================================================================ +// Encoder +// ============================================================================ + +/// Appends canonical msgpack encodings to a growable buffer. +pub const Encoder = struct { + allocator: std.mem.Allocator, + buf: std.ArrayList(u8) = .empty, + + pub fn init(allocator: std.mem.Allocator) Encoder { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Encoder) void { + self.buf.deinit(self.allocator); + } + + /// The bytes encoded so far (still owned by the encoder). + pub fn bytes(self: *const Encoder) []const u8 { + return self.buf.items; + } + + /// Hand the encoded bytes to the caller; the encoder is left empty. + pub fn toOwnedSlice(self: *Encoder) std.mem.Allocator.Error![]u8 { + return self.buf.toOwnedSlice(self.allocator); + } + + pub fn writeNil(self: *Encoder) EncodeError!void { + try self.buf.append(self.allocator, 0xc0); + } + + pub fn writeBool(self: *Encoder, value: bool) EncodeError!void { + try self.buf.append(self.allocator, if (value) 0xc3 else 0xc2); + } + + /// Unsigned integer in its shortest form: positive fixint, uint 8/16/32/64. + pub fn writeUint(self: *Encoder, value: u64) EncodeError!void { + if (value < 0x80) { + try self.buf.append(self.allocator, @intCast(value)); + } else if (value <= std.math.maxInt(u8)) { + try self.buf.append(self.allocator, 0xcc); + try self.buf.append(self.allocator, @intCast(value)); + } else if (value <= std.math.maxInt(u16)) { + try self.buf.append(self.allocator, 0xcd); + try self.appendBigEndian(u16, @intCast(value)); + } else if (value <= std.math.maxInt(u32)) { + try self.buf.append(self.allocator, 0xce); + try self.appendBigEndian(u32, @intCast(value)); + } else { + try self.buf.append(self.allocator, 0xcf); + try self.appendBigEndian(u64, value); + } + } + + /// UTF-8 string: fixstr (< 32 bytes), str 8/16/32. + pub fn writeStr(self: *Encoder, value: []const u8) EncodeError!void { + if (value.len < 32) { + try self.buf.append(self.allocator, 0xa0 | @as(u8, @intCast(value.len))); + } else { + try self.writeLengthPrefixed(value.len, 0xd9, 0xda, 0xdb); + } + try self.buf.appendSlice(self.allocator, value); + } + + /// Raw bytes: bin 8/16/32. + pub fn writeBin(self: *Encoder, value: []const u8) EncodeError!void { + try self.writeLengthPrefixed(value.len, 0xc4, 0xc5, 0xc6); + try self.buf.appendSlice(self.allocator, value); + } + + /// Array header for `count` elements; the caller then writes the elements. + pub fn writeArrayHeader(self: *Encoder, count: usize) EncodeError!void { + if (count < 16) { + try self.buf.append(self.allocator, 0x90 | @as(u8, @intCast(count))); + } else { + try self.writeLength16or32(count, 0xdc, 0xdd); + } + } + + /// Map header for `count` key/value pairs; the caller then writes 2*count values. + pub fn writeMapHeader(self: *Encoder, count: usize) EncodeError!void { + if (count < 16) { + try self.buf.append(self.allocator, 0x80 | @as(u8, @intCast(count))); + } else { + try self.writeLength16or32(count, 0xde, 0xdf); + } + } + + fn writeLengthPrefixed(self: *Encoder, len: usize, tag8: u8, tag16: u8, tag32: u8) EncodeError!void { + if (len <= std.math.maxInt(u8)) { + try self.buf.append(self.allocator, tag8); + try self.buf.append(self.allocator, @intCast(len)); + } else { + try self.writeLength16or32(len, tag16, tag32); + } + } + + fn writeLength16or32(self: *Encoder, len: usize, tag16: u8, tag32: u8) EncodeError!void { + if (len <= std.math.maxInt(u16)) { + try self.buf.append(self.allocator, tag16); + try self.appendBigEndian(u16, @intCast(len)); + } else if (len <= std.math.maxInt(u32)) { + try self.buf.append(self.allocator, tag32); + try self.appendBigEndian(u32, @intCast(len)); + } else { + return error.TooLong; + } + } + + fn appendBigEndian(self: *Encoder, comptime T: type, value: T) EncodeError!void { + var tmp: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &tmp, value, .big); + try self.buf.appendSlice(self.allocator, &tmp); + } +}; + +// ============================================================================ +// Decoder +// ============================================================================ + +/// Strict cursor over a msgpack byte string. Typed reads fail with +/// `error.TypeMismatch` unless the next value is of the requested family. +pub const Decoder = struct { + data: []const u8, + pos: usize = 0, + + pub fn init(data: []const u8) Decoder { + return .{ .data = data }; + } + + /// True once every input byte has been consumed. + pub fn finished(self: *const Decoder) bool { + return self.pos == self.data.len; + } + + pub fn remaining(self: *const Decoder) usize { + return self.data.len - self.pos; + } + + pub fn readNil(self: *Decoder) DecodeError!void { + const head = try self.peek(); + if (head != 0xc0) return error.TypeMismatch; + self.pos += 1; + } + + pub fn readBool(self: *Decoder) DecodeError!bool { + const head = try self.peek(); + switch (head) { + 0xc2 => { + self.pos += 1; + return false; + }, + 0xc3 => { + self.pos += 1; + return true; + }, + else => return error.TypeMismatch, + } + } + + pub fn readUint(self: *Decoder) DecodeError!u64 { + const head = try self.peek(); + switch (head) { + 0x00...0x7f => { + self.pos += 1; + return head; + }, + 0xcc => { + self.pos += 1; + return try self.readBigEndian(u8); + }, + 0xcd => { + self.pos += 1; + return try self.readBigEndian(u16); + }, + 0xce => { + self.pos += 1; + return try self.readBigEndian(u32); + }, + 0xcf => { + self.pos += 1; + return try self.readBigEndian(u64); + }, + else => return error.TypeMismatch, + } + } + + /// Returns a view into the input; valid as long as the input is. + pub fn readStr(self: *Decoder) DecodeError![]const u8 { + const head = try self.peek(); + const len: usize = switch (head) { + 0xa0...0xbf => blk: { + self.pos += 1; + break :blk head & 0x1f; + }, + 0xd9 => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u8); + }, + 0xda => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u16); + }, + 0xdb => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u32); + }, + else => return error.TypeMismatch, + }; + return self.take(len); + } + + /// Returns a view into the input; valid as long as the input is. + pub fn readBin(self: *Decoder) DecodeError![]const u8 { + const head = try self.peek(); + const len: usize = switch (head) { + 0xc4 => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u8); + }, + 0xc5 => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u16); + }, + 0xc6 => blk: { + self.pos += 1; + break :blk try self.readBigEndian(u32); + }, + else => return error.TypeMismatch, + }; + return self.take(len); + } + + /// Number of elements that follow. + pub fn readArrayHeader(self: *Decoder) DecodeError!usize { + const head = try self.peek(); + switch (head) { + 0x90...0x9f => { + self.pos += 1; + return head & 0x0f; + }, + 0xdc => { + self.pos += 1; + return try self.readBigEndian(u16); + }, + 0xdd => { + self.pos += 1; + return try self.readBigEndian(u32); + }, + else => return error.TypeMismatch, + } + } + + /// Number of key/value pairs that follow. + pub fn readMapHeader(self: *Decoder) DecodeError!usize { + const head = try self.peek(); + switch (head) { + 0x80...0x8f => { + self.pos += 1; + return head & 0x0f; + }, + 0xde => { + self.pos += 1; + return try self.readBigEndian(u16); + }, + 0xdf => { + self.pos += 1; + return try self.readBigEndian(u32); + }, + else => return error.TypeMismatch, + } + } + + /// Step over one complete value of any family without interpreting it. + pub fn skipValue(self: *Decoder) DecodeError!void { + return self.skipValueDepth(0); + } + + fn skipValueDepth(self: *Decoder, depth: usize) DecodeError!void { + if (depth >= max_depth) return error.TooDeep; + const head = try self.peek(); + self.pos += 1; + switch (head) { + 0x00...0x7f, 0xe0...0xff, 0xc0, 0xc2, 0xc3 => {}, + 0xc1 => return error.Reserved, + 0x80...0x8f => try self.skipN(2 * @as(usize, head & 0x0f), depth), + 0x90...0x9f => try self.skipN(head & 0x0f, depth), + 0xa0...0xbf => _ = try self.take(head & 0x1f), + 0xc4, 0xd9 => _ = try self.take(try self.readBigEndian(u8)), + 0xc5, 0xda => _ = try self.take(try self.readBigEndian(u16)), + 0xc6, 0xdb => _ = try self.take(try self.readBigEndian(u32)), + // ext 8/16/32: length, then one type byte, then the payload. + 0xc7 => _ = try self.take(@as(usize, try self.readBigEndian(u8)) + 1), + 0xc8 => _ = try self.take(@as(usize, try self.readBigEndian(u16)) + 1), + 0xc9 => _ = try self.take(@as(usize, try self.readBigEndian(u32)) + 1), + 0xca, 0xcc, 0xd0, 0xcd, 0xd1, 0xcb, 0xce, 0xd2, 0xcf, 0xd3 => _ = try self.take(scalarWidth(head)), + // fixext 1/2/4/8/16: one type byte plus the payload. + 0xd4 => _ = try self.take(2), + 0xd5 => _ = try self.take(3), + 0xd6 => _ = try self.take(5), + 0xd7 => _ = try self.take(9), + 0xd8 => _ = try self.take(17), + 0xdc => try self.skipN(try self.readBigEndian(u16), depth), + 0xdd => try self.skipN(try self.readBigEndian(u32), depth), + 0xde => try self.skipN(2 * @as(usize, try self.readBigEndian(u16)), depth), + 0xdf => try self.skipN(2 * @as(usize, try self.readBigEndian(u32)), depth), + } + } + + fn scalarWidth(head: u8) usize { + return switch (head) { + 0xcc, 0xd0 => 1, + 0xcd, 0xd1 => 2, + 0xca, 0xce, 0xd2 => 4, + 0xcb, 0xcf, 0xd3 => 8, + else => unreachable, + }; + } + + fn skipN(self: *Decoder, count: usize, depth: usize) DecodeError!void { + var i: usize = 0; + while (i < count) : (i += 1) try self.skipValueDepth(depth + 1); + } + + fn peek(self: *const Decoder) DecodeError!u8 { + if (self.pos >= self.data.len) return error.Truncated; + return self.data[self.pos]; + } + + fn take(self: *Decoder, len: usize) DecodeError![]const u8 { + if (len > self.data.len - self.pos) return error.Truncated; + const out = self.data[self.pos .. self.pos + len]; + self.pos += len; + return out; + } + + fn readBigEndian(self: *Decoder, comptime T: type) DecodeError!T { + const raw = try self.take(@sizeOf(T)); + return std.mem.readInt(T, raw[0..@sizeOf(T)], .big); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; + +fn expectEncoded(expected: []const u8, comptime write: anytype, arg: anytype) !void { + var enc = Encoder.init(testing.allocator); + defer enc.deinit(); + try write(&enc, arg); + try testing.expectEqualSlices(u8, expected, enc.bytes()); +} + +test "msgpack: nil and bool" { + var enc = Encoder.init(testing.allocator); + defer enc.deinit(); + try enc.writeNil(); + try enc.writeBool(false); + try enc.writeBool(true); + try testing.expectEqualSlices(u8, &.{ 0xc0, 0xc2, 0xc3 }, enc.bytes()); +} + +test "msgpack: uint boundaries use the shortest encoding" { + try expectEncoded(&.{0x00}, Encoder.writeUint, @as(u64, 0)); + try expectEncoded(&.{0x7f}, Encoder.writeUint, @as(u64, 0x7f)); + try expectEncoded(&.{ 0xcc, 0x80 }, Encoder.writeUint, @as(u64, 0x80)); + try expectEncoded(&.{ 0xcc, 0xff }, Encoder.writeUint, @as(u64, 0xff)); + try expectEncoded(&.{ 0xcd, 0x01, 0x00 }, Encoder.writeUint, @as(u64, 0x100)); + try expectEncoded(&.{ 0xcd, 0xff, 0xff }, Encoder.writeUint, @as(u64, 0xffff)); + try expectEncoded(&.{ 0xce, 0x00, 0x01, 0x00, 0x00 }, Encoder.writeUint, @as(u64, 0x10000)); + try expectEncoded(&.{ 0xce, 0xff, 0xff, 0xff, 0xff }, Encoder.writeUint, @as(u64, 0xffff_ffff)); + try expectEncoded(&.{ 0xcf, 0, 0, 0, 1, 0, 0, 0, 0 }, Encoder.writeUint, @as(u64, 0x1_0000_0000)); +} + +test "msgpack: str boundaries" { + try expectEncoded(&.{0xa0}, Encoder.writeStr, ""); + try expectEncoded("\xa3abc", Encoder.writeStr, "abc"); + const s31 = "a" ** 31; + try expectEncoded("\xbf" ++ s31, Encoder.writeStr, s31); + const s32 = "a" ** 32; + try expectEncoded("\xd9\x20" ++ s32, Encoder.writeStr, s32); + const s255 = "a" ** 255; + try expectEncoded("\xd9\xff" ++ s255, Encoder.writeStr, s255); + const s256 = "a" ** 256; + try expectEncoded("\xda\x01\x00" ++ s256, Encoder.writeStr, s256); +} + +test "msgpack: bin boundaries" { + try expectEncoded(&.{ 0xc4, 0x00 }, Encoder.writeBin, ""); + try expectEncoded("\xc4\x03\x01\x02\x03", Encoder.writeBin, "\x01\x02\x03"); + const b255 = "\x5a" ** 255; + try expectEncoded("\xc4\xff" ++ b255, Encoder.writeBin, b255); + const b256 = "\x5a" ** 256; + try expectEncoded("\xc5\x01\x00" ++ b256, Encoder.writeBin, b256); + const b65536 = "\x5a" ** 65536; + try expectEncoded("\xc6\x00\x01\x00\x00" ++ b65536, Encoder.writeBin, b65536); +} + +test "msgpack: array and map headers" { + try expectEncoded(&.{0x90}, Encoder.writeArrayHeader, @as(usize, 0)); + try expectEncoded(&.{0x9f}, Encoder.writeArrayHeader, @as(usize, 15)); + try expectEncoded(&.{ 0xdc, 0x00, 0x10 }, Encoder.writeArrayHeader, @as(usize, 16)); + try expectEncoded(&.{ 0xdc, 0x01, 0xca }, Encoder.writeArrayHeader, @as(usize, 458)); + try expectEncoded(&.{ 0xdd, 0x00, 0x01, 0x00, 0x00 }, Encoder.writeArrayHeader, @as(usize, 65536)); + try expectEncoded(&.{0x80}, Encoder.writeMapHeader, @as(usize, 0)); + try expectEncoded(&.{0x84}, Encoder.writeMapHeader, @as(usize, 4)); + try expectEncoded(&.{ 0xde, 0x00, 0x10 }, Encoder.writeMapHeader, @as(usize, 16)); + try expectEncoded(&.{ 0xdf, 0x00, 0x01, 0x00, 0x00 }, Encoder.writeMapHeader, @as(usize, 65536)); +} + +test "msgpack: toOwnedSlice hands over the buffer" { + var enc = Encoder.init(testing.allocator); + defer enc.deinit(); + try enc.writeStr("hi"); + const owned = try enc.toOwnedSlice(); + defer testing.allocator.free(owned); + try testing.expectEqualSlices(u8, "\xa2hi", owned); + try testing.expectEqual(@as(usize, 0), enc.bytes().len); +} + +test "msgpack: decoder round trip" { + var enc = Encoder.init(testing.allocator); + defer enc.deinit(); + try enc.writeArrayHeader(2); + try enc.writeStr("CircuitVerifyResponse"); + try enc.writeMapHeader(3); + try enc.writeStr("verified"); + try enc.writeBool(true); + try enc.writeStr("count"); + try enc.writeUint(70000); + try enc.writeStr("blob"); + try enc.writeBin(&.{ 1, 2, 3 }); + + var dec = Decoder.init(enc.bytes()); + try testing.expectEqual(@as(usize, 2), try dec.readArrayHeader()); + try testing.expectEqualStrings("CircuitVerifyResponse", try dec.readStr()); + try testing.expectEqual(@as(usize, 3), try dec.readMapHeader()); + try testing.expectEqualStrings("verified", try dec.readStr()); + try testing.expect(try dec.readBool()); + try testing.expectEqualStrings("count", try dec.readStr()); + try testing.expectEqual(@as(u64, 70000), try dec.readUint()); + try testing.expectEqualStrings("blob", try dec.readStr()); + try testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, try dec.readBin()); + try testing.expect(dec.finished()); +} + +test "msgpack: decoder reads every uint width" { + const input = [_]u8{ 0x05, 0xcc, 0xfe, 0xcd, 0x12, 0x34, 0xce, 0, 0, 0x10, 0, 0xcf, 0, 0, 0, 1, 0, 0, 0, 0 }; + var dec = Decoder.init(&input); + try testing.expectEqual(@as(u64, 5), try dec.readUint()); + try testing.expectEqual(@as(u64, 0xfe), try dec.readUint()); + try testing.expectEqual(@as(u64, 0x1234), try dec.readUint()); + try testing.expectEqual(@as(u64, 0x1000), try dec.readUint()); + try testing.expectEqual(@as(u64, 0x1_0000_0000), try dec.readUint()); + try testing.expect(dec.finished()); +} + +test "msgpack: strict decoder rejects the wrong family" { + var dec = Decoder.init(&.{0xc3}); + try testing.expectError(error.TypeMismatch, dec.readStr()); + try testing.expectError(error.TypeMismatch, dec.readUint()); + try testing.expectError(error.TypeMismatch, dec.readArrayHeader()); + try testing.expectError(error.TypeMismatch, dec.readMapHeader()); + try testing.expectError(error.TypeMismatch, dec.readBin()); + try testing.expectError(error.TypeMismatch, dec.readNil()); + // A failed read consumes nothing. + try testing.expect(try dec.readBool()); +} + +test "msgpack: strict decoder rejects truncated input" { + var empty = Decoder.init(&.{}); + try testing.expectError(error.Truncated, empty.readBool()); + try testing.expectError(error.Truncated, empty.skipValue()); + + // fixstr claiming 5 bytes with only 2 present. + var short_str = Decoder.init("\xa5ab"); + try testing.expectError(error.Truncated, short_str.readStr()); + + // bin16 header cut off after one length byte. + var short_len = Decoder.init(&.{ 0xc5, 0x01 }); + try testing.expectError(error.Truncated, short_len.readBin()); + + // array16 header with a missing length. + var short_arr = Decoder.init(&.{0xdc}); + try testing.expectError(error.Truncated, short_arr.readArrayHeader()); +} + +test "msgpack: skipValue steps over nested containers and foreign families" { + // [ -1, 1.5f32, {"k": [nil, ext]}, "s" ] followed by a trailing bool. + const input = [_]u8{ + 0x94, + 0xff, // negative fixint -1 + 0xca, 0x3f, 0xc0, 0x00, 0x00, // float32 1.5 + 0x81, 0xa1, 'k', 0x92, 0xc0, 0xd4, 0x01, 0xaa, // {"k": [nil, fixext1(type 1, 0xaa)]} + 0xa1, 's', 0xc3, + }; + var dec = Decoder.init(&input); + try dec.skipValue(); + try testing.expect(try dec.readBool()); + try testing.expect(dec.finished()); +} + +test "msgpack: decoder accepts non-minimal encodings of the expected family" { + // Same values as the canonical forms, written in longer encodings: str8 + // instead of fixstr, array16/map16 instead of the fixed headers, and + // uint64 instead of a positive fixint. + const input = "\xdc\x00\x02" ++ // array16 with 2 elements + "\xd9\x02ok" ++ // str8 "ok" + "\xde\x00\x01" ++ // map16 with 1 entry + "\xd9\x01n" ++ // str8 "n" + "\xcf\x00\x00\x00\x00\x00\x00\x00\x07"; // uint64 7 + var dec = Decoder.init(input); + try testing.expectEqual(@as(usize, 2), try dec.readArrayHeader()); + try testing.expectEqualStrings("ok", try dec.readStr()); + try testing.expectEqual(@as(usize, 1), try dec.readMapHeader()); + try testing.expectEqualStrings("n", try dec.readStr()); + try testing.expectEqual(@as(u64, 7), try dec.readUint()); + try testing.expect(dec.finished()); + + // The encoder, by contrast, only ever emits the shortest form. + var enc = Encoder.init(testing.allocator); + defer enc.deinit(); + try enc.writeArrayHeader(2); + try enc.writeStr("ok"); + try enc.writeMapHeader(1); + try enc.writeStr("n"); + try enc.writeUint(7); + try testing.expectEqualSlices(u8, "\x92\xa2ok\x81\xa1n\x07", enc.bytes()); + try testing.expect(enc.bytes().len < input.len); +} + +test "msgpack: skipValue rejects the reserved byte and runaway nesting" { + var reserved = Decoder.init(&.{0xc1}); + try testing.expectError(error.Reserved, reserved.skipValue()); + + // 40 nested single-element arrays exceed max_depth. + const nested = [_]u8{0x91} ** 40 ++ [_]u8{0xc0}; + var deep = Decoder.init(&nested); + try testing.expectError(error.TooDeep, deep.skipValue()); +} diff --git a/src/root.zig b/src/root.zig index 3340c59..7f3f735 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,6 +1,9 @@ // eth.zig - Pure Zig Ethereum Client Library // Maintained by Strobe Labs (https://github.com/strobelabs/eth.zig) +// Build-time switches from build.zig (`-Dnoir`). +const build_options = @import("build_options"); + // -- Layer 1: Primitives -- pub const primitives = @import("primitives.zig"); pub const uint256 = @import("uint256.zig"); @@ -19,6 +22,9 @@ pub const secp256k1 = @import("secp256k1.zig"); pub const signer = @import("signer.zig"); pub const kms = @import("kms.zig"); pub const eip155 = @import("eip155.zig"); +/// Noir UltraHonk proof verification over Barretenberg. Opt-in: the entry +/// points only compile when the build is invoked with `-Dnoir=true`. +pub const noir = @import("noir.zig"); // -- Layer 4: Types -- pub const access_list = @import("access_list.zig"); @@ -123,6 +129,12 @@ test { _ = @import("blob.zig"); _ = @import("kzg.zig"); _ = @import("kzg_vectors_test.zig"); + // Layer 3, opt-in: only collected when the build links Barretenberg + // (-Dnoir=true); the default test run is unchanged. + if (build_options.noir) { + _ = @import("noir.zig"); + _ = @import("noir/msgpack.zig"); + } // Layer 5 _ = @import("mnemonic.zig"); _ = @import("hd_wallet.zig"); diff --git a/tests/noir_vectors_test.zig b/tests/noir_vectors_test.zig new file mode 100644 index 0000000..d39307b --- /dev/null +++ b/tests/noir_vectors_test.zig @@ -0,0 +1,251 @@ +// Barretenberg v5.2.0 interop vectors for eth.noir. +// +// Verifies real UltraHonk proofs written by the bb CLI (bb 5.2.0, nargo +// 1.0.0-beta.25) for the hello circuit under tests/vectors/noir/, in both the +// poseidon2 (default) and keccak ("evm") flavors, and pins the exact request +// bytes eth.noir sends to the library against reference requests produced by +// an independent encoder and confirmed against the library at runtime. +// +// Only built and run when the build links Barretenberg: +// zig build test -Dnoir=true + +const std = @import("std"); +const eth = @import("eth"); +const noir = eth.noir; + +const testing = std.testing; + +const p2_vk = @embedFile("vectors/noir/poseidon2/vk"); +const p2_proof = @embedFile("vectors/noir/poseidon2/proof"); +const p2_public_inputs = @embedFile("vectors/noir/poseidon2/public_inputs"); + +const kc_vk = @embedFile("vectors/noir/keccak/vk"); +const kc_proof = @embedFile("vectors/noir/keccak/proof"); +const kc_public_inputs = @embedFile("vectors/noir/keccak/public_inputs"); + +const req_srs_g1only = @embedFile("vectors/noir/requests/req_srs_g1only.bin"); +const req_verify_default = @embedFile("vectors/noir/requests/req_verify_default.bin"); +const req_verify_evm = @embedFile("vectors/noir/requests/req_verify_evm.bin"); + +test "noir vectors: bb artifacts have the documented sizes" { + // poseidon2 proofs are padded to CONST_PROOF_SIZE_LOG_N; keccak (evm) proofs are not. + try testing.expectEqual(@as(usize, 458), (try noir.fieldsFromBytes(p2_proof)).len); + try testing.expectEqual(@as(usize, 3680), p2_vk.len); + try testing.expectEqual(@as(usize, 142), (try noir.fieldsFromBytes(kc_proof)).len); + try testing.expectEqual(@as(usize, 1888), kc_vk.len); + // The circuit has one public input, y = 2. + const inputs = try noir.fieldsFromBytes(p2_public_inputs); + try testing.expectEqual(@as(usize, 1), inputs.len); + try testing.expectEqual(@as(u8, 2), inputs[0][31]); + try testing.expectEqualSlices(u8, p2_public_inputs, kc_public_inputs); +} + +test "noir vectors: poseidon2 ZK proof verifies" { + try noir.init(); + const ok = try noir.verify( + testing.allocator, + p2_vk, + try noir.fieldsFromBytes(p2_public_inputs), + try noir.fieldsFromBytes(p2_proof), + .fromVerifierTarget(.default), + ); + try testing.expect(ok); +} + +test "noir vectors: keccak ZK (evm) proof verifies" { + try noir.init(); + const ok = try noir.verify( + testing.allocator, + kc_vk, + try noir.fieldsFromBytes(kc_public_inputs), + try noir.fieldsFromBytes(kc_proof), + .fromVerifierTarget(.evm), + ); + try testing.expect(ok); +} + +test "noir vectors: tampered proof element is rejected with an error and a message" { + try noir.init(); + var tampered: [p2_proof.len]u8 = p2_proof.*; + // Flip the low bit of the last field (the y coordinate of the KZG opening + // point): the point is no longer on the curve, so deserialization fails. + tampered[tampered.len - 1] ^= 0x01; + var diag: noir.Diagnostics = .{}; + try testing.expectError(error.ProofRejected, noir.verifyDiag( + testing.allocator, + p2_vk, + try noir.fieldsFromBytes(p2_public_inputs), + try noir.fieldsFromBytes(&tampered), + .fromVerifierTarget(.default), + &diag, + )); + try testing.expect(diag.message().len > 0); + try testing.expect(std.mem.indexOf(u8, diag.message(), "curve") != null); +} + +test "noir vectors: short proof returns false" { + try noir.init(); + const full = try noir.fieldsFromBytes(p2_proof); + const ok = try noir.verify( + testing.allocator, + p2_vk, + try noir.fieldsFromBytes(p2_public_inputs), + full[0 .. full.len - 1], + .fromVerifierTarget(.default), + ); + try testing.expect(!ok); +} + +test "noir vectors: wrong public input returns false" { + try noir.init(); + const zero_input = [_][32]u8{[_]u8{0} ** 32}; + const ok = try noir.verify( + testing.allocator, + p2_vk, + &zero_input, + try noir.fieldsFromBytes(p2_proof), + .fromVerifierTarget(.default), + ); + try testing.expect(!ok); +} + +test "noir vectors: wrong public input count is rejected with an error" { + try noir.init(); + // The value-mismatch case above returns false, but a count that disagrees + // with the verification key is rejected before a verdict is reached. Both + // mean "not verified"; the distinction is what the docs promise. + const two_inputs = [_][32]u8{ (try noir.fieldsFromBytes(p2_public_inputs))[0], [_]u8{0} ** 32 }; + for ([_][]const [32]u8{ &.{}, &two_inputs }) |inputs| { + var diag: noir.Diagnostics = .{}; + try testing.expectError(error.ProofRejected, noir.verifyDiag( + testing.allocator, + p2_vk, + inputs, + try noir.fieldsFromBytes(p2_proof), + .fromVerifierTarget(.default), + &diag, + )); + try testing.expect(diag.message().len > 0); + } +} + +test "noir vectors: right-size verification key that does not deserialize is rejected with an error" { + try noir.init(); + // Same length as the real poseidon2 VK, so the size gate passes and the + // failure comes from deserialization rather than a verdict. + const zero_vk = [_]u8{0} ** p2_vk.len; + var diag: noir.Diagnostics = .{}; + try testing.expectError(error.ProofRejected, noir.verifyDiag( + testing.allocator, + &zero_vk, + try noir.fieldsFromBytes(p2_public_inputs), + try noir.fieldsFromBytes(p2_proof), + .fromVerifierTarget(.default), + &diag, + )); + try testing.expect(diag.message().len > 0); +} + +test "noir vectors: non-canonical field element is rejected with an error" { + try noir.init(); + var bad: [p2_proof.len]u8 = p2_proof.*; + // 0xff..ff is >= the BN254 scalar field modulus. + @memset(bad[0..32], 0xff); + var diag: noir.Diagnostics = .{}; + try testing.expectError(error.ProofRejected, noir.verifyDiag( + testing.allocator, + p2_vk, + try noir.fieldsFromBytes(p2_public_inputs), + try noir.fieldsFromBytes(&bad), + .fromVerifierTarget(.default), + &diag, + )); + try testing.expect(diag.message().len > 0); +} + +test "noir vectors: flavor mismatch returns false" { + try noir.init(); + // A keccak proof and VK checked with the poseidon2 settings. + const ok = try noir.verify( + testing.allocator, + kc_vk, + try noir.fieldsFromBytes(kc_public_inputs), + try noir.fieldsFromBytes(kc_proof), + .fromVerifierTarget(.default), + ); + try testing.expect(!ok); +} + +test "noir vectors: concurrent verification across threads" { + try noir.init(); + // Barretenberg keeps per-process request state with no lock of its own, so + // eth.noir serializes every bbapi call behind one mutex. This is what would + // catch that mutex being dropped in a later refactor: without it the + // threads race on the library's global request object. + const thread_count = 8; + const rounds = 5; + const Worker = struct { + fn run(index: usize, ok: *bool) void { + var round: usize = 0; + while (round < rounds) : (round += 1) { + // Alternate flavors so the threads do not all walk the same path. + const use_keccak = (index + round) % 2 == 1; + const verified = if (use_keccak) noir.verify( + std.testing.allocator, + kc_vk, + noir.fieldsFromBytes(kc_public_inputs) catch return, + noir.fieldsFromBytes(kc_proof) catch return, + .fromVerifierTarget(.evm), + ) catch return else noir.verify( + std.testing.allocator, + p2_vk, + noir.fieldsFromBytes(p2_public_inputs) catch return, + noir.fieldsFromBytes(p2_proof) catch return, + .fromVerifierTarget(.default), + ) catch return; + if (!verified) return; + } + ok.* = true; + } + }; + var ok: [thread_count]bool = @splat(false); + var threads: [thread_count]std.Thread = undefined; + var spawned: usize = 0; + for (0..thread_count) |i| { + threads[i] = std.Thread.spawn(.{}, Worker.run, .{ i, &ok[i] }) catch break; + spawned += 1; + } + for (threads[0..spawned]) |t| t.join(); + try testing.expect(spawned > 0); + for (ok[0..spawned]) |flag| try testing.expect(flag); +} + +test "noir vectors: SrsInitSrs request bytes match the reference" { + const request = try noir.encodeSrsInitSrsRequest(testing.allocator, &noir.BN254_G1_GENERATOR, 1, &noir.BN254_G2_ELEMENT); + defer testing.allocator.free(request); + try testing.expectEqualSlices(u8, req_srs_g1only, request); +} + +test "noir vectors: CircuitVerify request bytes match the poseidon2 reference" { + const request = try noir.encodeCircuitVerifyRequest( + testing.allocator, + p2_vk, + try noir.fieldsFromBytes(p2_public_inputs), + try noir.fieldsFromBytes(p2_proof), + .fromVerifierTarget(.default), + ); + defer testing.allocator.free(request); + try testing.expectEqualSlices(u8, req_verify_default, request); +} + +test "noir vectors: CircuitVerify request bytes match the keccak reference" { + const request = try noir.encodeCircuitVerifyRequest( + testing.allocator, + kc_vk, + try noir.fieldsFromBytes(kc_public_inputs), + try noir.fieldsFromBytes(kc_proof), + .fromVerifierTarget(.evm), + ); + defer testing.allocator.free(request); + try testing.expectEqualSlices(u8, req_verify_evm, request); +} diff --git a/tests/vectors/noir/Prover.toml b/tests/vectors/noir/Prover.toml new file mode 100644 index 0000000..8c12ebb --- /dev/null +++ b/tests/vectors/noir/Prover.toml @@ -0,0 +1,2 @@ +x = "1" +y = "2" diff --git a/tests/vectors/noir/README.md b/tests/vectors/noir/README.md new file mode 100644 index 0000000..1b13229 --- /dev/null +++ b/tests/vectors/noir/README.md @@ -0,0 +1,43 @@ +# Noir / Barretenberg test vectors + +Artifacts for `tests/noir_vectors_test.zig` (built only with `-Dnoir=true`). +They were produced with **bb 5.2.0** and **nargo 1.0.0-beta.25** +(noirc `1.0.0-beta.25+75061fab15986eedee4e7d9104ff87dd9fa4ca10`, the Noir +release that Barretenberg v5.2.0 pins) and are checked into the repo unchanged. + +## Circuit + +`main.nr` is the whole program: one private input `x`, one public input `y`, +and the constraint `x != y`. `Prover.toml` supplies `x = 1`, `y = 2`, so the +single public input is the field element 2. + +``` +nargo compile # target/hello.json +nargo execute # target/hello.gz (witness) +``` + +## Directories + +- `poseidon2/`: `bb write_vk` + `bb prove` with no `--verifier_target` (the CLI + default: poseidon2 transcript, ZK, no IPA). `proof` = 458 fields (14656 + bytes, padded to the constant proof size), `vk` = 3680 bytes. +- `keccak/`: the same circuit and witness with `--verifier_target evm` + (keccak transcript, ZK). `proof` = 142 fields (4544 bytes, unpadded), + `vk` = 1888 bytes. +- Each directory holds bb's raw outputs: `proof` and `public_inputs` + (concatenated 32-byte big-endian field elements), `vk` (raw verification + key) and `vk_hash` (32 bytes; not needed by the verifier, kept for + completeness). +- `requests/`: reference `bbapi` request bytes. They were generated by an + independent, single-purpose msgpack encoder (Python) and confirmed against + `libbb-external.a` v5.2.0 at runtime (the library answered `SrsInitSrsResponse` + and `CircuitVerifyResponse{verified: true}` respectively). The tests assert + that eth.noir's Zig encoder reproduces them byte for byte: + - `req_srs_g1only.bin`: `SrsInitSrs` with one G1 point (the generator) and + the pinned G2 element, i.e. exactly what `noir.init` sends. + - `req_verify_default.bin`: `CircuitVerify` for `poseidon2/` with the default + settings. + - `req_verify_evm.bin`: `CircuitVerify` for `keccak/` with the `evm` settings. + +`bb verify -k vk -p proof -i public_inputs [-t evm]` reports both proofs as +verified with the same bb release. diff --git a/tests/vectors/noir/keccak/proof b/tests/vectors/noir/keccak/proof new file mode 100644 index 0000000..1e05a55 Binary files /dev/null and b/tests/vectors/noir/keccak/proof differ diff --git a/tests/vectors/noir/keccak/public_inputs b/tests/vectors/noir/keccak/public_inputs new file mode 100644 index 0000000..b68c955 Binary files /dev/null and b/tests/vectors/noir/keccak/public_inputs differ diff --git a/tests/vectors/noir/keccak/vk b/tests/vectors/noir/keccak/vk new file mode 100644 index 0000000..3ac3dd6 Binary files /dev/null and b/tests/vectors/noir/keccak/vk differ diff --git a/tests/vectors/noir/keccak/vk_hash b/tests/vectors/noir/keccak/vk_hash new file mode 100644 index 0000000..06b676b Binary files /dev/null and b/tests/vectors/noir/keccak/vk_hash differ diff --git a/tests/vectors/noir/main.nr b/tests/vectors/noir/main.nr new file mode 100644 index 0000000..4e1fd3c --- /dev/null +++ b/tests/vectors/noir/main.nr @@ -0,0 +1,3 @@ +fn main(x: Field, y: pub Field) { + assert(x != y); +} diff --git a/tests/vectors/noir/poseidon2/proof b/tests/vectors/noir/poseidon2/proof new file mode 100644 index 0000000..7cef55b Binary files /dev/null and b/tests/vectors/noir/poseidon2/proof differ diff --git a/tests/vectors/noir/poseidon2/public_inputs b/tests/vectors/noir/poseidon2/public_inputs new file mode 100644 index 0000000..b68c955 Binary files /dev/null and b/tests/vectors/noir/poseidon2/public_inputs differ diff --git a/tests/vectors/noir/poseidon2/vk b/tests/vectors/noir/poseidon2/vk new file mode 100644 index 0000000..f3b39a3 Binary files /dev/null and b/tests/vectors/noir/poseidon2/vk differ diff --git a/tests/vectors/noir/poseidon2/vk_hash b/tests/vectors/noir/poseidon2/vk_hash new file mode 100644 index 0000000..a63bb9c --- /dev/null +++ b/tests/vectors/noir/poseidon2/vk_hash @@ -0,0 +1 @@ +.u¯‚a­Ëéɵýº^Ń’‚cÏ?;³æ†XT¶p+{# \ No newline at end of file diff --git a/tests/vectors/noir/requests/req_srs_g1only.bin b/tests/vectors/noir/requests/req_srs_g1only.bin new file mode 100644 index 0000000..3826382 Binary files /dev/null and b/tests/vectors/noir/requests/req_srs_g1only.bin differ diff --git a/tests/vectors/noir/requests/req_verify_default.bin b/tests/vectors/noir/requests/req_verify_default.bin new file mode 100644 index 0000000..36f62f5 Binary files /dev/null and b/tests/vectors/noir/requests/req_verify_default.bin differ diff --git a/tests/vectors/noir/requests/req_verify_evm.bin b/tests/vectors/noir/requests/req_verify_evm.bin new file mode 100644 index 0000000..896c79d Binary files /dev/null and b/tests/vectors/noir/requests/req_verify_evm.bin differ