diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d0bc2..43a8fd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main, develop] pull_request: branches: [main, develop] diff --git a/CHANGELOG.md b/CHANGELOG.md index de4ec6d..6a1ec61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,70 @@ 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). +## [3.0.0](https://github.com/orbinum/groth16-proofs/releases/tag/v3.0.0) - 2026-04-08 + +### Added + +- `ProofError` — unified error enum replacing `String` errors throughout the crate. + Variants: `WitnessEmpty`, `WitnessConversion`, `ProvingKeyIo`, `ProvingKeyParse`, + `ProveGeneration`, `ProofSerialization`, `NumPublicSignals`, `WitnessJsonParse`, + `SnarkjsProofParse`. +- `from_decimal_str::()` — generic `PrimeField` parser for decimal strings, + replaces the `Bn254Fr`-only `decimal_to_field()` with a type-parameterized version + usable for any field (`Fr`, `Fq`, etc.). +- `from_hex_le::()` — generic `PrimeField` parser for little-endian hex strings, + replaces the `Bn254Fr`-only `hex_to_field()`. +- `prove_from_witness()` — core prover function shared by the native and WASM paths. + Accepts already-loaded `pk_bytes` and a converted witness; eliminates code duplication + that existed between `proof.rs` and `wasm.rs`. +- `compress_snarkjs_proof()` — native (non-WASM) snarkjs proof compression, available + for server-side Rust code (previously only exposed as `compress_snarkjs_proof_wasm`). +- New `src/codec.rs` module: snarkjs JSON → arkworks compressed bytes, decoupled from + the `wasm` feature. +- New `src/prover.rs` module: core Groth16 prove logic with input validation. +- New `src/field.rs` module: generic field element parsers. +- New `src/error.rs` module: `ProofError` type. +- `decimal_to_field()` and `hex_to_field()` remain as backward-compat shims in + `src/utils.rs` (thin wrappers over the new generic functions). + +### Changed + +- **BREAKING**: `generate_proof_from_witness()` signature changed — now requires an + explicit `num_public_signals: usize` third argument. + ```rust + // Before (2.x) + generate_proof_from_witness(&witness_hex, "key.ark") + // After (3.0) + generate_proof_from_witness(&witness_hex, "key.ark", 5) + ``` +- **BREAKING**: `generate_proof_from_witness()` now returns `Result, ProofError>` + instead of `Result, String>`. +- **BREAKING**: `prove_from_witness()` (new public function) also returns `ProofError`; + callers that previously matched on `String` errors must switch to `ProofError` variants. +- `WitnessCircuit` struct: `num_public_signals` is now an explicit field instead of + being computed from a heuristic `(witness.len() / 100).clamp(1, 10)` — which produced + wrong public signal counts for `disclosure` (got 1, expected 4) and `transfer` + (got 10, expected 5). +- `src/wasm.rs` rewritten as a thin wrapper using `prove_from_witness()`; no duplicated + prove logic. +- `src/wasm/snarkjs_proof.rs` rewritten as a 9-line WASM binding delegating to + `codec::compress_snarkjs_proof()`. +- `bench-groth16` binary: accepts optional `[num_public=5]` sixth argument. +- `generate-proof-from-witness` binary: `num_public_signals` derived from CLI arg, + JSON field, or default — no longer heuristic. +- Removed stale `#!/usr/bin/env rust` shebang from `generate-proof-from-witness` source. +- Removed trivial tautological test (`assert_eq!(128, 128)`) in `proof.rs`. +- Docs updated: `installation.md`, `usage.md`, `witness-formats.md` reflect new API, + `ProofError` variants, generic `from_decimal_str`/`from_hex_le`, and correct + `num_public_signals` semantics. + +### Fixed + +- **Bug**: `WitnessCircuit::generate_constraints` used `(witness.len() / 100).clamp(1,10)` + as a heuristic for `num_public_signals`. For `disclosure` (4 public signals, ~1171 + witness elements) this produced 1; for `transfer` (5 public signals, ~11,808 elements) + this produced 10. Fixed by requiring callers to pass the exact value. + ## [2.1.0](https://github.com/orbinum/groth16-proofs/releases/tag/v2.1.0) - 2026-04-07 ### Added @@ -73,7 +137,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Makefile with development commands - CHANGELOG following Keep a Changelog format -[Unreleased]: https://github.com/orbinum/groth16-proofs/compare/v2.0.0...HEAD +[Unreleased]: https://github.com/orbinum/groth16-proofs/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/orbinum/groth16-proofs/releases/tag/v3.0.0 +[2.1.0]: https://github.com/orbinum/groth16-proofs/releases/tag/v2.1.0 [2.0.0]: https://github.com/orbinum/groth16-proofs/releases/tag/v2.0.0 [1.0.0]: https://github.com/orbinum/groth16-proofs/releases/tag/v1.0.0 [0.1.0]: https://github.com/orbinum/groth16-proofs/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index c1712f2..06511e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "groth16-proofs" -version = "2.1.0" +version = "3.0.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" diff --git a/README.md b/README.md index 8edd05b..42c85e9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npm install @orbinum/groth16-proofs **Rust** (Native binary): ```toml [dependencies] -groth16-proofs = "2.0" +groth16-proofs = "2.2" ``` ### Use @@ -63,7 +63,7 @@ let proof = generate_proof_from_witness(&witness, "proving_key.ark")?; This crate generates **128-byte compressed Groth16 proofs** from witness data using the arkworks library. It supports: -- **Performance**: ~5-8 seconds per proof +- **Performance**: ~1.6–1.8s WASM (small circuits, post-warmup); native Rust faster - **Curves**: BN254 (Ethereum-compatible) - **Targets**: Native (Rust) + WebAssembly - **Circuits**: Unshield, Transfer, Disclosure @@ -115,7 +115,7 @@ canonical compressed proof bytes (`0x...`, 128 bytes). ## Features ✅ **Multiple Targets**: Native + WASM -✅ **Fast**: 5-8 second proof generation +✅ **Fast**: ~1.6–1.8s WASM (post-warmup); native Rust faster ✅ **WASM Decimal-Only API**: Direct snarkjs witness input ✅ **Type-Safe**: Memory-safe cryptography ✅ **Well-Tested**: 21+ tests included @@ -143,18 +143,6 @@ See [Makefile](./Makefile) for all available targets. - [**Witness Formats**](./docs/witness-formats.md) - Decimal witness flow and format notes - [**Release Process**](./docs/release.md) - How releases are managed -## Performance - -| Metric | Value | -|--------|-------| -| Proof Size | 128 bytes (compressed) | -| Generation Time | 5-8 seconds | -| Curve | BN254 | -| WASM Bundle | 3-5 MB | -| Native Binary | 10-15 MB | - -**Native is 20-30% faster than WASM**. Choose based on your deployment target. - ## Publishing This crate is published to both registries: diff --git a/docs/installation.md b/docs/installation.md index 26cd93e..ba9a74a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,7 +16,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -groth16-proofs = "2.0" +groth16-proofs = "3.0" ``` Then import in your code: @@ -24,7 +24,7 @@ Then import in your code: ```rust use groth16_proofs::generate_proof_from_witness; -let proof = generate_proof_from_witness(&witness, "proving_key.ark")?; +let proof = generate_proof_from_witness(&witness, "proving_key.ark", 5)?;; ``` ### As a WASM Module @@ -143,7 +143,7 @@ Once installed, see the **[Usage Guide](./usage.md)** for: ```rust // Rust — native proof generation use groth16_proofs::generate_proof_from_witness; -let proof = generate_proof_from_witness(&witness_hex, "key.ark")?; +let proof = generate_proof_from_witness(&witness_hex, "key.ark", 5)?; ``` ```bash @@ -169,12 +169,12 @@ The crate supports feature-based compilation: wasm = ["wasm-bindgen", "console_error_panic_hook"] ``` -**Build without WASM support**: +**Build without WASM support** (default — `features = []`): ```bash -cargo build --release --no-default-features +cargo build --release ``` -**Build with WASM support** (default): +**Build with WASM support**: ```bash cargo build --release --features wasm ``` diff --git a/docs/usage.md b/docs/usage.md index 035ec49..0ae9c4a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,16 +73,18 @@ Generate a Groth16 proof from witness data. pub fn generate_proof_from_witness( witness_hex: &[String], proving_key_path: &str, -) -> Result, String> + num_public_signals: usize, +) -> Result, ProofError> ``` **Arguments**: -- `witness_hex`: Array of hex-encoded field elements (little-endian), typically 11,808 elements +- `witness_hex`: Array of hex-encoded field elements (little-endian, 32 bytes each) - `proving_key_path`: Path to the `.ark` proving key file +- `num_public_signals`: Number of public signals (must be 1–(witness\_len-1)) **Returns**: - `Ok(Vec)`: 128-byte compressed Groth16 proof -- `Err(String)`: Error message +- `Err(ProofError)`: Typed error — see [`ProofError`](#proofError) **Example**: ```rust @@ -95,7 +97,7 @@ fn main() -> Result<(), Box> { // ... 11,806 more elements ]; - let proof = generate_proof_from_witness(&witness, "circuits/my_circuit_pk.ark")?; + let proof = generate_proof_from_witness(&witness, "circuits/my_circuit_pk.ark", 5)?; println!("Proof: 0x{}", hex::encode(&proof)); Ok(()) @@ -106,6 +108,8 @@ fn main() -> Result<(), Box> { Convert a decimal string (snarkjs format) to a BN254 field element. +> **Backward-compat shim** for `from_decimal_str::()`. Prefer `from_decimal_str` for new generic code. + **Signature**: ```rust pub fn decimal_to_field(decimal_str: &str) -> Result @@ -126,6 +130,8 @@ assert_eq!(field_element, Bn254Fr::from(12345u64)); Convert a hex string (little-endian) to a BN254 field element. +> **Backward-compat shim** for `from_hex_le::()`. Prefer `from_hex_le` for new generic code. + **Signature**: ```rust pub fn hex_to_field(hex_str: &str) -> Result @@ -143,6 +149,99 @@ let field_element = hex_to_field("0x01000000000000000000000000000000000000000000 assert_eq!(field_element, Bn254Fr::from(1u64)); ``` +### `from_decimal_str()` + +Generic decimal string parser for any `PrimeField` element. Underlying function used by `decimal_to_field`. + +**Signature**: +```rust +pub fn from_decimal_str(s: &str) -> Result +``` + +**Example**: +```rust +use groth16_proofs::from_decimal_str; +use ark_bn254::{Fr as Bn254Fr, Fq}; + +let fr: Bn254Fr = from_decimal_str("12345").unwrap(); +let fq: Fq = from_decimal_str("12345").unwrap(); // same string, different field +``` + +### `from_hex_le()` + +Generic little-endian hex parser for any `PrimeField` element. Underlying function used by `hex_to_field`. + +**Signature**: +```rust +pub fn from_hex_le(hex: &str) -> Result +``` + +**Example**: +```rust +use groth16_proofs::from_hex_le; +use ark_bn254::Fr as Bn254Fr; + +let val: Bn254Fr = from_hex_le("0x0100000000000000000000000000000000000000000000000000000000000000").unwrap(); +``` + +### `prove_from_witness()` + +Core prover shared by the native and WASM paths. Use this when you have already loaded the proving key bytes and converted the witness to field elements. + +**Signature**: +```rust +pub fn prove_from_witness( + pk_bytes: &[u8], + witness: Vec, + num_public_signals: usize, +) -> Result, ProofError> +``` + +**Example**: +```rust +use groth16_proofs::{prove_from_witness, from_hex_le}; +use ark_bn254::Fr as Bn254Fr; + +let pk_bytes = std::fs::read("circuit_pk.ark").unwrap(); +let witness: Vec = hex_strings.iter() + .map(|h| from_hex_le(h).unwrap()) + .collect(); +let proof_bytes = prove_from_witness(&pk_bytes, witness, 5).unwrap(); +``` + +### `compress_snarkjs_proof()` + +Native (non-WASM) version of the snarkjs compression function. Available in server-side Rust code. + +**Signature**: +```rust +pub fn compress_snarkjs_proof(proof_json: &str) -> Result, ProofError> +``` + +**Example**: +```rust +use groth16_proofs::compress_snarkjs_proof; + +let proof_bytes = compress_snarkjs_proof(&snarkjs_proof_json_string).unwrap(); +// proof_bytes.len() == 128 +``` + +### `ProofError` + +Unified error type returned by all Rust proof functions. + +| Variant | Description | +|---------|-------------| +| `WitnessEmpty` | The witness vector is empty | +| `WitnessConversion(String)` | Failed to convert a witness element | +| `ProvingKeyIo(String)` | Failed to read the `.ark` file | +| `ProvingKeyParse(String)` | Failed to deserialize the proving key | +| `ProveGeneration(String)` | arkworks proof generation failed | +| `ProofSerialization(String)` | Failed to serialize the proof | +| `NumPublicSignals(String)` | Invalid `num_public_signals` value | +| `WitnessJsonParse(String)` | Failed to parse witness JSON | +| `SnarkjsProofParse(String)` | Failed to parse snarkjs proof JSON | + ## WASM JavaScript API ### Initialization @@ -233,42 +332,6 @@ function generate_proof_from_decimal_wasm( ``` ``` -### `compress_snarkjs_proof_wasm()` - Interoperability - -Convert a proof generated by snarkjs (`pi_a`, `pi_b`, `pi_c`) into arkworks -canonical compressed bytes (same format expected by runtime integrations). - -**Signature**: -```typescript -function compress_snarkjs_proof_wasm( - proofJson: string // snarkjs proof as JSON string -): string // 0x-prefixed compressed proof -``` - -**Parameters**: - -| Parameter | Type | Description | -|-----------|------|-------------| -| `proofJson` | string | JSON string with `pi_a`, `pi_b`, `pi_c` decimal coordinates | - -**Returns**: Hex string (`0x...`) of compressed Groth16 proof (128 bytes) - -**Example**: -```typescript -import { compress_snarkjs_proof_wasm } from './wasm/groth16_proofs.js'; - -const snarkjsProofJson = JSON.stringify({ - pi_a: ["...", "...", "1"], - pi_b: [["...", "..."], ["...", "..."], ["1", "0"]], - pi_c: ["...", "...", "1"], -}); - -const compressedProof = compress_snarkjs_proof_wasm(snarkjsProofJson); -// compressedProof => "0x..." (128-byte arkworks canonical proof) -``` - -> Note: Internally, this interoperability path is implemented in `src/wasm/snarkjs_proof.rs` and re-exported by `src/wasm.rs` and `src/lib.rs`. - ### `initPanicHook()` Initialize panic handling for better browser error messages. Usually called automatically. @@ -309,28 +372,32 @@ Converts a snarkjs verification key JSON to the **arkworks compressed binary** ( ### `generate-proof-from-witness` — Rust-native CLI ```bash -./target/release/generate-proof-from-witness +./target/release/generate-proof-from-witness [num_public_signals] ``` -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)). +- `witness.json`: JSON array of hex LE strings (`0x...`, 32 bytes each), or a JSON object `{"witness": [...], "num_public_signals": 5}` +- `proving_key.ark`: arkworks compressed proving key (`.ark` format) +- `num_public_signals`: optional CLI override; defaults to the value in JSON or `5` + +Outputs proof and public signals as JSON to stdout (see [witness-formats.md](./witness-formats.md)). ## Complete Examples ### Rust Example ```rust -use groth16_proofs::generate_proof_from_witness; +use groth16_proofs::{generate_proof_from_witness, ProofError}; -fn main() -> Result<(), Box> { +fn main() -> Result<(), ProofError> { // Prepare witness (hex-encoded field elements) let witness = vec![ "0x0100000000000000000000000000000000000000000000000000000000000000".to_string(), "0x0200000000000000000000000000000000000000000000000000000000000000".to_string(), - // ... more elements (typically ~11,808 total) + // ... more elements (typically ~11,808 total for unshield/transfer) ]; - // Generate proof - let proof_bytes = generate_proof_from_witness(&witness, "circuits/my_circuit_pk.ark")?; + // Generate proof — 5 public signals for this circuit + let proof_bytes = generate_proof_from_witness(&witness, "circuits/my_circuit_pk.ark", 5)?; println!("Proof (128 bytes): 0x{}", hex::encode(&proof_bytes)); Ok(()) @@ -424,16 +491,27 @@ Decimal field elements (snarkjs native output): ### Rust Errors ```rust -match generate_proof_from_witness(&witness, "key.ark") { - Ok(proof) => println!("Success: {}", hex::encode(&proof)), - Err(e) => eprintln!("Error: {}", e), +use groth16_proofs::{generate_proof_from_witness, ProofError}; + +match generate_proof_from_witness(&witness, "key.ark", 5) { + Ok(proof) => println!("0x{}", hex::encode(&proof)), + Err(ProofError::ProvingKeyIo(e)) => eprintln!("Key file not found: {e}"), + Err(ProofError::ProvingKeyParse(e))=> eprintln!("Invalid key format: {e}"), + Err(ProofError::NumPublicSignals(e))=> eprintln!("Bad num_public_signals: {e}"), + Err(e) => eprintln!("Proof failed: {e}"), } ``` -**Common Errors**: -- `"Failed to read proving key: No such file or directory"` -- `"Failed to deserialize proving key: ..."` -- `"Failed to generate proof: Circuit constraint violation"` +**Error variants**: + +| Variant | When it occurs | +|---------|----------------| +| `WitnessEmpty` | Witness vector is empty | +| `WitnessConversion` | Hex LE string cannot be parsed | +| `ProvingKeyIo` | `.ark` file cannot be read | +| `ProvingKeyParse` | `.ark` bytes are not a valid proving key | +| `ProveGeneration` | arkworks constraint violation | +| `NumPublicSignals` | `0` or `>= witness.len()` | ### JavaScript Errors diff --git a/docs/witness-formats.md b/docs/witness-formats.md index 9c51bf8..d1e3b95 100644 --- a/docs/witness-formats.md +++ b/docs/witness-formats.md @@ -25,10 +25,12 @@ The library exposes two conceptually different entry points: | **Source** | snarkjs witness export | Custom / Rust internal | | **Example** | `"12345"` | `"0x3930000000...00"` | | **Conversion needed** | ❌ No | ✅ Yes | -| **Function (Rust)** | `decimal_to_field()` | `hex_to_field()` | +| **Function (Rust)** | `from_decimal_str::()` / `decimal_to_field()` ¹ | `from_hex_le::()` / `hex_to_field()` ¹ | | **Function (WASM)** | `generate_proof_from_decimal_wasm()` | N/A | | **Use case** | WASM witness-based flow | Rust CLI (`generate-proof-from-witness`) | +¹ `decimal_to_field` and `hex_to_field` are backward-compat shims for `from_decimal_str::` and `from_hex_le::`. Prefer the generic versions for new code. + ## 1. snarkjs Proof Format (for `compress_snarkjs_proof_wasm` — Primary ✅) ### What is it? diff --git a/src/bin/bench_groth16.rs b/src/bin/bench_groth16.rs index 8a54ced..acd6784 100644 --- a/src/bin/bench_groth16.rs +++ b/src/bin/bench_groth16.rs @@ -89,7 +89,7 @@ fn main() { let args: Vec = std::env::args().collect(); if args.len() < 4 { eprintln!( - "Usage: bench-groth16 [iterations=5]" + "Usage: bench-groth16 [iterations=5] [num_public=5]" ); std::process::exit(1); } @@ -97,6 +97,7 @@ fn main() { 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); + let num_public: usize = args.get(5).and_then(|s| s.parse().ok()).unwrap_or(5); eprintln!("Loading witness from {witness_path}..."); let witness = load_witness(witness_path); @@ -115,6 +116,7 @@ fn main() { for i in 0..iterations { let circuit = WitnessCircuit { witness: witness.clone(), + num_public_signals: num_public, }; let t0 = Instant::now(); let proof = Groth16::::prove(&pk, circuit, &mut rng) diff --git a/src/bin/generate-proof-from-witness.rs b/src/bin/generate-proof-from-witness.rs index bbe8534..ca36827 100644 --- a/src/bin/generate-proof-from-witness.rs +++ b/src/bin/generate-proof-from-witness.rs @@ -1,4 +1,3 @@ -#!/usr/bin/env rust //! Binary for generating Groth16 proofs from witness //! //! Usage: generate-proof-from-witness [num_public_signals] @@ -62,6 +61,9 @@ fn main() { std::process::exit(1); }); + // Priority: CLI arg > JSON field > default (5) + let num_public_signals = cli_num_public.or(input.num_public_signals).unwrap_or(5); + eprintln!( "🔐 Generating proof from {} witness elements...", input.witness.len() @@ -69,17 +71,14 @@ fn main() { // Generate proof let proof_bytes = - generate_proof_from_witness(&input.witness, proving_key_path).unwrap_or_else(|e| { - eprintln!("❌ Proof generation failed: {e}"); - std::process::exit(1); - }); + generate_proof_from_witness(&input.witness, proving_key_path, num_public_signals) + .unwrap_or_else(|e| { + eprintln!("❌ Proof generation failed: {e}"); + std::process::exit(1); + }); eprintln!("✅ Proof generated: {} bytes", proof_bytes.len()); - // Determine number of public signals - // Priority: CLI arg > JSON field > default (5) - let num_public_signals = cli_num_public.or(input.num_public_signals).unwrap_or(5); // Default to 5 (most common for unshield/transfer) - eprintln!("📊 Extracting {num_public_signals} public signals"); // Extract public signals (indices 1..n from witness) diff --git a/src/circuit.rs b/src/circuit.rs index 45e3e98..cfd5735 100644 --- a/src/circuit.rs +++ b/src/circuit.rs @@ -1,15 +1,19 @@ -//! Circuit wrapper for arkworks constraint system - use ark_bn254::Fr as Bn254Fr; use ark_relations::r1cs::ConstraintSynthesizer; -/// Minimal circuit wrapper for arkworks +/// Arkworks `ConstraintSynthesizer` wrapper for a pre-computed Circom witness. +/// +/// The proving key already encodes all constraints from the Circom compilation. +/// This struct only registers the witness variable assignment in the correct +/// order so arkworks can perform the MSM operations during proving. /// -/// This struct holds the witness and implements the ConstraintSynthesizer trait -/// required by ark-groth16. It doesn't generate actual constraints - those are -/// already baked into the proving key from the Circom circuit compilation. +/// Witness layout (Circom convention): +/// index 0 — constant 1 +/// indices 1..=num_public_signals — public signals +/// indices (num_public+1).. — private witness pub struct WitnessCircuit { pub witness: Vec, + pub num_public_signals: usize, } impl ConstraintSynthesizer for WitnessCircuit { @@ -17,26 +21,14 @@ impl ConstraintSynthesizer for WitnessCircuit { self, cs: ark_relations::r1cs::ConstraintSystemRef, ) -> ark_relations::r1cs::Result<()> { - // Mark public inputs (index 0 is always 1, indices 1..n are public) - // The exact number depends on the circuit - let num_public = if self.witness.len() > 1 { - // Estimate based on witness size (conservative) - (self.witness.len() / 100).clamp(1, 10) - } else { - 0 - }; - - for i in 0..num_public { - if i + 1 < self.witness.len() { - let _ = cs.new_input_variable(|| Ok(self.witness[i + 1]))?; + for i in 1..=self.num_public_signals { + if i < self.witness.len() { + let _ = cs.new_input_variable(|| Ok(self.witness[i]))?; } } - - // Private witness variables - for signal in self.witness.iter().skip(num_public + 1) { + for signal in self.witness.iter().skip(self.num_public_signals + 1) { let _ = cs.new_witness_variable(|| Ok(*signal))?; } - Ok(()) } } @@ -46,7 +38,7 @@ mod tests { use super::*; #[test] - fn test_witness_circuit_creation() { + fn test_circuit_stores_fields() { let witness = vec![ Bn254Fr::from(1u64), Bn254Fr::from(100u64), @@ -54,14 +46,18 @@ mod tests { ]; let circuit = WitnessCircuit { witness: witness.clone(), + num_public_signals: 1, }; - assert_eq!(circuit.witness.len(), 3); + assert_eq!(circuit.num_public_signals, 1); } #[test] - fn test_witness_circuit_empty() { - let circuit = WitnessCircuit { witness: vec![] }; + fn test_circuit_empty_witness() { + let circuit = WitnessCircuit { + witness: vec![], + num_public_signals: 0, + }; assert_eq!(circuit.witness.len(), 0); } } diff --git a/src/codec.rs b/src/codec.rs new file mode 100644 index 0000000..0ad07a6 --- /dev/null +++ b/src/codec.rs @@ -0,0 +1,172 @@ +use ark_bn254::{Bn254, Fq, Fq2, G1Affine, G2Affine}; +use ark_groth16::Proof as ArkProof; +use ark_serialize::CanonicalSerialize; + +use crate::error::ProofError; +use crate::field::from_decimal_str; + +#[derive(serde::Deserialize)] +struct SnarkjsProof { + pi_a: Vec, + pi_b: Vec>, + pi_c: Vec, +} + +fn validate_structure(proof: &SnarkjsProof) -> Result<(), ProofError> { + if proof.pi_a.len() < 2 { + return Err(ProofError::SnarkjsProofParse( + "pi_a must contain at least 2 elements".into(), + )); + } + if proof.pi_b.len() < 2 || proof.pi_b[0].len() < 2 || proof.pi_b[1].len() < 2 { + return Err(ProofError::SnarkjsProofParse( + "pi_b must be a 2x2 matrix".into(), + )); + } + if proof.pi_c.len() < 2 { + return Err(ProofError::SnarkjsProofParse( + "pi_c must contain at least 2 elements".into(), + )); + } + Ok(()) +} + +fn parse_proof(proof: &SnarkjsProof) -> Result, ProofError> { + let fq = |s: &str, ctx: &str| { + from_decimal_str::(s).map_err(|e| ProofError::SnarkjsProofParse(format!("{ctx}: {e}"))) + }; + Ok(ArkProof:: { + a: G1Affine::new( + fq(&proof.pi_a[0], "pi_a[0]")?, + fq(&proof.pi_a[1], "pi_a[1]")?, + ), + b: G2Affine::new( + Fq2::new( + fq(&proof.pi_b[0][0], "pi_b[0][0]")?, + fq(&proof.pi_b[0][1], "pi_b[0][1]")?, + ), + Fq2::new( + fq(&proof.pi_b[1][0], "pi_b[1][0]")?, + fq(&proof.pi_b[1][1], "pi_b[1][1]")?, + ), + ), + c: G1Affine::new( + fq(&proof.pi_c[0], "pi_c[0]")?, + fq(&proof.pi_c[1], "pi_c[1]")?, + ), + }) +} + +/// Parse a snarkjs Groth16 proof JSON string and return 128-byte arkworks compressed proof bytes. +pub fn compress_snarkjs_proof(proof_json: &str) -> Result, ProofError> { + let parsed: SnarkjsProof = serde_json::from_str(proof_json) + .map_err(|e| ProofError::SnarkjsProofParse(e.to_string()))?; + validate_structure(&parsed)?; + let proof = parse_proof(&parsed)?; + let mut compressed = Vec::new(); + proof + .serialize_compressed(&mut compressed) + .map_err(|e| ProofError::ProofSerialization(e.to_string()))?; + Ok(compressed) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_bn254::{G1Projective, G2Projective}; + use ark_ec::{CurveGroup, PrimeGroup}; + use ark_ff::{BigInteger, PrimeField}; + use num_bigint::BigUint; + + fn fq_to_decimal_string(value: Fq) -> String { + value + .into_bigint() + .to_bytes_le() + .iter() + .rev() + .fold(BigUint::from(0u8), |acc, &byte| { + (acc << 8) + BigUint::from(byte) + }) + .to_str_radix(10) + } + + fn build_valid_snarkjs_proof_json() -> String { + let a = G1Projective::generator().into_affine(); + let b = G2Projective::generator().into_affine(); + let c = G1Projective::generator().into_affine(); + serde_json::json!({ + "pi_a": [fq_to_decimal_string(a.x), fq_to_decimal_string(a.y)], + "pi_b": [ + [fq_to_decimal_string(b.x.c0), fq_to_decimal_string(b.x.c1)], + [fq_to_decimal_string(b.y.c0), fq_to_decimal_string(b.y.c1)] + ], + "pi_c": [fq_to_decimal_string(c.x), fq_to_decimal_string(c.y)] + }) + .to_string() + } + + #[test] + fn test_compress_produces_128_bytes() { + let bytes = compress_snarkjs_proof(&build_valid_snarkjs_proof_json()).unwrap(); + assert_eq!(bytes.len(), 128); + } + + #[test] + fn test_compress_matches_arkworks_serialization() { + let proof_json = build_valid_snarkjs_proof_json(); + let bytes = compress_snarkjs_proof(&proof_json).unwrap(); + + let parsed: SnarkjsProof = serde_json::from_str(&proof_json).unwrap(); + let proof = parse_proof(&parsed).unwrap(); + let mut expected = Vec::new(); + proof.serialize_compressed(&mut expected).unwrap(); + assert_eq!(bytes, expected); + } + + #[test] + fn test_rejects_malformed_json() { + assert!(compress_snarkjs_proof("not valid json {{{{").is_err()); + } + + #[test] + fn test_rejects_short_pi_a() { + let proof_json = serde_json::json!({ + "pi_a": ["1"], + "pi_b": [["1", "2"], ["3", "4"]], + "pi_c": ["1", "2"] + }) + .to_string(); + let err = compress_snarkjs_proof(&proof_json).unwrap_err(); + assert!(err + .to_string() + .contains("pi_a must contain at least 2 elements")); + } + + #[test] + fn test_rejects_pi_b_no_rows() { + let proof = SnarkjsProof { + pi_a: vec!["1".into(), "2".into()], + pi_b: vec![], + pi_c: vec!["1".into(), "2".into()], + }; + let err = validate_structure(&proof).unwrap_err(); + assert!(err.to_string().contains("pi_b must be a 2x2 matrix")); + } + + #[test] + fn test_rejects_pi_b_short_rows() { + let proof = SnarkjsProof { + pi_a: vec!["1".into(), "2".into()], + pi_b: vec![vec!["1".into()], vec!["3".into(), "4".into()]], + pi_c: vec!["1".into(), "2".into()], + }; + let err = validate_structure(&proof).unwrap_err(); + assert!(err.to_string().contains("pi_b must be a 2x2 matrix")); + } + + #[test] + fn test_from_decimal_str_fq_invalid() { + let err = from_decimal_str::("not-a-number").unwrap_err(); + assert!(err.contains("Failed to parse decimal string")); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..71a0c86 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,32 @@ +use std::fmt; + +#[derive(Debug)] +pub enum ProofError { + WitnessEmpty, + WitnessConversion(String), + ProvingKeyIo(String), + ProvingKeyParse(String), + ProveGeneration(String), + ProofSerialization(String), + NumPublicSignals(String), + WitnessJsonParse(String), + SnarkjsProofParse(String), +} + +impl fmt::Display for ProofError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProofError::WitnessEmpty => write!(f, "Witness is empty"), + ProofError::WitnessConversion(e) => write!(f, "Witness conversion failed: {e}"), + ProofError::ProvingKeyIo(e) => write!(f, "Failed to read proving key: {e}"), + ProofError::ProvingKeyParse(e) => write!(f, "Failed to deserialize proving key: {e}"), + ProofError::ProveGeneration(e) => write!(f, "Failed to generate proof: {e}"), + ProofError::ProofSerialization(e) => write!(f, "Failed to serialize proof: {e}"), + ProofError::NumPublicSignals(e) => write!(f, "Invalid num_public_signals: {e}"), + ProofError::WitnessJsonParse(e) => write!(f, "Failed to parse witness JSON: {e}"), + ProofError::SnarkjsProofParse(e) => write!(f, "Failed to parse snarkjs proof: {e}"), + } + } +} + +impl std::error::Error for ProofError {} diff --git a/src/field.rs b/src/field.rs new file mode 100644 index 0000000..49cd1f7 --- /dev/null +++ b/src/field.rs @@ -0,0 +1,137 @@ +use ark_ff::PrimeField; +use num_bigint::BigUint; + +/// Parse a decimal string into any `PrimeField` element (snarkjs native wire format). +pub fn from_decimal_str(s: &str) -> Result { + let n = BigUint::parse_bytes(s.as_bytes(), 10) + .ok_or_else(|| format!("Failed to parse decimal string: {s}"))?; + Ok(F::from_le_bytes_mod_order(&n.to_bytes_le())) +} + +/// Parse a little-endian hex string (`0x…` prefix optional) into any `PrimeField` element. +pub fn from_hex_le(hex: &str) -> Result { + let stripped = hex.strip_prefix("0x").unwrap_or(hex); + let padded = if stripped.len() % 2 == 1 { + format!("0{stripped}") + } else { + stripped.to_string() + }; + let bytes = hex::decode(&padded).map_err(|e| format!("Failed to decode hex: {e}"))?; + Ok(F::from_le_bytes_mod_order(&bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_bn254::Fr as Bn254Fr; + + #[test] + fn test_decimal_one() { + assert_eq!( + from_decimal_str::("1").unwrap(), + Bn254Fr::from(1u64) + ); + } + + #[test] + fn test_decimal_zero() { + assert_eq!( + from_decimal_str::("0").unwrap(), + Bn254Fr::from(0u64) + ); + } + + #[test] + fn test_decimal_large() { + assert!(from_decimal_str::("12345678901234567890").is_ok()); + } + + #[test] + fn test_decimal_invalid() { + let err = from_decimal_str::("not_a_number").unwrap_err(); + assert!(err.contains("Failed to parse decimal string")); + } + + #[test] + fn test_decimal_empty() { + let err = from_decimal_str::("").unwrap_err(); + assert!(err.contains("Failed to parse decimal string")); + } + + #[test] + fn test_decimal_leading_zeros() { + let a = from_decimal_str::("0001").unwrap(); + let b = from_decimal_str::("1").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn test_hex_le_one() { + let hex = "0x0100000000000000000000000000000000000000000000000000000000000000"; + assert_eq!(from_hex_le::(hex).unwrap(), Bn254Fr::from(1u64)); + } + + #[test] + fn test_hex_le_one_no_prefix() { + let hex = "0100000000000000000000000000000000000000000000000000000000000000"; + assert_eq!(from_hex_le::(hex).unwrap(), Bn254Fr::from(1u64)); + } + + #[test] + fn test_hex_le_zero() { + let hex = "0x0000000000000000000000000000000000000000000000000000000000000000"; + assert_eq!(from_hex_le::(hex).unwrap(), Bn254Fr::from(0u64)); + } + + #[test] + fn test_hex_le_max_value() { + let hex = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + assert!(from_hex_le::(hex).is_ok()); + } + + #[test] + fn test_hex_le_odd_length() { + assert_eq!(from_hex_le::("0x1").unwrap(), Bn254Fr::from(1u64)); + } + + #[test] + fn test_hex_le_invalid() { + let err = from_hex_le::("0xGGGG").unwrap_err(); + assert!(err.contains("Failed to decode hex")); + } + + #[test] + fn test_hex_le_roundtrip() { + let val = 12345u64; + let mut bytes = vec![0u8; 32]; + bytes[0] = (val & 0xFF) as u8; + bytes[1] = ((val >> 8) & 0xFF) as u8; + bytes[2] = ((val >> 16) & 0xFF) as u8; + bytes[3] = ((val >> 24) & 0xFF) as u8; + let hex = format!("0x{}", hex::encode(&bytes)); + assert_eq!(from_hex_le::(&hex).unwrap(), Bn254Fr::from(val)); + } + + #[test] + fn test_decimal_hex_consistency() { + let a = from_decimal_str::("12345").unwrap(); + let b = from_hex_le::( + "0x3930000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); + assert_eq!(a, b); + } + + #[test] + fn test_batch_hex_conversion() { + let inputs = [ + "0x0100000000000000000000000000000000000000000000000000000000000000", + "0x0200000000000000000000000000000000000000000000000000000000000000", + "0x0300000000000000000000000000000000000000000000000000000000000000", + ]; + let fields: Vec = inputs.iter().map(|h| from_hex_le(h).unwrap()).collect(); + assert_eq!(fields[0], Bn254Fr::from(1u64)); + assert_eq!(fields[1], Bn254Fr::from(2u64)); + assert_eq!(fields[2], Bn254Fr::from(3u64)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 13f4479..94750a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,44 @@ -//! Orbinum Proof Generator Library -//! -//! Generates Groth16 proofs from pre-calculated witness using arkworks. +//! Orbinum Groth16 Proof Generator //! //! # Architecture //! -//! - `utils`: Utility functions (hex conversions) -//! - `circuit`: Circuit wrapper for arkworks -//! - `proof`: Proof generation logic (native Rust) -//! - `wasm`: WASM bindings for browser usage +//! - `error` — [`ProofError`] unified error type +//! - `field` — generic [`from_decimal_str`] / [`from_hex_le`] field conversion +//! - `circuit`— [`WitnessCircuit`]: arkworks `ConstraintSynthesizer` adapter +//! - `prover` — [`prove_from_witness`]: core prover shared by native and WASM paths +//! - `codec` — [`codec::compress_snarkjs_proof`]: snarkjs JSON → compressed bytes +//! - `proof` — [`generate_proof_from_witness`]: file-I/O adapter (native/CLI) +//! - `utils` — backward-compat shims for `decimal_to_field` / `hex_to_field` +//! - `wasm` — WASM bindings (`generate_proof_from_decimal_wasm`, `compress_snarkjs_proof_wasm`) -// Modules mod circuit; +mod codec; +mod error; +mod field; mod proof; +mod prover; mod utils; #[cfg(feature = "wasm")] pub mod wasm; -// Public exports +// Core types pub use circuit::WitnessCircuit; +pub use error::ProofError; + +// Proof generation pub use proof::generate_proof_from_witness; +pub use prover::prove_from_witness; + +// snarkjs interop +pub use codec::compress_snarkjs_proof; + +// Field conversion +pub use field::{from_decimal_str, from_hex_le}; + +// Backward-compat aliases pub use utils::{decimal_to_field, hex_to_field}; -// Re-export WASM functions when feature is enabled +// WASM re-exports #[cfg(feature = "wasm")] pub use wasm::{compress_snarkjs_proof_wasm, generate_proof_from_decimal_wasm, init_panic_hook}; diff --git a/src/proof.rs b/src/proof.rs index cd8c7fa..fe59c33 100644 --- a/src/proof.rs +++ b/src/proof.rs @@ -1,56 +1,28 @@ -//! Proof generation using arkworks +use ark_bn254::Fr as Bn254Fr; -use ark_bn254::{Bn254, Fr as Bn254Fr}; -use ark_groth16::{Groth16, ProvingKey}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use ark_snark::SNARK; -use ark_std::rand::rngs::StdRng; -use ark_std::rand::SeedableRng; +use crate::error::ProofError; +use crate::field::from_hex_le; +use crate::prover::prove_from_witness; -use crate::circuit::WitnessCircuit; -use crate::utils::hex_to_field; - -/// Generate a Groth16 proof from witness -/// -/// # Arguments -/// * `witness_hex` - Array of hex-encoded witness elements (little-endian) -/// * `proving_key_path` - Path to .ark proving key file +/// Generate a Groth16 proof from a hex-LE witness array and a `.ark` proving key at `path`. /// -/// # Returns -/// * `Ok(Vec)` - Compressed proof bytes (128 bytes) -/// * `Err(String)` - Error message +/// This is the file-I/O adapter: it reads the proving key from disk and delegates +/// proof generation to [`prove_from_witness`]. pub fn generate_proof_from_witness( witness_hex: &[String], proving_key_path: &str, -) -> Result, String> { - // 1. Convert hex witness to field elements + num_public_signals: usize, +) -> Result, ProofError> { let witness: Vec = witness_hex .iter() - .map(|hex| hex_to_field(hex)) - .collect::, _>>()?; + .map(|h| from_hex_le(h)) + .collect::, _>>() + .map_err(ProofError::WitnessConversion)?; - // 2. Load proving key let pk_bytes = - std::fs::read(proving_key_path).map_err(|e| format!("Failed to read proving key: {e}"))?; - - let pk = ProvingKey::::deserialize_compressed(&pk_bytes[..]) - .map_err(|e| format!("Failed to deserialize proving key: {e}"))?; - - // 3. Create circuit with witness - let circuit = WitnessCircuit { witness }; - - // 4. Generate proof using arkworks - let mut rng = StdRng::from_entropy(); - let proof = Groth16::::prove(&pk, circuit, &mut rng) - .map_err(|e| format!("Failed to generate proof: {e}"))?; - - // 5. Serialize proof (compressed format - 128 bytes) - let mut proof_bytes = Vec::new(); - proof - .serialize_compressed(&mut proof_bytes) - .map_err(|e| format!("Failed to serialize proof: {e}"))?; + std::fs::read(proving_key_path).map_err(|e| ProofError::ProvingKeyIo(e.to_string()))?; - Ok(proof_bytes) + prove_from_witness(&pk_bytes, witness, num_public_signals) } #[cfg(test)] @@ -61,73 +33,42 @@ mod tests { fn test_generate_proof_invalid_proving_key_path() { let witness_hex = vec!["0x0100000000000000000000000000000000000000000000000000000000000000".to_string()]; - let result = generate_proof_from_witness(&witness_hex, "/nonexistent/path.ark"); - + let result = generate_proof_from_witness(&witness_hex, "/nonexistent/path.ark", 5); assert!(result.is_err()); - let error = result.unwrap_err(); - assert!(error.contains("Failed to read proving key")); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to read proving key")); } #[test] fn test_generate_proof_invalid_proving_key_content() { use std::io::Write; - let temp_file = "/tmp/invalid_proving_key.ark"; let mut file = std::fs::File::create(temp_file).unwrap(); file.write_all(b"invalid content").unwrap(); - let witness_hex = - vec!["0x0100000000000000000000000000000000000000000000000000000000000000".to_string()]; - let result = generate_proof_from_witness(&witness_hex, temp_file); - + // Need witness longer than num_public_signals so we reach PK deserialization. + let witness_hex: Vec = (0..10).map(|i| format!("0x{:064x}", i)).collect(); + let result = generate_proof_from_witness(&witness_hex, temp_file, 5); let _ = std::fs::remove_file(temp_file); - assert!(result.is_err()); - let error = result.unwrap_err(); - assert!(error.contains("Failed to deserialize proving key")); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to deserialize proving key")); } #[test] fn test_generate_proof_empty_witness() { - let witness_hex: Vec = vec![]; - let result = generate_proof_from_witness(&witness_hex, "/fake/path.ark"); - + let result = generate_proof_from_witness(&[], "/fake/path.ark", 5); assert!(result.is_err()); } - #[test] - fn test_proof_size_expectation() { - const EXPECTED_COMPRESSED_PROOF_SIZE: usize = 128; - - // Groth16 compressed proof in BN254: - // - G1 point (A): 32 bytes compressed - // - G2 point (B): 64 bytes compressed - // - G1 point (C): 32 bytes compressed - // Total: 128 bytes - - 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. + let result = generate_proof_from_witness(&witness_hex, "/fake/path.ark", 5); assert!(result.is_err()); } } diff --git a/src/prover.rs b/src/prover.rs new file mode 100644 index 0000000..5ee59c9 --- /dev/null +++ b/src/prover.rs @@ -0,0 +1,107 @@ +use ark_bn254::{Bn254, Fr as Bn254Fr}; +use ark_groth16::{Groth16, ProvingKey}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ark_snark::SNARK; +use ark_std::rand::rngs::StdRng; +use ark_std::rand::SeedableRng; + +use crate::circuit::WitnessCircuit; +use crate::error::ProofError; + +/// Generate a Groth16 compressed proof from a pre-computed witness. +/// +/// * `pk_bytes` — raw bytes of an arkworks compressed proving key (`.ark` format). +/// * `witness` — full Circom witness vector (index 0 = constant 1). +/// * `num_public_signals` — number of public signals (indices 1..=n in the witness). +/// +/// Returns 128 compressed proof bytes on success. +pub fn prove_from_witness( + pk_bytes: &[u8], + witness: Vec, + num_public_signals: usize, +) -> Result, ProofError> { + if witness.is_empty() { + return Err(ProofError::WitnessEmpty); + } + if num_public_signals == 0 { + return Err(ProofError::NumPublicSignals( + "must be greater than 0".into(), + )); + } + if num_public_signals >= witness.len() { + return Err(ProofError::NumPublicSignals(format!( + "{num_public_signals} >= witness length {}", + witness.len() + ))); + } + + let pk = ProvingKey::::deserialize_compressed(pk_bytes) + .map_err(|e| ProofError::ProvingKeyParse(e.to_string()))?; + + let circuit = WitnessCircuit { + witness, + num_public_signals, + }; + let mut rng = StdRng::from_entropy(); + let proof = Groth16::::prove(&pk, circuit, &mut rng) + .map_err(|e| ProofError::ProveGeneration(e.to_string()))?; + + let mut proof_bytes = Vec::new(); + proof + .serialize_compressed(&mut proof_bytes) + .map_err(|e| ProofError::ProofSerialization(e.to_string()))?; + + Ok(proof_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_witness_is_rejected() { + let result = prove_from_witness(b"dummy", vec![], 5); + assert!(matches!(result.unwrap_err(), ProofError::WitnessEmpty)); + } + + #[test] + fn test_zero_public_signals_is_rejected() { + let w = vec![Bn254Fr::from(1u64); 10]; + let result = prove_from_witness(b"dummy", w, 0); + assert!(matches!( + result.unwrap_err(), + ProofError::NumPublicSignals(_) + )); + } + + #[test] + fn test_num_public_signals_gte_witness_len_is_rejected() { + let w = vec![Bn254Fr::from(1u64); 10]; + let result = prove_from_witness(b"dummy", w, 10); + assert!(matches!( + result.unwrap_err(), + ProofError::NumPublicSignals(_) + )); + } + + #[test] + fn test_invalid_pk_bytes_are_rejected() { + let w = vec![Bn254Fr::from(1u64); 10]; + let result = prove_from_witness(b"not a proving key", w, 5); + assert!(matches!( + result.unwrap_err(), + ProofError::ProvingKeyParse(_) + )); + } + + #[test] + fn test_error_messages_are_descriptive() { + let result = prove_from_witness(b"dummy", vec![Bn254Fr::from(1u64); 10], 0); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("Invalid num_public_signals")); + + let result2 = prove_from_witness(b"dummy", vec![], 5); + let msg2 = result2.unwrap_err().to_string(); + assert!(msg2.contains("Witness is empty")); + } +} diff --git a/src/utils.rs b/src/utils.rs index fd54ecc..7f240f1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,48 +1,14 @@ -//! Utility functions for proof generation - +// Backward-compatible shims for decimal_to_field and hex_to_field. +// Logic lives in field.rs as generic functions. +use crate::field::{from_decimal_str, from_hex_le}; use ark_bn254::Fr as Bn254Fr; -use ark_ff::PrimeField; -use num_bigint::BigUint; - -/// Convert decimal string to field element (snarkjs format) -/// -/// Accepts decimal string representation of field element (e.g., "123456") -/// This is the native format used by snarkjs witness output. -pub fn decimal_to_field(decimal_str: &str) -> Result { - // Parse as BigUint - let big_uint = BigUint::parse_bytes(decimal_str.as_bytes(), 10) - .ok_or_else(|| format!("Failed to parse decimal string: {}", decimal_str))?; - - // Convert to bytes (little-endian for arkworks) - let bytes = big_uint.to_bytes_le(); - - // Convert to field element (with modular reduction) - Ok(Bn254Fr::from_le_bytes_mod_order(&bytes)) + +pub fn decimal_to_field(s: &str) -> Result { + from_decimal_str::(s) } -/// Convert hex string to field element -/// -/// Expects little-endian hex string (0x + 64 hex chars) -pub fn hex_to_field(hex_str: &str) -> Result { - // Remove 0x prefix if present - let hex_clean = if let Some(stripped) = hex_str.strip_prefix("0x") { - stripped - } else { - hex_str - }; - - // Pad to 64 chars if needed (handles odd-length hex) - let hex_padded = if hex_clean.len() % 2 == 1 { - format!("0{hex_clean}") - } else { - hex_clean.to_string() - }; - - // Decode hex to bytes (little-endian) - let bytes = hex::decode(&hex_padded).map_err(|e| format!("Failed to decode hex: {e}"))?; - - // Convert to field element (arkworks expects little-endian) - Ok(Bn254Fr::from_le_bytes_mod_order(&bytes)) +pub fn hex_to_field(hex: &str) -> Result { + from_hex_le::(hex) } #[cfg(test)] diff --git a/src/wasm.rs b/src/wasm.rs index 02d8e09..6f4b5c9 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -1,93 +1,37 @@ -//! WASM bindings for browser usage - -use ark_bn254::{Bn254, Fr as Bn254Fr}; +use ark_bn254::Fr as Bn254Fr; use ark_ff::{BigInteger, PrimeField}; -use ark_groth16::{Groth16, ProvingKey}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use ark_snark::SNARK; -use ark_std::rand::rngs::StdRng; -use ark_std::rand::SeedableRng; use wasm_bindgen::prelude::*; -use crate::circuit::WitnessCircuit; -use crate::utils::decimal_to_field; +use crate::field::from_decimal_str; +use crate::prover::prove_from_witness; mod snarkjs_proof; - pub use snarkjs_proof::compress_snarkjs_proof_wasm; -/// Initialize panic hook for better error messages in browser. -/// Only call this when running in actual WASM environment, not tests. #[cfg(target_arch = "wasm32")] #[wasm_bindgen(start)] pub fn init_panic_hook() { console_error_panic_hook::set_once(); } -/// No-op for non-WASM builds (like tests) #[cfg(not(target_arch = "wasm32"))] -pub fn init_panic_hook() { - // No-op: panic hook only needed in WASM -} +pub fn init_panic_hook() {} -/// Generate a Groth16 proof from witness in decimal format (snarkjs native) -/// -/// This function accepts witness in the native snarkjs format (decimal strings) -/// and handles the conversion to field elements internally. -/// -/// # Arguments -/// * `num_public_signals` - Number of public signals to extract from witness -/// * `witness_json` - JSON array of witness values as decimal strings (e.g., `["1", "123", "456"]`) -/// * `proving_key_bytes` - Serialized proving key (arkworks format) -/// -/// # Returns -/// JSON string with format: `{"proof": "0x...", "publicSignals": ["0x...", "0x..."]}` -/// -/// # Example -/// ```ignore -/// // Witness directly from snarkjs (no conversion needed) -/// let witness_json = r#"["1", "12345", "67890"]"#; -/// let result = generate_proof_from_decimal_wasm(5, witness_json, proving_key_bytes)?; -/// ``` #[wasm_bindgen] pub fn generate_proof_from_decimal_wasm( num_public_signals: usize, witness_json: &str, proving_key_bytes: &[u8], ) -> Result { - // Parse witness JSON (decimal strings) let witness_strings: Vec = serde_json::from_str(witness_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse witness JSON: {e}")))?; - // Convert decimal strings to field elements let witness: Vec = witness_strings .iter() - .map(|s| decimal_to_field(s)) + .map(|s| from_decimal_str::(s)) .collect::, _>>() .map_err(|e| JsValue::from_str(&e))?; - // Deserialize proving key - let proving_key = ProvingKey::::deserialize_compressed(proving_key_bytes) - .map_err(|e| JsValue::from_str(&format!("Failed to deserialize proving key: {e}")))?; - - // Generate proof - let circuit = WitnessCircuit { - witness: witness.clone(), - }; - let mut rng = StdRng::from_entropy(); - let proof = Groth16::::prove(&proving_key, circuit, &mut rng) - .map_err(|e| JsValue::from_str(&format!("Failed to generate proof: {e}")))?; - - // Serialize proof (compressed 128 bytes) - let mut proof_bytes = Vec::new(); - proof - .serialize_compressed(&mut proof_bytes) - .map_err(|e| JsValue::from_str(&format!("Failed to serialize proof: {e}")))?; - - let proof_hex = format!("0x{}", hex::encode(&proof_bytes)); - - // Extract public signals - // Validate bounds if num_public_signals == 0 { return Err(JsValue::from_str( "num_public_signals must be greater than 0", @@ -95,12 +39,12 @@ pub fn generate_proof_from_decimal_wasm( } if num_public_signals >= witness.len() { return Err(JsValue::from_str(&format!( - "num_public_signals ({}) exceeds witness length ({})", - num_public_signals, + "num_public_signals ({num_public_signals}) exceeds witness length ({})", witness.len() ))); } + // Extract public signals before moving witness into the prover. let public_signals: Vec = witness[1..=num_public_signals] .iter() .map(|f| { @@ -110,9 +54,11 @@ pub fn generate_proof_from_decimal_wasm( }) .collect(); - // Return JSON output + let proof_bytes = prove_from_witness(proving_key_bytes, witness, num_public_signals) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let output = serde_json::json!({ - "proof": proof_hex, + "proof": format!("0x{}", hex::encode(&proof_bytes)), "publicSignals": public_signals, }); @@ -120,118 +66,31 @@ pub fn generate_proof_from_decimal_wasm( .map_err(|e| JsValue::from_str(&format!("Failed to serialize output: {e}"))) } -// WASM module tests -// -// 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 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; - - // ── witness JSON parsing ────────────────────────────────────────────────── - - #[test] - fn test_witness_hex_json_parsed_correctly() { - let witness_json = r#"[ - "0x0100000000000000000000000000000000000000000000000000000000000000", - "0x0200000000000000000000000000000000000000000000000000000000000000" - ]"#; - 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_json_parsed_and_converted() { - let witness_json = r#"["1", "5", "255"]"#; - let strings: Vec = serde_json::from_str(witness_json).unwrap(); + fn test_decimal_witness_parse_and_convert() { + let strings: Vec = serde_json::from_str(r#"["1", "5", "255"]"#).unwrap(); let fields: Vec = strings .iter() - .map(|s| decimal_to_field(s).unwrap()) + .map(|s| from_decimal_str::(s).unwrap()) .collect(); assert_eq!(fields[0], Bn254Fr::from(1u64)); assert_eq!(fields[1], Bn254Fr::from(5u64)); assert_eq!(fields[2], Bn254Fr::from(255u64)); } - #[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_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"); - } - - #[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); - } - - #[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_extracted_from_correct_indices() { - // witness[0] is always 1 (constant), public signals start at index 1 - let witness = [ - 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 = 3; - let public_signals: Vec = witness[1..=num_public_signals] - .iter() - .map(|f| { - let mut bytes = f.into_bigint().to_bytes_le(); - bytes.resize(32, 0u8); - format!("0x{}", hex::encode(&bytes)) - }) - .collect(); - - 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_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); + assert_eq!(hex.len(), 66); // "0x" + 64 hex chars } - // ── output JSON format ───────────────────────────────────────────────────── - #[test] fn test_output_json_has_required_fields() { let output = serde_json::json!({ @@ -239,7 +98,6 @@ mod tests { "publicSignals": ["0x01", "0x02"] }); assert!(output.get("proof").is_some()); - let signals = output["publicSignals"].as_array().unwrap(); - assert_eq!(signals.len(), 2); + assert_eq!(output["publicSignals"].as_array().unwrap().len(), 2); } } diff --git a/src/wasm/snarkjs_proof.rs b/src/wasm/snarkjs_proof.rs index c9b9d29..6952ccd 100644 --- a/src/wasm/snarkjs_proof.rs +++ b/src/wasm/snarkjs_proof.rs @@ -1,240 +1,9 @@ -use ark_bn254::{Bn254, Fq, Fq2, G1Affine, G2Affine}; -use ark_ff::PrimeField; -use ark_groth16::Proof as ArkProof; -use ark_serialize::CanonicalSerialize; -use num_bigint::BigUint; +use crate::codec::compress_snarkjs_proof; use wasm_bindgen::prelude::*; -#[derive(serde::Deserialize)] -struct SnarkjsProof { - pi_a: Vec, - pi_b: Vec>, - pi_c: Vec, -} - -fn parse_fq_decimal(value: &str) -> Result { - let bigint = BigUint::parse_bytes(value.as_bytes(), 10) - .ok_or_else(|| format!("Invalid decimal field element: {value}"))?; - - let bytes = bigint.to_bytes_le(); - Ok(Fq::from_le_bytes_mod_order(&bytes)) -} - -fn validate_snarkjs_proof_structure(parsed: &SnarkjsProof) -> Result<(), String> { - if parsed.pi_a.len() < 2 { - return Err( - "Invalid snarkjs proof structure: pi_a must contain at least 2 elements".to_string(), - ); - } - if parsed.pi_b.len() < 2 || parsed.pi_b[0].len() < 2 || parsed.pi_b[1].len() < 2 { - return Err("Invalid snarkjs proof structure: pi_b must be a 2x2 matrix".to_string()); - } - if parsed.pi_c.len() < 2 { - return Err( - "Invalid snarkjs proof structure: pi_c must contain at least 2 elements".to_string(), - ); - } - - Ok(()) -} - -fn snarkjs_to_ark_proof(parsed: &SnarkjsProof) -> Result, String> { - validate_snarkjs_proof_structure(parsed)?; - - let a = G1Affine::new( - parse_fq_decimal(&parsed.pi_a[0]).map_err(|e| format!("Invalid pi_a[0]: {e}"))?, - parse_fq_decimal(&parsed.pi_a[1]).map_err(|e| format!("Invalid pi_a[1]: {e}"))?, - ); - - let b = G2Affine::new( - Fq2::new( - parse_fq_decimal(&parsed.pi_b[0][0]).map_err(|e| format!("Invalid pi_b[0][0]: {e}"))?, - parse_fq_decimal(&parsed.pi_b[0][1]).map_err(|e| format!("Invalid pi_b[0][1]: {e}"))?, - ), - Fq2::new( - parse_fq_decimal(&parsed.pi_b[1][0]).map_err(|e| format!("Invalid pi_b[1][0]: {e}"))?, - parse_fq_decimal(&parsed.pi_b[1][1]).map_err(|e| format!("Invalid pi_b[1][1]: {e}"))?, - ), - ); - - let c = G1Affine::new( - parse_fq_decimal(&parsed.pi_c[0]).map_err(|e| format!("Invalid pi_c[0]: {e}"))?, - parse_fq_decimal(&parsed.pi_c[1]).map_err(|e| format!("Invalid pi_c[1]: {e}"))?, - ); - - Ok(ArkProof:: { a, b, c }) -} - -/// Compress a snarkjs Groth16 proof into arkworks canonical compressed bytes (128 bytes) -/// -/// # Arguments -/// * `proof_json` - JSON string of snarkjs proof with `pi_a`, `pi_b`, `pi_c` -/// -/// # Returns -/// Hex string `0x...` with 128 bytes (arkworks compressed proof) #[wasm_bindgen] pub fn compress_snarkjs_proof_wasm(proof_json: &str) -> Result { - let parsed: SnarkjsProof = serde_json::from_str(proof_json) - .map_err(|e| JsValue::from_str(&format!("Failed to parse snarkjs proof JSON: {e}")))?; - let proof = snarkjs_to_ark_proof(&parsed).map_err(|e| JsValue::from_str(&e))?; - - let mut compressed = Vec::new(); - proof - .serialize_compressed(&mut compressed) - .map_err(|e| JsValue::from_str(&format!("Failed to serialize compressed proof: {e}")))?; - - Ok(format!("0x{}", hex::encode(compressed))) -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { - use super::*; - use ark_bn254::{G1Projective, G2Projective}; - use ark_ec::{CurveGroup, PrimeGroup}; - use ark_ff::BigInteger; - - fn fq_to_decimal_string(value: Fq) -> String { - value - .into_bigint() - .to_bytes_le() - .iter() - .rev() - .fold(BigUint::from(0u8), |acc, &byte| { - (acc << 8) + BigUint::from(byte) - }) - .to_str_radix(10) - } - - fn build_valid_snarkjs_proof_json() -> String { - let a = G1Projective::generator().into_affine(); - let b = G2Projective::generator().into_affine(); - let c = G1Projective::generator().into_affine(); - - serde_json::json!({ - "pi_a": [ - fq_to_decimal_string(a.x), - fq_to_decimal_string(a.y) - ], - "pi_b": [ - [ - fq_to_decimal_string(b.x.c0), - fq_to_decimal_string(b.x.c1) - ], - [ - fq_to_decimal_string(b.y.c0), - fq_to_decimal_string(b.y.c1) - ] - ], - "pi_c": [ - fq_to_decimal_string(c.x), - fq_to_decimal_string(c.y) - ] - }) - .to_string() - } - - #[test] - fn test_parse_fq_decimal_invalid_input() { - let result = parse_fq_decimal("not-a-number"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Invalid decimal field element: not-a-number")); - } - - #[test] - fn test_validate_snarkjs_proof_structure_rejects_short_pi_a() { - let parsed = SnarkjsProof { - pi_a: vec!["1".to_string()], - pi_b: vec![ - vec!["1".to_string(), "2".to_string()], - 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_a must contain at least 2 elements")); - } - - #[test] - fn test_compress_snarkjs_proof_matches_arkworks_serialization() { - let proof_json = build_valid_snarkjs_proof_json(); - - let compressed_hex = compress_snarkjs_proof_wasm(&proof_json).unwrap(); - assert!(compressed_hex.starts_with("0x")); - - let parsed: SnarkjsProof = serde_json::from_str(&proof_json).unwrap(); - let proof = snarkjs_to_ark_proof(&parsed).unwrap(); - let mut expected_bytes = Vec::new(); - proof.serialize_compressed(&mut expected_bytes).unwrap(); - - let expected_hex = format!("0x{}", hex::encode(expected_bytes)); - assert_eq!(compressed_hex, expected_hex); - } - - #[test] - fn test_snarkjs_to_ark_proof_rejects_invalid_structure() { - let invalid_proof_json = serde_json::json!({ - "pi_a": ["1"], - "pi_b": [["1", "2"]], - "pi_c": ["3", "4"] - }) - .to_string(); - - let parsed: SnarkjsProof = serde_json::from_str(&invalid_proof_json).unwrap(); - let result = snarkjs_to_ark_proof(&parsed); - assert!(result.is_err()); - assert!(result - .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); - } + compress_snarkjs_proof(proof_json) + .map(|bytes| format!("0x{}", hex::encode(bytes))) + .map_err(|e| JsValue::from_str(&e.to_string())) }