From 8f7722a907cc2c75503b614423f95c75440798a6 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Tue, 7 Apr 2026 20:18:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(convert-vk):=20add=20VK=20JSON=20=E2=86=92?= =?UTF-8?q?=20arkworks=20binary=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add convert-vk binary that reads a snarkjs verification_key_*.json and writes the 424-byte arkworks compressed binary required by the on-chain ArkVK::deserialize_compressed() verifier. Update Makefile build target and docs (installation, usage, witness-formats). --- .github/workflows/release.yml | 24 +-- CHANGELOG.md | 15 ++ Cargo.toml | 10 +- Makefile | 5 +- docs/installation.md | 61 +++++--- docs/usage.md | 254 +++++++++++++++++++------------ docs/witness-formats.md | 131 +++++++++++----- src/bin/bench_groth16.rs | 153 +++++++++++++++++++ src/bin/convert_vk.rs | 277 ++++++++++++++++++++++++++++++++++ src/proof.rs | 23 +++ src/utils.rs | 28 ++++ src/wasm.rs | 187 ++++++++++------------- src/wasm/snarkjs_proof.rs | 54 ++++++- 13 files changed, 924 insertions(+), 298 deletions(-) create mode 100644 src/bin/bench_groth16.rs create mode 100644 src/bin/convert_vk.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 105d575..ffae5cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,8 @@ jobs: with: cache-directories: target - - name: Install wasm-pack and cargo-release - run: | - cargo install wasm-pack - cargo install cargo-release + - name: Install wasm-pack + run: cargo install wasm-pack - name: Extract version from Cargo.toml id: version @@ -53,28 +51,16 @@ jobs: echo "✅ Tag v${{ steps.version.outputs.version }} does not exist, proceeding" fi - - name: Configure git for cargo-release + - name: Configure git for tagging if: steps.check_tag.outputs.exists == 'false' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Update CHANGELOG with cargo-release - if: steps.check_tag.outputs.exists == 'false' - run: | - # cargo-release will update CHANGELOG, commit, and tag - cargo release ${{ steps.version.outputs.version }} \ - --execute \ - --no-confirm \ - --no-publish \ - --no-push - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - - name: Push changes and tags + - name: Create and push tag if: steps.check_tag.outputs.exists == 'false' run: | - git push origin main + git tag v${{ steps.version.outputs.version }} git push origin v${{ steps.version.outputs.version }} - name: Build WASM for release diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2234e..de4ec6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0](https://github.com/orbinum/groth16-proofs/releases/tag/v2.1.0) - 2026-04-07 + +### Added +- `convert-vk` binary: converts a snarkjs `verification_key_*.json` to a + ~424-byte arkworks compressed binary (via `CanonicalSerialize::serialize_compressed`). + Required for on-chain VK registration — the runtime `ArkVK::deserialize_compressed()` + expects binary format, not raw JSON bytes. + +### Changed +- `Makefile` `build` target now builds both `generate-proof-from-witness` and `convert-vk`. +- Docs updated (`installation.md`, `usage.md`, `witness-formats.md`) to document the + current proof flows: CDN WASM init, snarkjs → `compress_snarkjs_proof_wasm` primary + path, and `convert-vk` VK registration workflow. +- CHANGELOG is now maintained manually; removed `cargo-release` from the release workflow. + ## [2.0.0](https://github.com/orbinum/groth16-proofs/releases/tag/v2.0.0) - 2026-02-16 ### Added diff --git a/Cargo.toml b/Cargo.toml index 4ace968..c1712f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "groth16-proofs" -version = "2.0.0" +version = "2.1.0" edition = "2021" license = "Apache-2.0 OR GPL-3.0-or-later" description = "High-performance Groth16 proof generator using arkworks for Orbinum privacy protocol" @@ -20,6 +20,14 @@ wasm-opt = true name = "generate-proof-from-witness" path = "src/bin/generate-proof-from-witness.rs" +[[bin]] +name = "bench-groth16" +path = "src/bin/bench_groth16.rs" + +[[bin]] +name = "convert-vk" +path = "src/bin/convert_vk.rs" + [dependencies] # Arkworks dependencies ark-bn254 = "0.5.0" diff --git a/Makefile b/Makefile index d2116a3..dc916c1 100644 --- a/Makefile +++ b/Makefile @@ -53,9 +53,10 @@ check: fmt-check lint ## Check code quality (fmt + clippy) # Build native binary build: ## Build native binary (release) - @echo "$(BLUE)Building native binary...$(NC)" + @echo "$(BLUE)Building native binaries...$(NC)" cargo build --release - @echo "$(GREEN)✓ Binary: ./target/release/generate-proof-from-witness$(NC)" + @echo "$(GREEN)✓ generate-proof-from-witness: ./target/release/generate-proof-from-witness$(NC)" + @echo "$(GREEN)✓ convert-vk: ./target/release/convert-vk$(NC)" build-debug: ## Build native binary (debug) @echo "$(BLUE)Building native binary (debug)...$(NC)" diff --git a/docs/installation.md b/docs/installation.md index 2333f03..26cd93e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,22 +35,19 @@ Install from npm: npm install @orbinum/groth16-proofs ``` -You can also download precompiled binaries from [GitHub Releases](https://github.com/orbinum/groth16-proofs/releases). - -1. Download `orb-groth16-proof.tar.gz` from the latest release (optional) -2. Extract to your project (optional): -```bash -tar -xzf orb-groth16-proof.tar.gz -C ./wasm -``` - -3. Import in TypeScript/JavaScript: +Import in TypeScript/JavaScript: ```typescript -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +import groth16pkg from '@orbinum/groth16-proofs/package.json'; + +// Initialize WASM from CDN (recommended — no bundling required) +const WASM_CDN = `https://unpkg.com/@orbinum/groth16-proofs@${groth16pkg.version}/groth16_proofs_bg.wasm`; +await init(WASM_CDN); -// numPublicSignals depends on your circuit (check your circuit definition) -const numPublicSignals = 5; -const result = generate_proof_from_decimal_wasm(numPublicSignals, witnessJson, provingKeyBytes); +// Convert snarkjs proof to on-chain arkworks format (128 bytes) +const compressed = compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); +// compressed => "0x..." (128 bytes) ``` ### Development Installation @@ -72,11 +69,13 @@ wasm-pack --version ## Building from Source -### Native Binary +### Native Binaries ```bash make build -# Output: ./target/release/generate-proof-from-witness +# Outputs: +# ./target/release/generate-proof-from-witness (Rust-native proof generation) +# ./target/release/convert-vk (VK JSON → arkworks binary) ``` ### WASM Module @@ -100,14 +99,17 @@ make build-all ```bash cargo build --release +# Proof generation ./target/release/generate-proof-from-witness witness.json proving_key.ark +# VK format conversion (snarkjs JSON → on-chain arkworks binary) +./target/release/convert-vk verification_key_unshield.json verification_key_unshield.bin ``` **Advantages**: - ✅ Fastest proof generation (5-8 seconds) - ✅ Direct file I/O access - ✅ Deterministic randomness (testable) -- ✅ Minimal dependencies +- ✅ `convert-vk` produces 424-byte arkworks binary required by on-chain verifier ### WASM @@ -139,15 +141,21 @@ Once installed, see the **[Usage Guide](./usage.md)** for: **Minimal example**: ```rust -// Rust +// Rust — native proof generation use groth16_proofs::generate_proof_from_witness; -let proof = generate_proof_from_witness(&witness, "key.ark")?; +let proof = generate_proof_from_witness(&witness_hex, "key.ark")?; +``` + +```bash +# CLI — convert VK for on-chain registration +./target/release/convert-vk verification_key_unshield.json verification_key_unshield.bin ``` ```typescript -// WASM -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; -const result = generate_proof_from_decimal_wasm(numPublicSignals, witnessJson, keyBytes); +// WASM — compress snarkjs proof to on-chain format +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +await init(WASM_CDN); +const compressed = compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); ``` ## Configuration @@ -182,10 +190,13 @@ make install-tools ### WASM bundle too large -The WASM module is ~3-5 MB (compressed ~1 MB). Consider: -- Serving over gzip/brotli compression -- Using code splitting for lazy loading -- Pre-generating proofs server-side (native) +The WASM module is ~3-5 MB (compressed ~1 MB). Load from CDN instead of bundling: + +```typescript +import groth16pkg from '@orbinum/groth16-proofs/package.json'; +const WASM_CDN = `https://unpkg.com/@orbinum/groth16-proofs@${groth16pkg.version}/groth16_proofs_bg.wasm`; +await init(WASM_CDN); // does not bundle the .wasm into your JS bundle +``` ### Proof generation is slow diff --git a/docs/usage.md b/docs/usage.md index 166b0d1..035ec49 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,18 +39,27 @@ The library converts decimal → hex LE internally, so you don't need to worry a ## Proof Generation Flow +There are two distinct paths depending on your stack: + +### Path A: snarkjs → WASM compress (browser / TypeScript — recommended ✅) + ``` -Witness (decimal or hex) - ↓ [parse and convert] +Circuit inputs + circuit.wasm + circuit_pk.zkey + ↓ snarkjs.groth16.fullProve() ↓ -Field Elements (BN254) - + Proving Key (loaded from file/bytes) - ↓ [arkworks] +{ pi_a, pi_b, pi_c } (snarkjs proof JSON) + + compress_snarkjs_proof_wasm() [WASM] ↓ -Groth16 Proof (128 bytes) - ↓ [extract] +0x<128 bytes> ← submitted on-chain +``` + +### Path B: Witness → arkworks native (Rust / CLI) + +``` +Witness (hex LE field elements) + proving_key.ark + ↓ generate_proof_from_witness() [Rust] ↓ -Proof (hex) + Public Signals (hex array) +0x<128 bytes> ← same on-chain format ``` ## Native Rust API @@ -136,16 +145,74 @@ assert_eq!(field_element, Bn254Fr::from(1u64)); ## WASM JavaScript API -### `generate_proof_from_decimal_wasm()` - Recommended ✅ +### Initialization + +Before calling any WASM function, initialize the module. Load the `.wasm` binary from CDN to avoid bundling it: + +```typescript +import init from '@orbinum/groth16-proofs'; +import groth16pkg from '@orbinum/groth16-proofs/package.json'; + +const WASM_CDN = `https://unpkg.com/@orbinum/groth16-proofs@${groth16pkg.version}/groth16_proofs_bg.wasm`; +await init(WASM_CDN); +``` + +> Note: CJS interop — if `init` is not a function, look for `init.default`. See [loader.ts in proof-generator](../../proof-generator/src/wasm/loader.ts) for a production example. + +--- + +### `compress_snarkjs_proof_wasm()` — Primary browser function ✅ -Generate proof from snarkjs witness (decimal format) - no conversion needed! +Convert a snarkjs proof (`pi_a`, `pi_b`, `pi_c`) into the **128-byte arkworks compressed format** expected by the on-chain verifier. This is the main function used in the Orbinum TypeScript stack. + +**Signature**: +```typescript +function compress_snarkjs_proof_wasm( + proofJson: string // snarkjs proof as JSON string +): string // 0x-prefixed compressed proof (128 bytes) +``` + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `proofJson` | string | JSON with `pi_a`, `pi_b`, `pi_c` decimal coordinate arrays | + +**Returns**: `"0x..."` — 128-byte arkworks canonical Groth16 proof + +**Example**: +```typescript +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +import * as snarkjs from 'snarkjs'; + +await init(WASM_CDN); + +// Step 1: Generate proof with snarkjs (uses .wasm circuit + .zkey proving key) +const { proof: snarkjsProof, publicSignals } = await snarkjs.groth16.fullProve( + circuitInputs, + 'circuit.wasm', // circuit binary (from CDN or local) + 'circuit_pk.zkey' // proving key (NOT .ark — that is Rust-only) +); + +// Step 2: Compress to on-chain format +const compressedProof = compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); +// compressedProof => "0x..." (128 bytes, arkworks canonical) +``` + +> Implementation: `src/wasm/snarkjs_proof.rs`, re-exported from `src/lib.rs`. + +--- + +### `generate_proof_from_decimal_wasm()` — Witness-based alternative + +Generate a proof directly from a raw decimal witness + `.ark` proving key bytes. Use this only when you have a pre-computed witness and the `.ark` key available in-browser. **Signature**: ```typescript function generate_proof_from_decimal_wasm( numPublicSignals: number, // Number of public signals to extract witnessJson: string, // JSON array of decimal strings - provingKeyBytes: Uint8Array // Binary proving key + provingKeyBytes: Uint8Array // Binary proving key (.ark format) ): string // JSON output ``` @@ -154,48 +221,16 @@ function generate_proof_from_decimal_wasm( | Parameter | Type | Description | |-----------|------|-------------| | `numPublicSignals` | number | Number of public signals to extract from witness | -| `witnessJson` | string | JSON string: `'["1", "12345", "67890", ...]'` (decimal) | -| `provingKeyBytes` | Uint8Array | Binary proving key (from `.ark` file) | +| `witnessJson` | string | JSON string: `'["1", "12345", ...]'` (decimal) | +| `provingKeyBytes` | Uint8Array | Binary proving key (`.ark` file bytes) | **Returns**: JSON string ```json { - "proof": "0x...", // 128-byte compressed Groth16 proof - "publicSignals": ["0x...", "0x...", ...] // Public signals as hex + "proof": "0x...", + "publicSignals": ["0x...", "0x...", ...] } ``` - -**Example (with snarkjs)**: -```typescript -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; -import * as snarkjs from 'snarkjs'; - -async function generateProof(circuitInputs) { - // Step 1: Calculate witness using snarkjs - const { witness } = await snarkjs.wtns.calculate( - circuitInputs, - 'circuit.wasm', - 'witness.wtns' - ); - - // Step 2: Export witness as array (already in decimal format!) - const witnessArray = await snarkjs.wtns.exportJson('witness.wtns'); - - // Step 3: Load proving key - const provingKey = await fetch('circuit_pk.ark') - .then(r => r.arrayBuffer()) - .then(b => new Uint8Array(b)); - - // Step 4: Generate proof (pass witness directly!) - const resultJson = generate_proof_from_decimal_wasm( - 5, // number of public signals - JSON.stringify(witnessArray), // No conversion needed! - provingKey - ); - - const { proof, publicSignals } = JSON.parse(resultJson); - return { proof, publicSignals }; -} ``` ### `compress_snarkjs_proof_wasm()` - Interoperability @@ -243,6 +278,42 @@ Initialize panic handling for better browser error messages. Usually called auto function initPanicHook(): void ``` +--- + +## CLI Binaries + +### `convert-vk` — VK format conversion + +Converts a snarkjs verification key JSON to the **arkworks compressed binary** (~424 bytes) required by the on-chain verifier. Run this once per circuit before registering keys on-chain. + +**Usage**: +```bash +./target/release/convert-vk [output_vk.bin] +# If output is omitted, replaces .json with .bin +``` + +**Example**: +```bash +./target/release/convert-vk artifacts/verification_key_unshield.json verification_key_unshield.bin +# stderr: Converted ... → ... (3657 bytes JSON → 424 bytes binary) +``` + +**VK artifact sizes**: +| Format | Extension | Size | Used by | +|--------|-----------|------|---------| +| snarkjs JSON | `verification_key_*.json` | ~3.6 KB | input to `convert-vk` | +| arkworks binary | `*.bin` | ~424 bytes | on-chain registration | + +> The `setup-dev.sh` and `rotate-dev.sh` scripts in the node repo auto-compile `convert-vk` and run it before VK registration. Do not register JSON bytes directly — the runtime deserializer expects arkworks compressed binary. + +### `generate-proof-from-witness` — Rust-native CLI + +```bash +./target/release/generate-proof-from-witness +``` + +Outputs the 128-byte compressed proof to stdout as hex. The witness JSON must be an array of hex LE field elements (see [witness-formats.md](./witness-formats.md)). + ## Complete Examples ### Rust Example @@ -266,68 +337,53 @@ fn main() -> Result<(), Box> { } ``` -### Browser Example +### Browser Example (snarkjs + compress — Recommended ✅) ```typescript -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; - -async function generateProof() { - // Load witness data - const witness = [ - "1", - "2", - // ... more elements - ]; - - // Load proving key - const response = await fetch('proving_keys/my_circuit.ark'); - const provingKeyBytes = new Uint8Array(await response.arrayBuffer()); - - // Specify number of public signals for your circuit - const numPublicSignals = 5; // Depends on your circuit definition - - try { - const resultJson = generate_proof_from_decimal_wasm( - numPublicSignals, - JSON.stringify(witness), - provingKeyBytes - ); - - const { proof, publicSignals } = JSON.parse(resultJson); - console.log('✓ Proof:', proof); - console.log('✓ Public signals:', publicSignals); - - return { proof, publicSignals }; - } catch (error) { - console.error('✗ Generation failed:', error); - throw error; - } +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +import groth16pkg from '@orbinum/groth16-proofs/package.json'; +import * as snarkjs from 'snarkjs'; + +async function generateUnshieldProof(inputs: Record) { + // 1. Initialize WASM from CDN + const WASM_CDN = `https://unpkg.com/@orbinum/groth16-proofs@${groth16pkg.version}/groth16_proofs_bg.wasm`; + await init(WASM_CDN); + + // 2. Generate proof with snarkjs (.zkey from CDN) + const { proof: snarkjsProof } = await snarkjs.groth16.fullProve( + inputs, + 'https://cdn.example.com/circuits/unshield.wasm', + 'https://cdn.example.com/circuits/unshield_pk.zkey' + ); + + // 3. Compress to 128-byte on-chain format + const compressedProof = compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); + console.log('✓ Proof:', compressedProof); // "0x..." 128 bytes + return compressedProof; } ``` ### Node.js Example ```typescript -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; -import fs from 'fs'; +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +import groth16pkg from '@orbinum/groth16-proofs/package.json'; +import { readFileSync } from 'fs'; +import * as snarkjs from 'snarkjs'; -function generateProofFromFile(circuitName: string, witnessPath: string) { - // Load witness and proving key - const witness = JSON.parse(fs.readFileSync(witnessPath, 'utf-8')); - const provingKey = fs.readFileSync(`circuits/${circuitName}_pk.ark`); - - // Configure based on your circuit - // You need to know how many public signals your circuit has - const numPublicSignals = 5; - - const result = generate_proof_from_decimal_wasm( - numPublicSignals, - JSON.stringify(witness), - new Uint8Array(provingKey) +async function generateProofNode(circuitName: string, inputs: unknown) { + // Initialize WASM + const wasmBytes = readFileSync(`./circuits/${circuitName}_bg.wasm`); + await init(wasmBytes); + + // Generate with snarkjs + const { proof: snarkjsProof } = await snarkjs.groth16.fullProve( + inputs, + `./circuits/${circuitName}.wasm`, + `./circuits/${circuitName}_pk.zkey` ); - - const { proof, publicSignals } = JSON.parse(result); - return { proof, publicSignals }; + + return compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); } ``` diff --git a/docs/witness-formats.md b/docs/witness-formats.md index 0ae918f..9c51bf8 100644 --- a/docs/witness-formats.md +++ b/docs/witness-formats.md @@ -1,30 +1,76 @@ -# Witness Formats Guide +# Input Formats Guide -This document explains the different witness formats supported by groth16-proofs and when to use each one. +This document explains the different input formats accepted by groth16-proofs functions and when to use each one. ## Overview -**groth16-proofs** uses decimal witness format for WASM proof generation: +The library exposes two conceptually different entry points: -1. **Decimal Format** (Recommended) - Native snarkjs output -2. **Hex Little-Endian Format** - Utility conversion format for low-level Rust helpers +| Entry point | Input | Output | Use case | +|-------------|-------|--------|----------| +| `compress_snarkjs_proof_wasm` | snarkjs proof JSON `{pi_a, pi_b, pi_c}` | 128-byte compressed proof | Browser / TS stack ✅ | +| `generate_proof_from_decimal_wasm` | Decimal witness array + `.ark` key | 128-byte compressed proof | WASM with server-side `.ark` | +| `generate_proof_from_witness` (Rust/CLI) | Hex LE witness array + `.ark` path | 128-byte compressed proof | Rust-native / CLI | + +> **Important**: `compress_snarkjs_proof_wasm` does **not** receive a witness — it receives a proof that snarkjs already generated. The witness is consumed internally by `snarkjs.groth16.fullProve()`. + +--- ## Format Comparison +### Witness formats (for `generate_proof_from_decimal_wasm` and `generate_proof_from_witness` CLI) + | Aspect | Decimal Format | Hex LE Format | |--------|----------------|---------------| -| **Source** | snarkjs native | Custom conversion | +| **Source** | snarkjs witness export | Custom / Rust internal | | **Example** | `"12345"` | `"0x3930000000...00"` | | **Conversion needed** | ❌ No | ✅ Yes | | **Function (Rust)** | `decimal_to_field()` | `hex_to_field()` | | **Function (WASM)** | `generate_proof_from_decimal_wasm()` | N/A | -| **Use case** | Modern integrations | Legacy code | +| **Use case** | WASM witness-based flow | Rust CLI (`generate-proof-from-witness`) | -## 1. Decimal Format (Recommended ✅) +## 1. snarkjs Proof Format (for `compress_snarkjs_proof_wasm` — Primary ✅) ### What is it? -The **native format** used by snarkjs when exporting witness. Each field element is represented as a decimal string. +The JSON output of `snarkjs.groth16.fullProve()` containing elliptic curve points `pi_a`, `pi_b`, `pi_c`. This is what the primary Orbinum TypeScript flow sends to `compress_snarkjs_proof_wasm`. + +### Example + +```json +{ + "pi_a": ["123456...789", "987654...321", "1"], + "pi_b": [ + ["111...", "222..."], + ["333...", "444..."] + ["1", "0"] + ], + "pi_c": ["555...", "666...", "1"] +} +``` + +### How to use + +```typescript +import init, { compress_snarkjs_proof_wasm } from '@orbinum/groth16-proofs'; +import * as snarkjs from 'snarkjs'; + +await init(WASM_CDN); + +const { proof: snarkjsProof } = await snarkjs.groth16.fullProve( + inputs, + circuitWasmUrl, + circuitZkeyUrl +); + +// snarkjsProof already has the right format — pass directly +const compressedProof = compress_snarkjs_proof_wasm(JSON.stringify(snarkjsProof)); +// => "0x..." (128 bytes on-chain format) +``` + +--- + +## 2. Decimal Witness Format (for `generate_proof_from_decimal_wasm`) ### Example @@ -39,51 +85,46 @@ The **native format** used by snarkjs when exporting witness. Each field element ### Why use it? -- ✅ **No conversion overhead**: Direct from snarkjs → groth16-proofs +- ✅ **No conversion overhead**: Direct from snarkjs witness export - ✅ **Simpler code**: Less data transformation - ✅ **Human-readable**: Easy to debug -- ✅ **Standard**: Works with any ZK toolkit ### How to use **WASM (JavaScript/TypeScript)**: ```typescript -import { generate_proof_from_decimal_wasm } from './wasm/groth16_proofs.js'; +import { generate_proof_from_decimal_wasm } from '@orbinum/groth16-proofs'; import * as snarkjs from 'snarkjs'; -// Step 1: Calculate witness +// Export witness as decimal array await snarkjs.wtns.calculate(inputs, 'circuit.wasm', 'witness.wtns'); - -// Step 2: Export as JSON (decimal format - native!) const witnessArray = await snarkjs.wtns.exportJson('witness.wtns'); -// Step 3: Generate proof (no conversion!) +// Load .ark proving key (Rust format — NOT .zkey) +const provingKey = new Uint8Array(await fetch('circuit_pk.ark').then(r => r.arrayBuffer())); + const resultJson = generate_proof_from_decimal_wasm( 5, // number of public signals - JSON.stringify(witnessArray), // Pass directly - provingKeyBytes + JSON.stringify(witnessArray), + provingKey ); +const { proof, publicSignals } = JSON.parse(resultJson); ``` **Rust**: ```rust use groth16_proofs::decimal_to_field; -// Convert individual decimal string to field element let field = decimal_to_field("12345")?; - -// Or convert entire witness array -let witness: Vec = decimal_strings - .iter() - .map(|s| decimal_to_field(s)) - .collect::, _>>()?; ``` -## 2. Hex Little-Endian Format (Utility / Rust) +--- + +## 3. Hex Little-Endian Format (for `generate_proof_from_witness` CLI) ### What is it? -A **custom format** where each field element is a 32-byte hex string in little-endian byte order. +A **32-byte hex string in little-endian order** used by the `generate-proof-from-witness` CLI and the Rust `generate_proof_from_witness()` function. The binary format matches arkworks' internal BN254 field element representation. ### Example @@ -126,10 +167,15 @@ Reverse bytes (LE): 0x3930000000...0000 ### How to use +**CLI**: +```bash +# witness.json is an array of hex LE strings +./target/release/generate-proof-from-witness witness.json proving_key.ark +``` + **WASM (JavaScript/TypeScript)**: -Use `generate_proof_from_decimal_wasm()` with decimal witness exported by snarkjs. -Hex LE is not accepted by the WASM proof-generation API. +Hex LE is not accepted by the WASM proof-generation API. Use decimal witness with `generate_proof_from_decimal_wasm`, or snarkjs proof JSON with `compress_snarkjs_proof_wasm`. **Rust**: ```rust @@ -138,6 +184,8 @@ use groth16_proofs::hex_to_field; let field = hex_to_field("0x0100...00")?; ``` +--- + ## Converting Between Formats ### Decimal → Hex LE (if needed) @@ -179,19 +227,26 @@ function hexLEToDecimal(hexLE: string): string { } ``` -## Which Format Should I Use? +## Which Input Should I Use? + +### Use `compress_snarkjs_proof_wasm` ✅ if: + +- You're in a browser / TypeScript app +- You use `snarkjs.groth16.fullProve()` (standard Orbinum flow) +- You have `pi_a`, `pi_b`, `pi_c` from snarkjs +- You want the 128-byte on-chain format -### Use Decimal Format ✅ if: +### Use `generate_proof_from_decimal_wasm` if: -- ✅ You're using snarkjs for witness calculation -- ✅ Starting a new integration -- ✅ Want simplest possible code -- ✅ Need human-readable values for debugging +- You have a pre-computed decimal witness array +- You have the `.ark` proving key available (server-hosted) +- You want to generate the proof directly (no snarkjs step) -### Use Hex LE Format if: +### Use `generate_proof_from_witness` CLI if: -- You need low-level conversion utilities in Rust -- You are working with internal field-level tooling +- You are on a Rust/server environment +- You have hex LE witness + `.ark` proving key file +- You want the fastest native proof generation ## Technical Details diff --git a/src/bin/bench_groth16.rs b/src/bin/bench_groth16.rs new file mode 100644 index 0000000..8a54ced --- /dev/null +++ b/src/bin/bench_groth16.rs @@ -0,0 +1,153 @@ +//! Groth16 prover benchmark. +//! +//! Reads a Circom `.wtns` witness file and an `.ark` proving key, then runs +//! proof generation N times and reports timing + proof size as JSON. +//! +//! Usage: +//! bench-groth16 [iterations=5] +//! +//! Output (JSON to stdout, progress to stderr): +//! { +//! "circuit": "disclosure", +//! "prover": "groth16", +//! "prove_ms_avg": 42.1, +//! "prove_ms_min": 40.8, +//! "proof_bytes": 128, +//! "num_witness": 1171, +//! "iterations": 5 +//! } + +use ark_bn254::{Bn254, Fr as Bn254Fr}; +use ark_ff::PrimeField; +use ark_groth16::{Groth16, ProvingKey}; +use ark_serialize::CanonicalDeserialize; +use ark_snark::SNARK; +use ark_std::rand::rngs::StdRng; +use ark_std::rand::SeedableRng; +use groth16_proofs::WitnessCircuit; +use std::time::Instant; + +// ── .wtns binary reader ────────────────────────────────────────────────────── + +fn read_u32_le(buf: &[u8], offset: &mut usize) -> u32 { + let v = u32::from_le_bytes(buf[*offset..*offset + 4].try_into().unwrap()); + *offset += 4; + v +} + +fn read_u64_le(buf: &[u8], offset: &mut usize) -> u64 { + let v = u64::from_le_bytes(buf[*offset..*offset + 8].try_into().unwrap()); + *offset += 8; + v +} + +/// Parse a Circom `.wtns` file into a `Vec`. +fn load_witness(path: &str) -> Vec { + let data = std::fs::read(path).unwrap_or_else(|e| panic!("Cannot read {path}: {e}")); + + assert_eq!(&data[0..4], b"wtns", "Not a .wtns file: {path}"); + let mut off = 4; + let _version = read_u32_le(&data, &mut off); + let section_count = read_u32_le(&data, &mut off); + + // Scan section table — we need section type 1 (header) and type 2 (data). + let mut header_off: Option = None; + let mut data_off: Option = None; + for _ in 0..section_count { + let stype = read_u32_le(&data, &mut off); + let ssize = read_u64_le(&data, &mut off) as usize; + let pos = off; + match stype { + 1 => header_off = Some(pos), + 2 => data_off = Some(pos), + _ => {} + } + off += ssize; + } + + // Header: field_size (u32) | prime (field_size bytes) | num_witness (u32) + let mut h = header_off.expect(".wtns missing header section"); + let field_size = read_u32_le(&data, &mut h) as usize; + h += field_size; // skip prime bytes + let num_witness = read_u32_le(&data, &mut h) as usize; + + // Data: num_witness * field_size bytes (little-endian field elements) + let mut d = data_off.expect(".wtns missing data section"); + let mut witness = Vec::with_capacity(num_witness); + for _ in 0..num_witness { + let bytes = &data[d..d + field_size]; + d += field_size; + // arkworks expects little-endian byte order, same as Circom stores them + witness.push(Bn254Fr::from_le_bytes_mod_order(bytes)); + } + witness +} + +// ── main ───────────────────────────────────────────────────────────────────── + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 4 { + eprintln!( + "Usage: bench-groth16 [iterations=5]" + ); + std::process::exit(1); + } + let circuit_name = &args[1]; + let witness_path = &args[2]; + let pk_path = &args[3]; + let iterations: u32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(5); + + eprintln!("Loading witness from {witness_path}..."); + let witness = load_witness(witness_path); + eprintln!(" {} field elements", witness.len()); + + eprintln!("Loading proving key from {pk_path}..."); + let pk_bytes = std::fs::read(pk_path).unwrap_or_else(|e| panic!("Cannot read {pk_path}: {e}")); + let pk = ProvingKey::::deserialize_compressed(&pk_bytes[..]) + .unwrap_or_else(|e| panic!("Failed to deserialize proving key: {e}")); + eprintln!(" Proving key loaded ({} KB)", pk_bytes.len() / 1024); + + let mut rng = StdRng::from_entropy(); + let mut times_ms: Vec = Vec::with_capacity(iterations as usize); + + eprintln!("\nRunning {iterations} iterations..."); + for i in 0..iterations { + let circuit = WitnessCircuit { + witness: witness.clone(), + }; + let t0 = Instant::now(); + let proof = Groth16::::prove(&pk, circuit, &mut rng) + .unwrap_or_else(|e| panic!("Proof generation failed: {e}")); + let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0; + times_ms.push(elapsed_ms); + eprintln!( + " iter {}/{}: {:.1}ms (proof {} bytes)", + i + 1, + iterations, + elapsed_ms, + { + use ark_serialize::CanonicalSerialize; + let mut buf = Vec::new(); + proof.serialize_compressed(&mut buf).unwrap(); + buf.len() + } + ); + } + + let avg = times_ms.iter().sum::() / times_ms.len() as f64; + let min = times_ms.iter().cloned().fold(f64::INFINITY, f64::min); + + println!( + concat!( + r#"{{"circuit":"{circuit}","prover":"groth16","#, + r#""prove_ms_avg":{avg:.1},"prove_ms_min":{min:.1},"#, + r#""proof_bytes":128,"num_witness":{nw},"iterations":{it}}}"# + ), + circuit = circuit_name, + avg = avg, + min = min, + nw = witness.len(), + it = iterations, + ); +} diff --git a/src/bin/convert_vk.rs b/src/bin/convert_vk.rs new file mode 100644 index 0000000..43491f4 --- /dev/null +++ b/src/bin/convert_vk.rs @@ -0,0 +1,277 @@ +//! Convert snarkjs Groth16 VK JSON to arkworks compressed binary. +//! +//! Usage: +//! convert_vk [output.bin] +//! +//! If output is omitted, replaces .json with .bin. +//! Outputs the byte count to stderr. + +use ark_bn254::{Bn254, Fq, Fq2, G1Affine, G2Affine}; +use ark_ff::PrimeField; +use ark_groth16::VerifyingKey; +use ark_serialize::CanonicalSerialize; +use num_bigint::BigUint; +use serde_json::Value; +use std::{env, fs, process}; + +fn parse_fq(s: &str) -> Fq { + let n = + BigUint::parse_bytes(s.as_bytes(), 10).unwrap_or_else(|| panic!("invalid Fq element: {s}")); + Fq::from_le_bytes_mod_order(&n.to_bytes_le()) +} + +/// Parse a G1 affine point from snarkjs projective form [x, y, z]. +/// For standard VK points z == "1" so affine == projective coordinates. +fn parse_g1(v: &Value) -> G1Affine { + let x = parse_fq(v[0].as_str().expect("G1 x must be string")); + let y = parse_fq(v[1].as_str().expect("G1 y must be string")); + G1Affine::new(x, y) +} + +/// Parse an Fq2 element from snarkjs array [c0, c1]. +fn parse_fq2(v: &Value) -> Fq2 { + let c0 = parse_fq(v[0].as_str().expect("Fq2 c0 must be string")); + let c1 = parse_fq(v[1].as_str().expect("Fq2 c1 must be string")); + Fq2::new(c0, c1) +} + +/// Parse a G2 affine point from snarkjs projective form [[x.c0,x.c1],[y.c0,y.c1],[z.c0,z.c1]]. +/// For standard VK points z == [1,0] so affine == projective coordinates. +fn parse_g2(v: &Value) -> G2Affine { + let x = parse_fq2(&v[0]); + let y = parse_fq2(&v[1]); + G2Affine::new(x, y) +} + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: convert_vk [output_vk.bin]"); + process::exit(1); + } + + let in_path = &args[1]; + let out_path = if args.len() >= 3 { + args[2].clone() + } else if let Some(stripped) = in_path.strip_suffix(".json") { + format!("{stripped}.bin") + } else { + format!("{in_path}.bin") + }; + + let json_str = + fs::read_to_string(in_path).unwrap_or_else(|e| panic!("cannot read {in_path}: {e}")); + + let json: Value = serde_json::from_str(&json_str) + .unwrap_or_else(|e| panic!("invalid JSON in {in_path}: {e}")); + + let alpha_g1 = parse_g1(&json["vk_alpha_1"]); + let beta_g2 = parse_g2(&json["vk_beta_2"]); + let gamma_g2 = parse_g2(&json["vk_gamma_2"]); + let delta_g2 = parse_g2(&json["vk_delta_2"]); + + let ic_arr = json["IC"] + .as_array() + .unwrap_or_else(|| panic!("missing IC field in {in_path}")); + let gamma_abc_g1: Vec = ic_arr.iter().map(parse_g1).collect(); + + let vk = VerifyingKey:: { + alpha_g1, + beta_g2, + gamma_g2, + delta_g2, + gamma_abc_g1, + }; + + let mut bytes = Vec::new(); + vk.serialize_compressed(&mut bytes) + .unwrap_or_else(|e| panic!("serialization failed: {e}")); + + fs::write(&out_path, &bytes).unwrap_or_else(|e| panic!("cannot write {out_path}: {e}")); + + eprintln!( + "Converted {} → {} ({} bytes JSON → {} bytes binary)", + in_path, + out_path, + json_str.len(), + bytes.len() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_bn254::{Bn254, G1Affine, G1Projective, G2Affine, G2Projective}; + use ark_ec::{CurveGroup, PrimeGroup}; + use ark_ff::BigInteger; + use ark_groth16::VerifyingKey; + use ark_serialize::CanonicalSerialize; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn fq_to_decimal(f: Fq) -> String { + let bytes_le = f.into_bigint().to_bytes_le(); + BigUint::from_bytes_le(&bytes_le).to_str_radix(10) + } + + fn g1_gen_json() -> Value { + let g: G1Affine = G1Projective::generator().into_affine(); + serde_json::json!([fq_to_decimal(g.x), fq_to_decimal(g.y), "1"]) + } + + fn g2_gen_json() -> Value { + let g: G2Affine = G2Projective::generator().into_affine(); + serde_json::json!([ + [fq_to_decimal(g.x.c0), fq_to_decimal(g.x.c1)], + [fq_to_decimal(g.y.c0), fq_to_decimal(g.y.c1)], + ["1", "0"] + ]) + } + + /// Build a minimal but structurally valid snarkjs VK JSON with `num_ic` IC points. + fn build_vk_json(num_ic: usize) -> Value { + let ic: Vec = (0..num_ic).map(|_| g1_gen_json()).collect(); + serde_json::json!({ + "vk_alpha_1": g1_gen_json(), + "vk_beta_2": g2_gen_json(), + "vk_gamma_2": g2_gen_json(), + "vk_delta_2": g2_gen_json(), + "IC": ic + }) + } + + fn vk_from_json(json: &Value) -> VerifyingKey { + let ic_arr = json["IC"].as_array().unwrap(); + VerifyingKey:: { + alpha_g1: parse_g1(&json["vk_alpha_1"]), + beta_g2: parse_g2(&json["vk_beta_2"]), + gamma_g2: parse_g2(&json["vk_gamma_2"]), + delta_g2: parse_g2(&json["vk_delta_2"]), + gamma_abc_g1: ic_arr.iter().map(parse_g1).collect(), + } + } + + // ── parse_fq ───────────────────────────────────────────────────────────── + + #[test] + fn test_parse_fq_zero() { + assert_eq!(parse_fq("0"), Fq::from(0u64)); + } + + #[test] + fn test_parse_fq_one() { + assert_eq!(parse_fq("1"), Fq::from(1u64)); + } + + #[test] + fn test_parse_fq_large_value() { + // 2^64 — larger than u64, must survive mod-order reduction + let big = "18446744073709551616"; + let result = std::panic::catch_unwind(|| parse_fq(big)); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_fq_invalid_panics() { + let result = std::panic::catch_unwind(|| parse_fq("not-a-number")); + assert!(result.is_err()); + } + + // ── parse_g1 ───────────────────────────────────────────────────────────── + + #[test] + fn test_parse_g1_roundtrip_generator() { + let original: G1Affine = G1Projective::generator().into_affine(); + let json = g1_gen_json(); + let parsed = parse_g1(&json); + assert_eq!(parsed, original); + } + + // ── parse_g2 ───────────────────────────────────────────────────────────── + + #[test] + fn test_parse_g2_roundtrip_generator() { + let original: G2Affine = G2Projective::generator().into_affine(); + let json = g2_gen_json(); + let parsed = parse_g2(&json); + assert_eq!(parsed, original); + } + + // ── serialized VK size ──────────────────────────────────────────────────── + // + // Compressed sizes on BN254: + // G1 affine = 32 bytes + // G2 affine = 64 bytes + // Vec header (u64 len) = 8 bytes + // + // Total = 32 + 64*3 + 8 + n*32 = 232 + n*32 + + #[test] + fn test_vk_serialized_size_one_ic() { + let vk = vk_from_json(&build_vk_json(1)); + let mut bytes = Vec::new(); + vk.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 264); // 232 + 1*32 + } + + #[test] + fn test_vk_serialized_size_six_ic() { + // 6 IC elements = 5 public signals + 1 (matches the unshield circuit) + let vk = vk_from_json(&build_vk_json(6)); + let mut bytes = Vec::new(); + vk.serialize_compressed(&mut bytes).unwrap(); + assert_eq!(bytes.len(), 424); // 232 + 6*32 + } + + // ── file round-trip ─────────────────────────────────────────────────────── + + #[test] + fn test_file_roundtrip_writes_correct_binary() { + let json = build_vk_json(6); + let json_str = json.to_string(); + + let in_path = "/tmp/test_convert_vk_input.json"; + let out_path = "/tmp/test_convert_vk_output.bin"; + std::fs::write(in_path, &json_str).unwrap(); + + // Run the same conversion logic as main() + let parsed: Value = serde_json::from_str(&json_str).unwrap(); + let vk = vk_from_json(&parsed); + let mut bytes = Vec::new(); + vk.serialize_compressed(&mut bytes).unwrap(); + std::fs::write(out_path, &bytes).unwrap(); + + // Read back and verify + let read_back = std::fs::read(out_path).unwrap(); + assert_eq!(read_back, bytes); + assert_eq!(read_back.len(), 424); + + let _ = std::fs::remove_file(in_path); + let _ = std::fs::remove_file(out_path); + } + + // ── output path derivation ──────────────────────────────────────────────── + + #[test] + fn test_output_path_strips_json_suffix() { + // Replicate the out_path logic from main() + let in_path = "artifacts/verification_key_unshield.json".to_string(); + let out_path = if let Some(stripped) = in_path.strip_suffix(".json") { + format!("{stripped}.bin") + } else { + format!("{in_path}.bin") + }; + assert_eq!(out_path, "artifacts/verification_key_unshield.bin"); + } + + #[test] + fn test_output_path_no_json_suffix_appends_bin() { + let in_path = "mykey".to_string(); + let out_path = if let Some(stripped) = in_path.strip_suffix(".json") { + format!("{stripped}.bin") + } else { + format!("{in_path}.bin") + }; + assert_eq!(out_path, "mykey.bin"); + } +} diff --git a/src/proof.rs b/src/proof.rs index 2c4f817..cd8c7fa 100644 --- a/src/proof.rs +++ b/src/proof.rs @@ -107,4 +107,27 @@ mod tests { assert_eq!(EXPECTED_COMPRESSED_PROOF_SIZE, 128); } + + #[test] + fn test_generate_proof_invalid_hex_in_witness() { + use std::io::Write; + + // Create a valid (but trivially wrong) proving key file so the read succeeds + // and we reach the hex-parsing step. + let temp_file = "/tmp/dummy_pk_for_hex_test.ark"; + { + let mut f = std::fs::File::create(temp_file).unwrap(); + f.write_all(b"dummy").unwrap(); + } + + let witness_hex = vec!["0xGGGGGGGG".to_string()]; + let result = generate_proof_from_witness(&witness_hex, temp_file); + + let _ = std::fs::remove_file(temp_file); + + // The hex decode happens before the key is read in the current impl, + // so this returns a hex-parse error OR a key-deserialize error. + // Either way it must be an error. + assert!(result.is_err()); + } } diff --git a/src/utils.rs b/src/utils.rs index 009c428..fd54ecc 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -160,4 +160,32 @@ mod tests { assert_eq!(witness[1], Bn254Fr::from(2u64)); assert_eq!(witness[2], Bn254Fr::from(3u64)); } + + #[test] + fn test_decimal_hex_consistency() { + // decimal_to_field and hex_to_field must produce the same field element + // for the same numeric value. + let decimal = decimal_to_field("12345").unwrap(); + let hex = + hex_to_field("0x3930000000000000000000000000000000000000000000000000000000000000") + .unwrap(); + assert_eq!(decimal, hex); + } + + #[test] + fn test_decimal_to_field_empty_string() { + let result = decimal_to_field(""); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Failed to parse decimal string")); + } + + #[test] + fn test_decimal_to_field_leading_zeros() { + // "0001" should parse the same as "1" + let a = decimal_to_field("0001").unwrap(); + let b = decimal_to_field("1").unwrap(); + assert_eq!(a, b); + } } diff --git a/src/wasm.rs b/src/wasm.rs index 814fe07..02d8e09 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -122,118 +122,91 @@ pub fn generate_proof_from_decimal_wasm( // WASM module tests // -// Note: These tests use conditional compilation to avoid JsValue issues in native test runner. -// They validate the underlying logic (JSON parsing, data conversion, validation) without -// calling generate_proof_from_decimal_wasm() directly. +// Note: `generate_proof_from_decimal_wasm` returns `Result`. +// JsValue is not available in the native test runner, so the function itself +// cannot be called directly in these tests. The tests below validate all the +// logic that surrounds it: JSON parsing, field conversion, bounds validation, +// and output format — by calling the underlying helpers directly. // -// For complete testing instructions, see: docs/testing.md +// For end-to-end WASM tests use `wasm-pack test --headless`. #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; + use ark_ff::BigInteger; - // Helper to create valid witness JSON - fn create_witness_json(count: usize) -> String { - let witness: Vec = (0..count) - .map(|i| { - let value = (i + 1) as u64; - let mut bytes = vec![0u8; 32]; - bytes[0] = (value & 0xFF) as u8; - bytes[1] = ((value >> 8) & 0xFF) as u8; - format!("0x{}", hex::encode(&bytes)) - }) - .collect(); - serde_json::to_string(&witness).unwrap() - } - - #[test] - fn test_public_signals_validation() { - // Test that we can specify any number of public signals - let test_cases = vec![ - (5, true), // Valid: 5 signals - (4, true), // Valid: 4 signals - (10, true), // Valid: 10 signals - (0, false), // Invalid: 0 signals - ]; - - for (num_signals, should_be_valid) in test_cases { - // This test validates the concept, not the actual function call - let is_valid = num_signals > 0; - assert_eq!( - is_valid, should_be_valid, - "num_public_signals={} validity check failed", - num_signals - ); - } - } + // ── witness JSON parsing ────────────────────────────────────────────────── #[test] - fn test_witness_json_parsing() { + fn test_witness_hex_json_parsed_correctly() { let witness_json = r#"[ "0x0100000000000000000000000000000000000000000000000000000000000000", "0x0200000000000000000000000000000000000000000000000000000000000000" ]"#; - - let witness_strings: Result, _> = serde_json::from_str(witness_json); - - assert!(witness_strings.is_ok()); - let witness_strings = witness_strings.unwrap(); - assert_eq!(witness_strings.len(), 2); + let strings: Vec = serde_json::from_str(witness_json).unwrap(); + assert_eq!(strings.len(), 2); + assert!(strings[0].starts_with("0x")); } #[test] - fn test_witness_decimal_to_field_conversion() { - let witness_json = r#"[ - "1", - "5" - ]"#; - - let witness_strings: Vec = serde_json::from_str(witness_json).unwrap(); - let witness_result: Result, String> = witness_strings + fn test_witness_decimal_json_parsed_and_converted() { + let witness_json = r#"["1", "5", "255"]"#; + let strings: Vec = serde_json::from_str(witness_json).unwrap(); + let fields: Vec = strings .iter() - .map(|s| decimal_to_field(s)) + .map(|s| decimal_to_field(s).unwrap()) .collect(); + assert_eq!(fields[0], Bn254Fr::from(1u64)); + assert_eq!(fields[1], Bn254Fr::from(5u64)); + assert_eq!(fields[2], Bn254Fr::from(255u64)); + } - assert!(witness_result.is_ok()); - let witness = witness_result.unwrap(); - assert_eq!(witness.len(), 2); - assert_eq!(witness[0], Bn254Fr::from(1u64)); - assert_eq!(witness[1], Bn254Fr::from(5u64)); + #[test] + fn test_witness_json_invalid_is_error() { + let bad_json = "not-json[[["; + let result: Result, _> = serde_json::from_str(bad_json); + assert!(result.is_err()); } + // ── num_public_signals validation ───────────────────────────────────────── + #[test] - fn test_proof_output_format() { - let output = serde_json::json!({ - "proof": "0xabcd1234", - "publicSignals": ["0x01", "0x02"] - }); + fn test_num_public_signals_zero_is_invalid() { + // Replicates the guard inside generate_proof_from_decimal_wasm + let num_public_signals: usize = 0; + assert!(num_public_signals == 0, "should be caught as invalid"); + } - let json_string = serde_json::to_string(&output).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json_string).unwrap(); + #[test] + fn test_num_public_signals_equal_to_witness_len_is_invalid() { + let witness_len = 10usize; + let num_public_signals = 10usize; + // Guard: num_public_signals >= witness.len() + assert!(num_public_signals >= witness_len); + } - assert!(parsed.get("proof").is_some()); - assert!(parsed.get("publicSignals").is_some()); - assert!(parsed["publicSignals"].is_array()); - assert_eq!(parsed["publicSignals"].as_array().unwrap().len(), 2); + #[test] + fn test_num_public_signals_within_bounds_is_valid() { + let witness_len = 100usize; + let num_public_signals = 5usize; + assert!(num_public_signals > 0); + assert!(num_public_signals < witness_len); } + // ── public signals extraction ───────────────────────────────────────────── + #[test] - fn test_public_signals_extraction_bounds() { + fn test_public_signals_extracted_from_correct_indices() { + // witness[0] is always 1 (constant), public signals start at index 1 let witness = [ - Bn254Fr::from(1u64), // index 0 - Bn254Fr::from(10u64), // index 1 (first public) - Bn254Fr::from(20u64), // index 2 - Bn254Fr::from(30u64), // index 3 - Bn254Fr::from(40u64), // index 4 - Bn254Fr::from(50u64), // index 5 (last public for unshield) - Bn254Fr::from(60u64), // index 6 (private) + Bn254Fr::from(1u64), // [0] constant + Bn254Fr::from(10u64), // [1] public + Bn254Fr::from(20u64), // [2] public + Bn254Fr::from(30u64), // [3] public (last public for num=3) + Bn254Fr::from(40u64), // [4] private ]; - - let num_public_signals = 5; - - let public_signals: Vec<_> = witness[..] - .get(1..=num_public_signals) - .unwrap_or(&[]) + let num_public_signals = 3; + let public_signals: Vec = witness[1..=num_public_signals] .iter() .map(|f| { let mut bytes = f.into_bigint().to_bytes_le(); @@ -242,37 +215,31 @@ mod tests { }) .collect(); - assert_eq!(public_signals.len(), 5); - // Verify we extract the correct indices (1-5) - assert!(public_signals[0].starts_with("0x0a")); // 10 in hex - assert!(public_signals[4].starts_with("0x32")); // 50 in hex + assert_eq!(public_signals.len(), 3); + assert!(public_signals[0].starts_with("0x0a")); // 10 = 0x0a + assert!(public_signals[2].starts_with("0x1e")); // 30 = 0x1e } #[test] - fn test_public_signals_bounds_validation() { - // Test boundary conditions for num_public_signals - let witness_len = 100; - - // Valid cases - assert!(1 < witness_len, "Valid: 1 signal with witness of 100"); - assert!(50 < witness_len, "Valid: 50 signals with witness of 100"); - - // Invalid cases - let num_signals = 0; - assert_eq!(num_signals, 0, "Invalid: 0 signals"); - - let num_signals = 101; - assert!( - num_signals > witness_len, - "Invalid: signals exceed witness length" - ); + fn test_public_signals_are_32_byte_hex() { + let f = Bn254Fr::from(42u64); + let mut bytes = f.into_bigint().to_bytes_le(); + bytes.resize(32, 0u8); + let hex = format!("0x{}", hex::encode(&bytes)); + // 2 ("0x") + 64 (32 bytes * 2 hex chars) = 66 chars + assert_eq!(hex.len(), 66); } + // ── output JSON format ───────────────────────────────────────────────────── + #[test] - fn test_create_witness_json_helper() { - let witness_json = create_witness_json(3); - let witness_array: Vec = serde_json::from_str(&witness_json).unwrap(); - assert_eq!(witness_array.len(), 3); - assert!(witness_array[0].starts_with("0x")); + fn test_output_json_has_required_fields() { + let output = serde_json::json!({ + "proof": "0xabcd", + "publicSignals": ["0x01", "0x02"] + }); + assert!(output.get("proof").is_some()); + let signals = output["publicSignals"].as_array().unwrap(); + assert_eq!(signals.len(), 2); } } diff --git a/src/wasm/snarkjs_proof.rs b/src/wasm/snarkjs_proof.rs index 2f960d4..c9b9d29 100644 --- a/src/wasm/snarkjs_proof.rs +++ b/src/wasm/snarkjs_proof.rs @@ -89,7 +89,8 @@ pub fn compress_snarkjs_proof_wasm(proof_json: &str) -> Result #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; - use ark_ec::AffineRepr; + use ark_bn254::{G1Projective, G2Projective}; + use ark_ec::{CurveGroup, PrimeGroup}; use ark_ff::BigInteger; fn fq_to_decimal_string(value: Fq) -> String { @@ -105,9 +106,9 @@ mod tests { } fn build_valid_snarkjs_proof_json() -> String { - let a = G1Affine::generator(); - let b = G2Affine::generator(); - let c = G1Affine::generator(); + let a = G1Projective::generator().into_affine(); + let b = G2Projective::generator().into_affine(); + let c = G1Projective::generator().into_affine(); serde_json::json!({ "pi_a": [ @@ -191,4 +192,49 @@ mod tests { .unwrap_err() .contains("Invalid snarkjs proof structure")); } + + #[test] + fn test_serde_json_rejects_malformed_proof_input() { + // Calling compress_snarkjs_proof_wasm with invalid JSON cannot be done + // in native tests because the Err(JsValue) return path from a #[wasm_bindgen] + // function triggers a non-unwinding abort via wasm-bindgen internals. + // Instead, validate the JSON parsing step directly. + let result: Result = serde_json::from_str("not valid json at all {{{{"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_snarkjs_proof_pi_b_no_rows() { + let parsed = SnarkjsProof { + pi_a: vec!["1".to_string(), "2".to_string()], + pi_b: vec![], + pi_c: vec!["1".to_string(), "2".to_string()], + }; + let result = validate_snarkjs_proof_structure(&parsed); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("pi_b must be a 2x2 matrix")); + } + + #[test] + fn test_validate_snarkjs_proof_pi_b_short_rows() { + let parsed = SnarkjsProof { + pi_a: vec!["1".to_string(), "2".to_string()], + pi_b: vec![ + vec!["1".to_string()], // only 1 element, needs 2 + vec!["3".to_string(), "4".to_string()], + ], + pi_c: vec!["1".to_string(), "2".to_string()], + }; + let result = validate_snarkjs_proof_structure(&parsed); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("pi_b must be a 2x2 matrix")); + } + + #[test] + fn test_compressed_proof_is_128_bytes() { + let proof_json = build_valid_snarkjs_proof_json(); + let compressed_hex = compress_snarkjs_proof_wasm(&proof_json).unwrap(); + // "0x" prefix + 256 hex chars = 128 bytes + assert_eq!(compressed_hex.len(), 2 + 256); + } }