From e822e020d6987d2957b7c7387a381d1e3e2e0e09 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 9 Oct 2026 20:10:52 -0300 Subject: [PATCH] feat(zk-verifier): register a native Groth16 host function (register_only) --- Cargo.lock | 7 +- frame/zk-verifier/CHANGELOG.md | 12 ++ frame/zk-verifier/Cargo.toml | 2 +- frame/zk-verifier/src/tests.rs | 7 +- frame/zk-verifier/src/verifier.rs | 110 ++++++++----- primitives/zk-verifier/CHANGELOG.md | 20 +++ primitives/zk-verifier/Cargo.toml | 13 +- primitives/zk-verifier/src/host_interface.rs | 47 ++++++ primitives/zk-verifier/src/lib.rs | 4 +- primitives/zk-verifier/src/prepared.rs | 71 +++++++- primitives/zk-verifier/tests/real_proof.rs | 163 ++++++++++++++++++- template/node/Cargo.toml | 1 + template/node/src/service.rs | 21 +++ template/runtime/Cargo.toml | 1 + template/runtime/RUNTIME_VERSIONS.md | 12 +- template/runtime/src/runtime_tests.rs | 20 +++ 16 files changed, 459 insertions(+), 52 deletions(-) create mode 100644 primitives/zk-verifier/src/host_interface.rs diff --git a/Cargo.lock b/Cargo.lock index 14164496..eaed8b9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9939,6 +9939,7 @@ dependencies = [ "log", "orbinum-runtime", "orbinum-zk-core", + "orbinum-zk-verifier", "pallet-ismp-rpc", "pallet-ismp-runtime-api", "pallet-relayer", @@ -10059,6 +10060,7 @@ dependencies = [ "sp-genesis-builder", "sp-inherents", "sp-io", + "sp-maybe-compressed-blob", "sp-offchain", "sp-runtime", "sp-session", @@ -10083,7 +10085,7 @@ dependencies = [ [[package]] name = "orbinum-zk-verifier" -version = "3.1.0" +version = "3.2.0" dependencies = [ "ark-bn254", "ark-ec 0.5.0", @@ -10098,6 +10100,7 @@ dependencies = [ "orbinum-zk-core", "parity-scale-codec", "scale-info", + "sp-runtime-interface", ] [[package]] @@ -12984,7 +12987,7 @@ dependencies = [ [[package]] name = "pallet-zk-verifier" -version = "0.17.0" +version = "0.17.1" dependencies = [ "ark-bn254", "ark-ec 0.5.0", diff --git a/frame/zk-verifier/CHANGELOG.md b/frame/zk-verifier/CHANGELOG.md index 6dc19074..b2b2285a 100644 --- a/frame/zk-verifier/CHANGELOG.md +++ b/frame/zk-verifier/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to this pallet are documented here. ## [Unreleased] +## [0.17.1] - 2026-10-09 + +### Changed + +- Verification always works from the key's prepared form: a key with none is + prepared on the fly, and its arity is read from the prepared layout. + The pairing runs through `orbinum_zk_verifier::verify_prepared`, the same + function the node's `bn254_groth16_verify` host function runs. +- `verify_proof` (raw inputs) refuses inputs that do not match the key's arity + before the pairing, as statement verification already did; it used to leave + the count to the verifier. + ## [0.17.0] - 2026-10-09 ### Changed diff --git a/frame/zk-verifier/Cargo.toml b/frame/zk-verifier/Cargo.toml index 7be4f19f..ec669939 100644 --- a/frame/zk-verifier/Cargo.toml +++ b/frame/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-zk-verifier" -version = "0.17.0" +version = "0.17.1" description = "Zero-Knowledge proof verification pallet for Orbinum" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/zk-verifier/src/tests.rs b/frame/zk-verifier/src/tests.rs index 94aea556..9b953624 100644 --- a/frame/zk-verifier/src/tests.rs +++ b/frame/zk-verifier/src/tests.rs @@ -839,11 +839,16 @@ fn verify_proof_happy_path_emits_proof_verified_event() { TRANSFER_PUBLIC_INPUTS + MEMO_HASH_INPUTS, ); activate(CircuitId::TRANSFER, 1); + let inputs: PublicInputs = (0..TRANSFER_PUBLIC_INPUTS + MEMO_HASH_INPUTS) + .map(|_| vec![0x02u8; 32].try_into().unwrap()) + .collect::>() + .try_into() + .unwrap(); assert_ok!(ZkVerifier::verify_proof( signed().into(), CircuitId::TRANSFER, proof_bytes(), - one_public_input(), + inputs, )); assert!(has_event(Event::ProofVerified { circuit_id: CircuitId::TRANSFER, diff --git a/frame/zk-verifier/src/verifier.rs b/frame/zk-verifier/src/verifier.rs index 68102aa5..73213f3c 100644 --- a/frame/zk-verifier/src/verifier.rs +++ b/frame/zk-verifier/src/verifier.rs @@ -13,8 +13,7 @@ use crate::{ }; use alloc::vec::Vec; use orbinum_zk_verifier::{ - Bn254, InputLayout, PreparedVerifyingKey, VerifyingKey, has_memo_layout, input_layout, - prepared_from_stored, + InputLayout, VerifyingKey, has_memo_layout, input_layout, prepared_arity, }; /// Verify a proof of a circuit statement, encoded for the layout of the key it @@ -29,13 +28,12 @@ pub fn verify_statement( proof: &[u8], encode: impl FnOnce(InputLayout) -> Option>, ) -> Result<(bool, u32), sp_runtime::DispatchError> { - check::(circuit_id, version, proof, |layout, arity| { - encode(layout).filter(|raw| raw.len() == arity) - }) + check::(circuit_id, version, proof, encode) } -/// Verify a proof against caller-supplied inputs, taken as-is. Serves the -/// `verify_proof` extrinsic, whose caller encodes every input itself. +/// Verify a proof against caller-encoded inputs, taken as-is (the +/// `verify_proof` extrinsic). Inputs that do not fill the key are +/// `valid = false`, as for a statement. pub fn verify_raw( circuit_id: CircuitId, version: Option, @@ -43,7 +41,7 @@ pub fn verify_raw( raw_inputs: Vec<[u8; 32]>, ) -> Result<(bool, u32), sp_runtime::DispatchError> { frame_support::ensure!(!raw_inputs.is_empty(), Error::::EmptyPublicInputs); - check::(circuit_id, version, proof, |_, _| Some(raw_inputs)) + check::(circuit_id, version, proof, |_| Some(raw_inputs)) } /// The layout a key of `arity` inputs gives `circuit_id`, if admitted. @@ -57,32 +55,41 @@ pub(crate) fn admitted_layout(circuit_id: CircuitId, arity: usize) -> Option( circuit_id: CircuitId, version: Option, proof: &[u8], - inputs: impl FnOnce(InputLayout, usize) -> Option>, + inputs: impl FnOnce(InputLayout) -> Option>, ) -> Result<(bool, u32), sp_runtime::DispatchError> { frame_support::ensure!(!proof.is_empty(), Error::::EmptyProof); let (key, resolved) = resolve_key::(circuit_id, version)?; - // Registration validated the key; if a stored one ever did not load, the - // proof fails rather than verifying under guessed inputs. + // 1. A key without a prepared form (storage written outside `keys`) is + // prepared here; one that does not prepare fails the proof. let prepared = match key { - StoredKey::Prepared(bytes) => prepared_from_stored(&bytes), - StoredKey::Raw(bytes) => VerifyingKey::new(bytes).prepare(), + StoredKey::Prepared(bytes) => Some(bytes), + StoredKey::Raw(bytes) => VerifyingKey::new(bytes).prepared_bytes().ok(), }; - let result = match prepared { - Ok(pvk) => { - let arity = pvk.vk.gamma_abc_g1.len().saturating_sub(1); + // 2–4. + let result = prepared.is_some_and(|prepared| { + prepared_arity(&prepared).is_some_and(|arity| { admitted_layout(circuit_id, arity) - .and_then(|layout| inputs(layout, arity)) - .is_some_and(|raw| do_verify(&pvk, proof, raw)) - } - Err(_) => false, - }; + .and_then(inputs) + .filter(|raw| raw.len() == arity) + .is_some_and(|raw| do_verify(&prepared, proof, &raw)) + }) + }); + // 5. record_stats::(circuit_id, resolved, result); Ok((result, resolved)) } @@ -123,26 +130,23 @@ fn record_stats(circuit_id: CircuitId, version: u32, result: bool) { }); } -/// The pairing check. +/// The pairing, under a key in prepared form: [`verify_prepared`], the same +/// function the node's `bn254_groth16_verify` host function runs. +/// +/// Always `true` in **test** builds, whose keys deserialize but carry no real +/// proofs. Benchmarks run the full pairing, so weights include it. /// -/// Always `true` in **test** builds: unit tests use keys that deserialize but -/// no real proofs. Benchmarks run the full pairing so weights reflect its cost. -fn do_verify(pvk: &PreparedVerifyingKey, proof: &[u8], raw_inputs: Vec<[u8; 32]>) -> bool { +/// [`verify_prepared`]: orbinum_zk_verifier::verify_prepared +fn do_verify(prepared: &[u8], proof: &[u8], inputs: &[[u8; 32]]) -> bool { #[cfg(test)] { - let _ = (pvk, proof, raw_inputs); + let _ = (prepared, proof, inputs); true } #[cfg(not(test))] { - use orbinum_zk_verifier::{Groth16Verifier, Proof, PublicInputs}; - Groth16Verifier::verify_with_prepared_vk( - pvk, - &PublicInputs::new(raw_inputs), - &Proof::new(proof.to_vec()), - ) - .is_ok() + orbinum_zk_verifier::verify_prepared(prepared, proof, &inputs.concat()) } } @@ -334,20 +338,41 @@ mod tests { }); } - /// Raw inputs are not encoded or counted here; the count check against the - /// key is ark-groth16's, which the test build stubs out. + /// Raw inputs are never encoded: any values pass as long as they fill the key. #[test] fn verify_raw_does_not_encode_inputs() { new_test_ext().execute_with(|| { insert_vk(CircuitId::TRANSFER, 1, KEY); activate(CircuitId::TRANSFER, 1); assert_eq!( - verify_raw::(CircuitId::TRANSFER, None, &proof(), vec![[0x02; 32]]), + verify_raw::(CircuitId::TRANSFER, None, &proof(), vec![[0x02; 32]; KEY]), Ok((true, 1)) ); }); } + /// Raw inputs that do not fill the key fail before the pairing, as a + /// statement's do. + #[test] + fn verify_raw_refuses_inputs_that_do_not_fill_the_key() { + new_test_ext().execute_with(|| { + insert_vk(CircuitId::TRANSFER, 1, KEY); + activate(CircuitId::TRANSFER, 1); + for count in [1, KEY - 1, KEY + 1] { + assert_eq!( + verify_raw::( + CircuitId::TRANSFER, + None, + &proof(), + vec![[0x02; 32]; count] + ), + Ok((false, 1)), + "{count} inputs" + ); + } + }); + } + // ── Version resolution ──────────────────────────────────────────────────── #[test] @@ -424,7 +449,12 @@ mod tests { new_test_ext().execute_with(|| { store(CircuitId::TRANSFER, 2, KEY); insert_key(CircuitId::TRANSFER, 2, vec![0x01; 100].try_into().unwrap()); - let res = verify_raw::(CircuitId::TRANSFER, Some(2), &proof(), vec![[0x02; 32]]); + let res = verify_raw::( + CircuitId::TRANSFER, + Some(2), + &proof(), + vec![[0x02; 32]; KEY], + ); assert_eq!(res, Ok((true, 2))); }); } @@ -513,7 +543,7 @@ mod tests { for cid in [CircuitId::TRANSFER, CircuitId::UNSHIELD] { insert_vk(cid, 1, KEY); } - let raw = || vec![[0x02; 32]]; + let raw = || vec![[0x02; 32]; KEY]; verify_raw::(CircuitId::TRANSFER, Some(1), &proof(), raw()).unwrap(); verify_raw::(CircuitId::UNSHIELD, Some(1), &proof(), raw()).unwrap(); verify_raw::(CircuitId::UNSHIELD, Some(1), &proof(), raw()).unwrap(); diff --git a/primitives/zk-verifier/CHANGELOG.md b/primitives/zk-verifier/CHANGELOG.md index ac0b3639..57140afc 100644 --- a/primitives/zk-verifier/CHANGELOG.md +++ b/primitives/zk-verifier/CHANGELOG.md @@ -6,6 +6,26 @@ All notable changes to this crate are documented here. ## [Unreleased] +## [3.2.0] - 2026-10-09 + +### Added + +- `host_interface` (feature `groth16-native`, in `default`): Groth16 + verification over BN254 as a native host function, `bn254_groth16_verify`, + from a key in its prepared form; `verify_proof` measured 2.43 → 0.61 ms once a + runtime uses it. Every malformed argument is `false` (proof not + `PROOF_BYTES`, inputs not a multiple of 32 bytes or not the key's arity, + non-canonical input, a key whose layout does not fit) and it never panics. +- It ships `#[version(1, register_only)]`: nodes register it, no runtime can + call it yet. A later runtime drops `register_only` once every node runs a + release with it; version 1 itself is frozen. +- `verify_prepared(prepared_vk, proof, inputs)`: verification on raw bytes from a + prepared key, checking every argument's shape first (`false` on any + malformation, never a panic). The host function's body, and what the runtime + runs in Wasm meanwhile: both paths run the same code. +- `prepared_arity`: a prepared key's input count, read from its layout without + deserializing a point. + ## [3.1.0] - 2026-10-09 ### Added diff --git a/primitives/zk-verifier/Cargo.toml b/primitives/zk-verifier/Cargo.toml index 6a63c8db..0bcf0304 100644 --- a/primitives/zk-verifier/Cargo.toml +++ b/primitives/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-zk-verifier" -version = "3.1.0" +version = "3.2.0" authors = ["Orbinum Network "] edition = "2021" publish = false @@ -34,6 +34,9 @@ orbinum-zk-core = { path = "../zk-core", default-features = false } parity-scale-codec = { version = "3.7.5", default-features = false, features = ["derive"], optional = true } scale-info = { version = "2.11", default-features = false, features = ["derive"], optional = true } +# Native host function (`groth16-native`) +sp-runtime-interface = { version = "37.0.0", default-features = false, optional = true } + # For parsing verification key from JSON (std only) num-bigint = { version = "0.4", default-features = false, optional = true } @@ -45,7 +48,7 @@ ark-snark = { version = "0.5.0", default-features = false } ark-std = { version = "0.5.0", default-features = false, features = ["std"] } [features] -default = ["std"] +default = ["std", "groth16-native"] # Standard library support (enables JSON parsing, BigInt, etc.) std = [ @@ -57,8 +60,14 @@ std = [ "ark-std/std", "num-bigint", "orbinum-zk-core/std", + "sp-runtime-interface?/std", ] +# Groth16 verification host function. `no_std`-compatible: generates the Wasm +# import and the native implementation. Ships `register_only` (see +# `src/host_interface.rs`). +groth16-native = ["sp-runtime-interface"] + # Substrate runtime integration (requires codec) substrate = [ "parity-scale-codec", diff --git a/primitives/zk-verifier/src/host_interface.rs b/primitives/zk-verifier/src/host_interface.rs new file mode 100644 index 00000000..72bcd028 --- /dev/null +++ b/primitives/zk-verifier/src/host_interface.rs @@ -0,0 +1,47 @@ +#![allow(unexpected_cfgs)] +//! Groth16 verification as a native host function (~3.5× faster than Wasm). +//! +//! - **Frozen.** Executed blocks call version 1 and syncing nodes re-run them: +//! any observable change is a new `#[version(N)]`, and version 1 stays. +//! - **`register_only`.** A node without the function cannot instantiate a +//! runtime that imports it, so nodes register it first and no runtime calls +//! it yet. A later runtime drops the flag once every node runs a release +//! with it (minimum author version for authors; RPC and archive by hand). + +use alloc::vec::Vec; +use sp_runtime_interface::{pass_by::PassFatPointerAndRead, runtime_interface}; + +/// Groth16 verification over BN254, natively. +#[runtime_interface] +pub trait Groth16HostInterface { + /// [`crate::verify_prepared`], natively. It must never panic: natively a + /// panic takes the node down instead of trapping the runtime. + #[version(1, register_only)] + fn bn254_groth16_verify( + prepared_vk: PassFatPointerAndRead<&[u8]>, + proof: PassFatPointerAndRead<&[u8]>, + inputs: PassFatPointerAndRead<&[u8]>, + ) -> bool { + crate::verify_prepared(prepared_vk, proof, inputs) + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use sp_runtime_interface::sp_wasm_interface::HostFunctions; + + /// The symbol runtimes import. Renaming the trait or the function changes + /// it, and a runtime built against the old one would not instantiate. + #[test] + fn the_registered_symbol_is_frozen() { + let names: alloc::vec::Vec<_> = + super::groth_16_host_interface::HostFunctions::host_functions() + .iter() + .map(|f| f.name()) + .collect(); + assert_eq!( + names, + ["ext_groth_16_host_interface_bn254_groth16_verify_version_1"] + ); + } +} diff --git a/primitives/zk-verifier/src/lib.rs b/primitives/zk-verifier/src/lib.rs index a77d70d1..7335b962 100644 --- a/primitives/zk-verifier/src/lib.rs +++ b/primitives/zk-verifier/src/lib.rs @@ -15,6 +15,8 @@ extern crate alloc; mod circuits; +#[cfg(feature = "groth16-native")] +pub mod host_interface; mod prepared; mod snarkjs; mod types; @@ -34,7 +36,7 @@ pub use circuits::{ CROSS_TREE_INPUTS, MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, }; -pub use prepared::{prepared_from_stored, MAX_PREPARED_VK_BYTES}; +pub use prepared::{prepared_arity, prepared_from_stored, verify_prepared, MAX_PREPARED_VK_BYTES}; pub use snarkjs::SnarkjsProofPoints; #[cfg(feature = "std")] pub use snarkjs::{parse_proof_from_snarkjs, parse_public_inputs_from_snarkjs}; diff --git a/primitives/zk-verifier/src/prepared.rs b/primitives/zk-verifier/src/prepared.rs index a4f7e671..a7da5bb2 100644 --- a/primitives/zk-verifier/src/prepared.rs +++ b/primitives/zk-verifier/src/prepared.rs @@ -11,7 +11,10 @@ use ark_bn254::Bn254; use ark_groth16::PreparedVerifyingKey; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; -use crate::{VerifierError, VerifyingKey, MAX_PUBLIC_INPUTS}; +use crate::{ + Groth16Verifier, Proof, PublicInputs, VerifierError, VerifyingKey, MAX_PUBLIC_INPUTS, + PROOF_BYTES, +}; /// Uncompressed sizes of the parts of a prepared key. mod layout { @@ -67,6 +70,52 @@ pub fn prepared_from_stored(bytes: &[u8]) -> Result, .map_err(|_| VerifierError::InvalidVerifyingKey) } +/// Whether `proof` verifies `inputs` under a key stored by +/// [`VerifyingKey::prepared_bytes`]. +/// +/// Both execution paths run this one function (the runtime in Wasm, the node +/// behind `bn254_groth16_verify`), so their answers cannot differ. +/// +/// - `proof`: [`PROOF_BYTES`], compressed. +/// - `inputs`: one 32-byte little-endian canonical field element per key input, +/// concatenated. +/// +/// Shapes are checked before any curve arithmetic; anything malformed is +/// `false`, never a panic. +pub fn verify_prepared(prepared_vk: &[u8], proof: &[u8], inputs: &[u8]) -> bool { + if proof.len() != PROOF_BYTES || !inputs.len().is_multiple_of(32) { + return false; + } + let Ok(pvk) = prepared_from_stored(prepared_vk) else { + return false; + }; + if pvk.vk.gamma_abc_g1.len() != inputs.len() / 32 + 1 { + return false; + } + let inputs = inputs + .chunks_exact(32) + .filter_map(|c| <[u8; 32]>::try_from(c).ok()) + .collect(); + Groth16Verifier::verify_with_prepared_vk( + &pvk, + &PublicInputs::new(inputs), + &Proof::new(proof.to_vec()), + ) + .is_ok() +} + +/// The input count of a key stored by [`VerifyingKey::prepared_bytes`], read +/// from its layout without deserializing a point. `None` where +/// [`prepared_from_stored`] refuses the layout. +pub fn prepared_arity(bytes: &[u8]) -> Option { + if !layout_fits(bytes) { + return None; + } + let points = bytes.get(layout::KEY_HEADER..layout::KEY_HEADER + layout::LEN)?; + let points = u64::from_le_bytes(points.try_into().ok()?); + usize::try_from(points).ok()?.checked_sub(1) +} + /// Whether `bytes` has exactly the layout of a prepared key: a `gamma_abc` /// count in range, then `-gamma` and `-delta` with [`layout::LINES`] lines each /// and a clear infinity flag, and nothing after. @@ -212,6 +261,26 @@ mod tests { } } + #[test] + fn prepared_arity_reads_the_input_count() { + for arity in [0, 1, 3, 9, MAX_PUBLIC_INPUTS] { + assert_eq!(prepared_arity(&stored(arity)), Some(arity)); + } + } + + #[test] + fn prepared_arity_refuses_what_the_loader_refuses() { + let good = stored(3); + let mut flag = good.clone(); + flag[GAMMA_AT_3 + LEN + LINES * LINE] = 1; + let mut prefix = good.clone(); + prefix[KEY_HEADER..KEY_HEADER + LEN].copy_from_slice(&u64::MAX.to_le_bytes()); + for bad in [&good[..good.len() - 1], &flag[..], &prefix[..], &[][..]] { + assert!(refused(bad)); + assert_eq!(prepared_arity(bad), None); + } + } + #[test] fn a_set_infinity_flag_is_refused() { let good = stored(3); diff --git a/primitives/zk-verifier/tests/real_proof.rs b/primitives/zk-verifier/tests/real_proof.rs index a3fb5734..4fc67014 100644 --- a/primitives/zk-verifier/tests/real_proof.rs +++ b/primitives/zk-verifier/tests/real_proof.rs @@ -15,7 +15,8 @@ use ark_snark::SNARK; use ark_std::rand::{rngs::StdRng, SeedableRng}; use orbinum_zk_verifier::{ - prepared_from_stored, Groth16Verifier, Proof, PublicInputs, VerifierError, VerifyingKey, + prepared_from_stored, verify_prepared, Groth16Verifier, Proof, PublicInputs, VerifierError, + VerifyingKey, }; /// Circuit proving knowledge of `a`, `b` with `a * b == c`, where `c` is public. @@ -176,6 +177,10 @@ fn a_corrupted_stored_key_never_verifies_an_invalid_proof() { let at = rng.gen_range(0..bad.len()); bad[at] ^= 1 << rng.gen_range(0..8); let Ok(pvk) = prepared_from_stored(&bad) else { + assert!( + !flat(&bad, &proof.bytes, &right), + "byte {at}: refused, yet verified" + ); continue; }; loaded += 1; @@ -183,9 +188,16 @@ fn a_corrupted_stored_key_never_verifies_an_invalid_proof() { Groth16Verifier::verify_with_prepared_vk(&pvk, &wrong, &proof).is_err(), "a flip at byte {at} let a wrong input verify" ); - if Groth16Verifier::verify_with_prepared_vk(&pvk, &right, &proof).is_ok() { + let valid = Groth16Verifier::verify_with_prepared_vk(&pvk, &right, &proof).is_ok(); + if valid { still_valid += 1; } + assert!(!flat(&bad, &proof.bytes, &wrong), "byte {at}: diverged"); + assert_eq!( + flat(&bad, &proof.bytes, &right), + valid, + "byte {at}: diverged" + ); } // Most flips land in a coordinate and load; the pairing then rejects them. A // few land in `beta`/`gamma`/`delta` of the embedded key, which verification @@ -193,3 +205,150 @@ fn a_corrupted_stored_key_never_verifies_an_invalid_proof() { assert!(loaded > 300, "only {loaded} corrupted keys loaded"); println!("{loaded} corrupted keys loaded, {still_valid} still verify the valid proof"); } + +// ─── verify_prepared ────────────────────────────────────────────────────────── + +/// [`verify_prepared`] on a statement, with its inputs concatenated. +fn flat(prepared: &[u8], proof: &[u8], inputs: &PublicInputs) -> bool { + verify_prepared(prepared, proof, &inputs.inputs.concat()) +} + +/// Answers as an independent oracle (the raw key, prepared from scratch): the +/// valid statement passes, a wrong or non-canonical input fails. +#[test] +fn verify_prepared_answers_as_the_raw_key() { + let (vk, proof, inputs) = setup_and_prove(); + let prepared = vk.prepared_bytes().unwrap(); + let mut wrong = [0u8; 32]; + wrong[0] = 34; + let mut non_canonical = inputs[0]; + // `c + p`: same field element, non-canonical bytes. `c = 33` and `p` leaves + // the top byte room, so the sum fits. + let p = ark_bn254::Fr::MODULUS.to_bytes_le(); + let mut carry = 0u16; + for (byte, p) in non_canonical.iter_mut().zip(p) { + let v = *byte as u16 + p as u16 + carry; + *byte = v as u8; + carry = v >> 8; + } + assert_eq!(carry, 0, "c + p must fit in 32 bytes"); + for (statement, expected) in [ + (PublicInputs::new(inputs), true), + (PublicInputs::new(vec![wrong]), false), + (PublicInputs::new(vec![non_canonical]), false), + ] { + assert_eq!( + Groth16Verifier::verify(&vk, &statement, &proof).is_ok(), + expected + ); + assert_eq!(flat(&prepared, &proof.bytes, &statement), expected); + } +} + +/// Every malformed argument is `false`, never a panic. +#[test] +fn verify_prepared_refuses_malformed_arguments() { + let (vk, proof, inputs) = setup_and_prove(); + let prepared = vk.prepared_bytes().unwrap(); + let flat = inputs.concat(); + let verify = verify_prepared; + assert!( + verify(&prepared, &proof.bytes, &flat), + "the well-formed call verifies" + ); + + let mut longer_proof = proof.bytes.clone(); + longer_proof.push(0); + for bad_proof in [ + &[][..], + &proof.bytes[..127], + &longer_proof[..], + &[0xFF; 128][..], + ] { + assert!( + !verify(&prepared, bad_proof, &flat), + "proof of {} bytes", + bad_proof.len() + ); + } + + let two_inputs = [flat.clone(), flat.clone()].concat(); + for bad_inputs in [ + &[][..], + &flat[..31], + &[flat.clone(), vec![0]].concat()[..], + &two_inputs[..], + ] { + assert!( + !verify(&prepared, &proof.bytes, bad_inputs), + "inputs of {} bytes", + bad_inputs.len() + ); + } + + let mut longer_key = prepared.clone(); + longer_key.push(0); + let mut absurd = prepared.clone(); + absurd[64 + 3 * 128..64 + 3 * 128 + 8].copy_from_slice(&u64::MAX.to_le_bytes()); + for bad_key in [ + &[][..], + &prepared[..prepared.len() - 1], + &longer_key[..], + &absurd[..], + &vk.bytes[..], + ] { + assert!( + !verify(bad_key, &proof.bytes, &flat), + "key of {} bytes", + bad_key.len() + ); + } + assert!(!verify(&[], &[], &[])); +} + +/// Random proofs never verify and never panic. +#[test] +fn random_proofs_never_verify() { + use ark_std::rand::RngCore; + let (vk, _, inputs) = setup_and_prove(); + let prepared = vk.prepared_bytes().unwrap(); + let flat_inputs = inputs.concat(); + let mut rng = StdRng::seed_from_u64(11); + for _ in 0..2000 { + let mut proof = [0u8; 128]; + rng.fill_bytes(&mut proof); + assert!(!verify_prepared(&prepared, &proof, &flat_inputs)); + } +} + +/// No single-bit change to a valid proof verifies, nor swapping `A` and `C`. +#[test] +fn a_mutated_valid_proof_never_verifies() { + let (vk, proof, inputs) = setup_and_prove(); + let prepared = vk.prepared_bytes().unwrap(); + let flat_inputs = inputs.concat(); + assert!(verify_prepared(&prepared, &proof.bytes, &flat_inputs)); + for bit in 0..proof.bytes.len() * 8 { + let mut bad = proof.bytes.clone(); + bad[bit / 8] ^= 1 << (bit % 8); + assert!(!verify_prepared(&prepared, &bad, &flat_inputs), "bit {bit}"); + } + let mut swapped = proof.bytes.clone(); + swapped[..32].copy_from_slice(&proof.bytes[96..]); + swapped[96..].copy_from_slice(&proof.bytes[..32]); + assert!(!verify_prepared(&prepared, &swapped, &flat_inputs)); +} + +/// Random public inputs never verify the valid proof. +#[test] +fn random_inputs_never_verify() { + use ark_std::rand::RngCore; + let (vk, proof, _) = setup_and_prove(); + let prepared = vk.prepared_bytes().unwrap(); + let mut rng = StdRng::seed_from_u64(13); + for _ in 0..500 { + let mut input = [0u8; 32]; + rng.fill_bytes(&mut input); + assert!(!verify_prepared(&prepared, &proof.bytes, &input)); + } +} diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index 28e2aafb..f4de9419 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -100,6 +100,7 @@ fp-rpc = { workspace = true, features = ["default"] } # Orbinum: ZK primitives with native Poseidon host interface orbinum-runtime = { workspace = true, features = ["std"] } orbinum-zk-core = { workspace = true, features = ["std"] } +orbinum-zk-verifier = { workspace = true, features = ["std", "groth16-native"] } # Cumulus primitives cumulus-primitives-proof-size-hostfunction = { workspace = true } diff --git a/template/node/src/service.rs b/template/node/src/service.rs index 7ec559e7..c3a92881 100644 --- a/template/node/src/service.rs +++ b/template/node/src/service.rs @@ -40,6 +40,7 @@ pub type HostFunctions = ( sp_io::SubstrateHostFunctions, frame_benchmarking::benchmarking::HostFunctions, cumulus_primitives_proof_size_hostfunction::storage_proof_size::HostFunctions, + orbinum_zk_verifier::host_interface::groth_16_host_interface::HostFunctions, orbinum_zk_core::host_interface::poseidon_host_interface::HostFunctions, ); #[cfg(all(feature = "runtime-benchmarks", not(feature = "poseidon-native")))] @@ -47,18 +48,21 @@ pub type HostFunctions = ( sp_io::SubstrateHostFunctions, frame_benchmarking::benchmarking::HostFunctions, cumulus_primitives_proof_size_hostfunction::storage_proof_size::HostFunctions, + orbinum_zk_verifier::host_interface::groth_16_host_interface::HostFunctions, ); /// Otherwise we use empty host functions for ext host functions. #[cfg(all(not(feature = "runtime-benchmarks"), feature = "poseidon-native"))] pub type HostFunctions = ( sp_io::SubstrateHostFunctions, cumulus_primitives_proof_size_hostfunction::storage_proof_size::HostFunctions, + orbinum_zk_verifier::host_interface::groth_16_host_interface::HostFunctions, orbinum_zk_core::host_interface::poseidon_host_interface::HostFunctions, ); #[cfg(all(not(feature = "runtime-benchmarks"), not(feature = "poseidon-native")))] pub type HostFunctions = ( sp_io::SubstrateHostFunctions, cumulus_primitives_proof_size_hostfunction::storage_proof_size::HostFunctions, + orbinum_zk_verifier::host_interface::groth_16_host_interface::HostFunctions, ); pub type Backend = FullBackend; @@ -910,3 +914,20 @@ pub fn new_chain_ops( )?; Ok((client, backend, import_queue, task_manager, other.3)) } + +#[cfg(test)] +mod tests { + use super::{HostFunctions, HostFunctionsT}; + + /// Every build registers `bn254_groth16_verify`, so a later runtime can call + /// it on any node from this release on. + #[test] + fn the_node_registers_the_groth16_host_function() { + let names: Vec<_> = HostFunctions::host_functions() + .iter() + .map(|f| f.name()) + .collect(); + assert!(names.contains(&"ext_groth_16_host_interface_bn254_groth16_verify_version_1")); + assert!(names.contains(&"ext_poseidon_host_interface_poseidon_hash_2_version_1")); + } +} diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index 7979cb6b..849c0d35 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -98,6 +98,7 @@ polkadot-runtime-common = { workspace = true } substrate-wasm-builder = { workspace = true, optional = true, features = ["metadata-hash"] } [dev-dependencies] +sp-maybe-compressed-blob = "11.1.0" # Only the consensus-binding tests build fixtures from it. grandpa-verifier-primitives = { workspace = true, features = ["std"] } diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 817b0128..e62c0b11 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -27,8 +27,8 @@ transfer can spend two notes from different trees. `transaction_version` moves: `private_transfer` (call 1) takes `merkle_roots: [Hash; 2]` in place of `merkle_root`. Validator-set only gains calls 6 and 7. Ships with `orbinum-runtime` / `orbinum-node` 0.4.0, `pallet-validator-set` 0.4.0, -`pallet-shielded-pool` 0.23.0, `pallet-zk-verifier` 0.17.0, -`orbinum-zk-verifier` 3.1.0 and `pallet-evm-precompile-shielded-pool` 0.9.0; +`pallet-shielded-pool` 0.23.0, `pallet-zk-verifier` 0.17.1, +`orbinum-zk-verifier` 3.2.0 and `pallet-evm-precompile-shielded-pool` 0.9.0; node 0.4.0 is the first binary that declares its version. #### 1 · Minimum author version @@ -101,6 +101,14 @@ active.** - **zk-verifier:** keys are stored prepared (`PreparedKeys`), so a proof no longer prepares its key: verification ~2.2× cheaper. Storage v2; `MigrateToV2` prepares the keys already registered, during the upgrade. +- **Node 0.4.0** registers the `bn254_groth16_verify` host function + (`register_only`): this runtime does not call it, so nodes without it keep + working. A later spec turns it on (a transfer validated in ~1.5 ms instead of + ~4.6 ms on a dev node). That runtime imports + `ext_groth_16_host_interface_bn254_groth16_verify_version_1`, and a node without + it stops importing at the upgrade block, for good (reproduced). Before that + `setCode`: every RPC, archive and validator node on 0.4.0+, 2/3 of the authors + declaring it (`lastAuthorVersion`), and `setMinAuthorVersion(0.4.0)`. **After the upgrade, by Root:** register the v3 keys from `@orbinum/circuits` **0.17.1** (release ceremony, beacon = testnet block #1190708), activate both with diff --git a/template/runtime/src/runtime_tests.rs b/template/runtime/src/runtime_tests.rs index 4ba683dd..58ed2075 100644 --- a/template/runtime/src/runtime_tests.rs +++ b/template/runtime/src/runtime_tests.rs @@ -585,3 +585,23 @@ fn signed_extra_implicit_succeeds_with_check_metadata_hash() { .expect("SignedPayload::new must succeed with disabled CheckMetadataHash"); }); } + +// ── Host functions ──────────────────────────────────────────────────────────── + +/// The runtime must not import `bn254_groth16_verify` before every node +/// registers it: a node without it cannot instantiate the runtime and stops +/// importing. Flip when a runtime turns it on. +#[test] +fn the_runtime_does_not_import_the_groth16_host_function_yet() { + let Some(blob) = crate::WASM_BINARY else { + return; // SKIP_WASM_BUILD + }; + // Release builds embed the compressed blob. + let wasm = sp_maybe_compressed_blob::decompress(blob, 64 << 20).expect("runtime blob"); + let imports = |symbol: &[u8]| wasm.windows(symbol.len()).any(|w| w == symbol); + // The search works: Poseidon's host function is imported. + assert!(imports(b"ext_poseidon_host_interface_poseidon_hash_2")); + assert!(!imports( + b"ext_groth_16_host_interface_bn254_groth16_verify" + )); +}