diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22b6b840..7654d59e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,6 +75,13 @@ jobs: - name: Check workspace run: cargo check --release --workspace + # zk-core must build in a no_std/WASM context. Guard against a consumer or + # feature change that leaks `std` into the runtime build. + - name: Check zk-core no_std feature matrix + run: | + cargo check -p orbinum-zk-core --no-default-features + cargo check -p orbinum-zk-core --no-default-features --features poseidon-native + # Step 3: Build workspace - compiles once for all subsequent jobs to reuse build: name: Build diff --git a/primitives/zk-core/CHANGELOG.md b/primitives/zk-core/CHANGELOG.md index a5dace79..3b5124fc 100644 --- a/primitives/zk-core/CHANGELOG.md +++ b/primitives/zk-core/CHANGELOG.md @@ -4,6 +4,61 @@ All notable changes to this crate are documented here. --- +## [1.1.0] — 2026-07-02 + +### Security +- Poseidon hashing no longer uses `.expect()` / `panic!`. All arities + (`hash_2`, `hash_4`, `hash_5`, `poseidon_hash_1`) now route through a single + internal `poseidon_circom` helper that returns a deterministic value instead of + panicking. This closes a consensus-divergence risk: a native panic aborts the + node process while a WASM panic is a recoverable trap, so the same malformed + input could split the network. The failure path is provably unreachable (constant + arity, reduced `Fr` inputs) and is now documented as an invariant. The fallback + value is a fixed non-zero marker, never zero, so it cannot be mistaken for the + all-zero dummy sentinel (a zero nullifier is skipped rather than marked spent). +- Poseidon host functions (`poseidon_hash_2`, `poseidon_hash_4`) no longer + `assert_eq!` input length. A failed assert in a native host function aborts the + node process. Inputs are now read through a `read_32_le` helper that zero-pads or + truncates to 32 bytes deterministically and never panics. +- The 32-byte field↔bytes conversions (`field_to_bytes`, `bytes_to_field`, host + output) now copy by length instead of slicing `[..32]`, removing a latent panic + if a bigint ever serialized to fewer than 32 bytes. + +### Changed +- `Note::is_zero` now matches the system-wide dummy definition (`value == 0`) + instead of also requiring `asset_id`/`owner_pubkey`/`blinding` to be zero. The + circuit and shielded-pool treat any value-zero input as dummy; the previous + stricter check could disagree. Documented on both `is_zero` and `zero`. + +### Documentation +- Documented in `Cargo.toml` that runtime/WASM consumers must depend on this crate + with `default-features = false` (else `std` leaks into the `no_std` build), and + added a CI feature-matrix check to enforce it. + +### Added +- `FieldElement::is_canonical_le` and `FieldElement::from_canonical_le` — reject + non-canonical little-endian byte encodings (values `>= p`). Trust boundaries that + ingest raw bytes (nullifiers, commitments, external addresses) should use these so + that `x` and `x + p` cannot masquerade as distinct values while hashing/proving + identically. +- Documented the consensus invariant `Native(x) == Wasm(x) == Circuit(x)` in the + crate docs. + +### Tests +- Added `hash_no_panic_on_boundary_inputs`, `hash_boundary_inputs_are_deterministic`, + and `poseidon_circom_core_matches_trait` covering boundary field elements + (`0` and `p-1`) and native/WASM path equivalence. +- Added `read_32_le_pads_and_truncates`, `hash_2_no_panic_on_short_input`, + `hash_4_no_panic_on_short_input`, and `hash_2_still_correct_for_32_bytes` + covering non-32-byte host-function inputs. +- Added `tests/poseidon_vectors.rs`: known-answer vectors against canonical + circomlib BN254 Poseidon outputs (arities 1/2/4/5) and native≡WASM equivalence + checks. The vectors catch any parameter drift between `light-poseidon-nostd` and + the compiled circuit. +- Added canonical-encoding tests including the `n` vs `n+p` collision vector. + +--- + ## [1.0.1] — 2026-05-14 ### Changed diff --git a/primitives/zk-core/Cargo.toml b/primitives/zk-core/Cargo.toml index 770c0d26..f4be3eaa 100644 --- a/primitives/zk-core/Cargo.toml +++ b/primitives/zk-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-zk-core" -version = "1.0.1" +version = "1.1.0" authors = ["Orbinum Network "] edition = "2021" license = "Apache-2.0 OR GPL-3.0-or-later" @@ -40,6 +40,14 @@ ark-std = { version = "0.5.0", default-features = false, features = ["std"] } serde_json = "1.0" [features] +# `default` enables `std` + native host functions for host tooling and tests. +# +# IMPORTANT: runtime/WASM consumers MUST depend on this crate with +# `default-features = false` and opt into `poseidon-native` explicitly, otherwise +# they pull in `std` (breaks the `no_std` WASM build). All in-tree consumers do +# this correctly; verify with: +# cargo build -p orbinum-zk-core --no-default-features +# cargo build -p orbinum-zk-core --no-default-features --features poseidon-native default = ["std", "poseidon-native"] std = [ "ark-bn254/std", @@ -50,5 +58,6 @@ std = [ "sp-runtime-interface/std", ] -# Native Poseidon hashing - enabled by default for optimal performance +# Native Poseidon host functions. `no_std`-compatible: generates the WASM-side +# import plus the native implementation. Enable explicitly in runtime consumers. poseidon-native = ["sp-runtime-interface"] diff --git a/primitives/zk-core/src/hash.rs b/primitives/zk-core/src/hash.rs index c0b15b8e..f3d85e79 100644 --- a/primitives/zk-core/src/hash.rs +++ b/primitives/zk-core/src/hash.rs @@ -9,6 +9,63 @@ use crate::types::FieldElement; use ark_bn254::Fr; use light_poseidon_nostd::{Poseidon, PoseidonHasher as LightHasher}; +// ─── Circom Poseidon core ─────────────────────────────────────────────────────── + +/// Compute the circomlib Poseidon hash for a fixed arity. +/// +/// # Consensus determinism +/// +/// This is the ONLY place that invokes `light-poseidon-nostd`. Both the WASM path +/// ([`LightPoseidonHasher`]) and the native path ([`NativePoseidonHasher`], via the +/// host function that internally calls `LightPoseidonHasher`) go through here, so +/// their behavior is identical by construction. +/// +/// # Invariant (why it cannot fail) +/// +/// - `new_circom(nr_inputs)` only fails with `InvalidWidthCircom` when +/// `nr_inputs + 1 > MAX_X5_LEN (= 13)`. The arities in use (1, 2, 4, 5) yield +/// width 2/3/5/6, always valid. +/// - `hash(&inputs)` only fails with `InvalidNumberOfInputs` when +/// `inputs.len() != nr_inputs`. Callers pass fixed-length arrays matching the +/// arity. +/// +/// Inputs are already-reduced `Fr` (not raw bytes), so no parsing/range error +/// variant applies. +/// +/// # Failure behavior (unreachable) +/// +/// If the invariant were ever violated (a programming bug), we return a +/// **deterministic** value instead of `panic!`. This guarantees native and WASM +/// never diverge in behavior: a native `panic!` aborts the node process while in +/// WASM it is a recoverable trap — a divergence that would break consensus. Never +/// introduce `.expect()`/`panic!` in this path. +/// +/// The fallback is deliberately NOT zero: a zero commitment/nullifier is the dummy +/// sentinel elsewhere in the system, so a zero fallback could be silently skipped +/// (e.g. an unspent nullifier). We return a fixed non-zero domain-separated marker +/// so any downstream use of a fallback value stands out rather than masquerading as +/// a dummy. This value is never produced by a real Poseidon hash of valid inputs in +/// practice, and the branch is unreachable regardless. +#[inline] +fn poseidon_circom(nr_inputs: usize, inputs: &[Fr]) -> Fr { + debug_assert_eq!( + inputs.len(), + nr_inputs, + "poseidon_circom: arity mismatch (broken invariant)" + ); + match Poseidon::::new_circom(nr_inputs).and_then(|mut p| p.hash(inputs)) { + Ok(result) => result, + // Unreachable with constant arity and `Fr` inputs (see invariant above). + // Non-zero deterministic marker: identical on native and WASM, never panics, + // and never collides with the all-zero dummy sentinel. + Err(_) => Fr::from(POSEIDON_FALLBACK_MARKER_U64), + } +} + +/// Non-zero, deterministic fallback for the unreachable Poseidon failure branch. +/// Chosen so it cannot be confused with the all-zero dummy sentinel. +const POSEIDON_FALLBACK_MARKER_U64: u64 = 0xDEAD_BEEF_DEAD_BEEF; + // ─── Trait ──────────────────────────────────────────────────────────────────── /// Abstraction over Poseidon hash implementations. @@ -33,37 +90,34 @@ pub struct LightPoseidonHasher; impl PoseidonHasher for LightPoseidonHasher { fn hash_2(&self, inputs: [FieldElement; 2]) -> FieldElement { - let result = Poseidon::::new_circom(2) - .expect("Poseidon init (2 inputs) failed") - .hash(&[inputs[0].inner(), inputs[1].inner()]) - .expect("Poseidon hash_2 failed"); + let result = poseidon_circom(2, &[inputs[0].inner(), inputs[1].inner()]); FieldElement::new(result) } fn hash_4(&self, inputs: [FieldElement; 4]) -> FieldElement { - let result = Poseidon::::new_circom(4) - .expect("Poseidon init (4 inputs) failed") - .hash(&[ + let result = poseidon_circom( + 4, + &[ inputs[0].inner(), inputs[1].inner(), inputs[2].inner(), inputs[3].inner(), - ]) - .expect("Poseidon hash_4 failed"); + ], + ); FieldElement::new(result) } fn hash_5(&self, inputs: [FieldElement; 5]) -> FieldElement { - let result = Poseidon::::new_circom(5) - .expect("Poseidon init (5 inputs) failed") - .hash(&[ + let result = poseidon_circom( + 5, + &[ inputs[0].inner(), inputs[1].inner(), inputs[2].inner(), inputs[3].inner(), inputs[4].inner(), - ]) - .expect("Poseidon hash_5 failed"); + ], + ); FieldElement::new(result) } } @@ -109,7 +163,8 @@ fn field_to_bytes(field: Fr) -> [u8; 32] { use ark_ff::{BigInteger, PrimeField}; let bytes = field.into_bigint().to_bytes_le(); let mut result = [0u8; 32]; - result.copy_from_slice(&bytes[..32]); + let n = bytes.len().min(32); + result[..n].copy_from_slice(&bytes[..n]); result } @@ -117,7 +172,8 @@ fn field_to_bytes(field: Fr) -> [u8; 32] { fn bytes_to_field(bytes: &[u8]) -> FieldElement { use ark_ff::PrimeField; let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes[..32]); + let n = bytes.len().min(32); + arr[..n].copy_from_slice(&bytes[..n]); FieldElement::new(Fr::from_le_bytes_mod_order(&arr)) } @@ -131,10 +187,7 @@ fn bytes_to_field(bytes: &[u8]) -> FieldElement { /// Single-input Poseidon is intentionally not part of [`PoseidonHasher`] because /// it is only needed for the value_proof circuit. pub fn poseidon_hash_1(input: FieldElement) -> FieldElement { - let result = Poseidon::::new_circom(1) - .expect("Poseidon init (1 input) failed") - .hash(&[input.inner()]) - .expect("Poseidon hash_1 failed"); + let result = poseidon_circom(1, &[input.inner()]); FieldElement::new(result) } @@ -216,4 +269,79 @@ mod tests { let h2 = LightPoseidonHasher.hash_2([input, FieldElement::from_u64(0)]); assert_ne!(h1, h2); } + + // ─── No panic on boundary inputs, deterministic behavior ─────────────────── + + /// `Fr = p - 1`, the largest canonical field element. Must not panic or diverge. + fn field_max() -> FieldElement { + use ark_ff::Field; + // -1 in the field == p - 1. + FieldElement::new(-Fr::ONE) + } + + #[test] + fn hash_no_panic_on_boundary_inputs() { + let h = LightPoseidonHasher; + let zero = FieldElement::zero(); + let max = field_max(); + // None of these calls must panic (they previously used `.expect()`). + let _ = h.hash_2([zero, max]); + let _ = h.hash_2([max, max]); + let _ = h.hash_4([zero, max, zero, max]); + let _ = h.hash_5([max, max, max, max, max]); + let _ = poseidon_hash_1(max); + } + + #[test] + fn hash_boundary_inputs_are_deterministic() { + let h = LightPoseidonHasher; + let max = field_max(); + assert_eq!(h.hash_2([max, max]), h.hash_2([max, max])); + assert_eq!( + h.hash_4([max, max, max, max]), + h.hash_4([max, max, max, max]) + ); + assert_eq!(poseidon_hash_1(max), poseidon_hash_1(max)); + } + + #[test] + fn poseidon_circom_core_matches_trait() { + // The internal core and the trait produce the same result (shared path). + let a = FieldElement::from_u64(7); + let b = FieldElement::from_u64(11); + let via_trait = LightPoseidonHasher.hash_2([a, b]); + let via_core = FieldElement::new(poseidon_circom(2, &[a.inner(), b.inner()])); + assert_eq!(via_trait, via_core); + } + + // ─── Defensive 32-byte conversion (no panic on short/long) ───────────────── + + #[cfg(feature = "poseidon-native")] + #[test] + fn field_to_bytes_roundtrips_boundary_values() { + use ark_ff::Field; + // 0, 1, and p-1 must all round-trip through the 32-byte conversion. + for f in [Fr::from(0u64), Fr::from(1u64), -Fr::ONE] { + let bytes = field_to_bytes(f); + assert_eq!(bytes.len(), 32); + let back = bytes_to_field(&bytes); + assert_eq!(back, FieldElement::new(f)); + } + } + + #[cfg(feature = "poseidon-native")] + #[test] + fn bytes_to_field_no_panic_on_short_or_long() { + // Shorter and longer inputs must not panic (previously copy_from_slice would). + let _ = bytes_to_field(&[1u8, 2, 3]); + let _ = bytes_to_field(&[]); + let _ = bytes_to_field(&[0xABu8; 40]); + } + + #[test] + fn fallback_marker_is_non_zero() { + // The unreachable failure branch must never yield the all-zero dummy sentinel. + assert_ne!(super::POSEIDON_FALLBACK_MARKER_U64, 0); + assert!(!FieldElement::new(Fr::from(super::POSEIDON_FALLBACK_MARKER_U64)).is_zero()); + } } diff --git a/primitives/zk-core/src/host_interface.rs b/primitives/zk-core/src/host_interface.rs index 31edebba..3bad20f8 100644 --- a/primitives/zk-core/src/host_interface.rs +++ b/primitives/zk-core/src/host_interface.rs @@ -10,6 +10,21 @@ use sp_runtime_interface::{ runtime_interface, }; +/// Read an input slice into a fixed 32-byte little-endian array. +/// +/// Copies at most 32 bytes; a shorter input is zero-padded, a longer one is +/// truncated. This NEVER panics — critical because these functions run as native +/// host functions, where a panic aborts the node process (whereas in WASM it is a +/// recoverable trap), which would break consensus. Callers already pass exactly +/// 32 bytes, so this is purely defensive and deterministic across native/WASM. +#[inline] +fn read_32_le(bytes: &[u8]) -> [u8; 32] { + let mut arr = [0u8; 32]; + let n = bytes.len().min(32); + arr[..n].copy_from_slice(&bytes[..n]); + arr +} + /// Native runtime interface for Poseidon hash operations. #[runtime_interface] pub trait PoseidonHostInterface { @@ -25,13 +40,8 @@ pub trait PoseidonHostInterface { use ark_bn254::Fr; use ark_ff::{BigInteger, PrimeField}; - assert_eq!((*left).len(), 32, "Left input must be 32 bytes"); - assert_eq!((*right).len(), 32, "Right input must be 32 bytes"); - - let mut left_arr = [0u8; 32]; - let mut right_arr = [0u8; 32]; - left_arr.copy_from_slice(left); - right_arr.copy_from_slice(right); + let left_arr = super::read_32_le(left); + let right_arr = super::read_32_le(right); let left_fr = Fr::from_le_bytes_mod_order(&left_arr); let right_fr = Fr::from_le_bytes_mod_order(&right_arr); @@ -40,7 +50,7 @@ pub trait PoseidonHostInterface { let result = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]); let bytes = result.inner().into_bigint().to_bytes_le(); - bytes[..32].to_vec() + super::read_32_le(&bytes).to_vec() } /// Hash four 32-byte inputs (note commitment). @@ -57,19 +67,10 @@ pub trait PoseidonHostInterface { use ark_bn254::Fr; use ark_ff::{BigInteger, PrimeField}; - assert_eq!((*input1).len(), 32, "Input1 must be 32 bytes"); - assert_eq!((*input2).len(), 32, "Input2 must be 32 bytes"); - assert_eq!((*input3).len(), 32, "Input3 must be 32 bytes"); - assert_eq!((*input4).len(), 32, "Input4 must be 32 bytes"); - - let mut arr1 = [0u8; 32]; - let mut arr2 = [0u8; 32]; - let mut arr3 = [0u8; 32]; - let mut arr4 = [0u8; 32]; - arr1.copy_from_slice(input1); - arr2.copy_from_slice(input2); - arr3.copy_from_slice(input3); - arr4.copy_from_slice(input4); + let arr1 = super::read_32_le(input1); + let arr2 = super::read_32_le(input2); + let arr3 = super::read_32_le(input3); + let arr4 = super::read_32_le(input4); let frs = [ Fr::from_le_bytes_mod_order(&arr1), @@ -87,7 +88,7 @@ pub trait PoseidonHostInterface { ]); let bytes = result.inner().into_bigint().to_bytes_le(); - bytes[..32].to_vec() + super::read_32_le(&bytes).to_vec() } } @@ -186,4 +187,53 @@ mod tests { let from_host_fe = FieldElement::new(Fr::from_le_bytes_mod_order(&arr)); assert_eq!(from_trait, from_host_fe); } + + // ─── No panic on non-32-byte inputs (host functions must never panic) ────── + + #[test] + fn read_32_le_pads_and_truncates() { + // Shorter input is zero-padded on the right. + assert_eq!(super::read_32_le(&[1, 2, 3]), { + let mut e = [0u8; 32]; + e[..3].copy_from_slice(&[1, 2, 3]); + e + }); + // Exact length is copied verbatim. + assert_eq!(super::read_32_le(&[0xAB; 32]), [0xAB; 32]); + // Longer input is truncated to the first 32 bytes. + assert_eq!(super::read_32_le(&[0xCD; 40]), [0xCD; 32]); + // Empty input yields all zeros. + assert_eq!(super::read_32_le(&[]), [0u8; 32]); + } + + #[test] + fn hash_2_no_panic_on_short_input() { + // Previously asserted len == 32 and would panic (aborting a native node). + let short = alloc::vec![7u8; 10]; + let long = alloc::vec![9u8; 40]; + let _ = poseidon_host_interface::poseidon_hash_2(&short, &u64_to_bytes(1)); + let _ = poseidon_host_interface::poseidon_hash_2(&u64_to_bytes(1), &long); + let _ = poseidon_host_interface::poseidon_hash_2(&[], &[]); + } + + #[test] + fn hash_4_no_panic_on_short_input() { + let short = alloc::vec![7u8; 5]; + let _ = poseidon_host_interface::poseidon_hash_4( + &short, + &u64_to_bytes(2), + &u64_to_bytes(3), + &alloc::vec![9u8; 33], + ); + } + + #[test] + fn hash_2_still_correct_for_32_bytes() { + // The 32-byte path must be unchanged (no regression). + let a = poseidon_host_interface::poseidon_hash_2(&u64_to_bytes(42), &u64_to_bytes(100)); + let b = poseidon_host_interface::poseidon_hash_2(&u64_to_bytes(42), &u64_to_bytes(100)); + assert_eq!(a, b); + assert_eq!(a.len(), 32); + assert!(!bytes_to_fr(&a).is_zero()); + } } diff --git a/primitives/zk-core/src/lib.rs b/primitives/zk-core/src/lib.rs index f05dc52f..64ef00f6 100644 --- a/primitives/zk-core/src/lib.rs +++ b/primitives/zk-core/src/lib.rs @@ -17,6 +17,14 @@ //! ## No-std //! //! The crate is `no_std` compatible by default; enable `std` for standard library support. +//! +//! ## Consensus invariant +//! +//! `NativePoseidonHasher(x) == LightPoseidonHasher(x)` for all `x`, and both must +//! equal the Poseidon of the compiled Circom circuit. The native and WASM paths +//! must never diverge (a divergence forks the chain). This is enforced by the +//! known-answer vectors and native≡WASM tests in `tests/poseidon_vectors.rs`; the +//! circuit remains the source of truth for the parameters. #![cfg_attr(not(feature = "std"), no_std)] diff --git a/primitives/zk-core/src/types.rs b/primitives/zk-core/src/types.rs index f7fa08e1..987872b6 100644 --- a/primitives/zk-core/src/types.rs +++ b/primitives/zk-core/src/types.rs @@ -43,6 +43,30 @@ impl FieldElement { pub fn is_zero(&self) -> bool { self.0 == Fr::from(0u64) } + + /// Returns `true` if `bytes` is the canonical little-endian encoding of a + /// field element, i.e. it represents a value strictly less than the modulus `p`. + /// + /// Because byte-to-field conversion reduces modulo `p`, non-canonical encodings + /// (`>= p`) map to the same element as their reduced form. Callers that store or + /// compare raw bytes should reject non-canonical inputs at the trust boundary. + pub fn is_canonical_le(bytes: &[u8; 32]) -> bool { + use ark_ff::{BigInteger, PrimeField}; + let fe = Fr::from_le_bytes_mod_order(bytes); + // Canonical iff re-encoding the reduced element is byte-identical. + fe.into_bigint().to_bytes_le().as_slice() == &bytes[..] + } + + /// Builds a field element from its canonical little-endian encoding, returning + /// `None` for non-canonical byte strings (`>= p`). See [`Self::is_canonical_le`]. + pub fn from_canonical_le(bytes: &[u8; 32]) -> Option { + use ark_ff::PrimeField; + if Self::is_canonical_le(bytes) { + Some(Self(Fr::from_le_bytes_mod_order(bytes))) + } else { + None + } + } } impl From for FieldElement { @@ -153,7 +177,9 @@ impl Note { } } - /// A zero / dummy note used to pad circuit inputs. + /// A canonical dummy note used to pad circuit inputs: every field is zero. + /// + /// Use this to construct dummies so their commitment/nullifier are stable. pub fn zero() -> Self { Self { value: 0, @@ -163,11 +189,13 @@ impl Note { } } + /// Returns `true` if this is a dummy (padding) note. + /// + /// A note is dummy iff `value == 0`, matching the circuit and shielded-pool. + /// `asset_id`, `owner_pubkey`, and `blinding` are not part of the predicate. + /// Use [`Self::zero`] to build the canonical dummy. pub fn is_zero(&self) -> bool { self.value == 0 - && self.asset_id == 0 - && self.owner_pubkey.inner().is_zero() - && self.blinding.inner().is_zero() } /// Compute the Merkle commitment for this note. @@ -224,6 +252,80 @@ mod tests { assert!(!FieldElement::from_u64(1).is_zero()); } + // --- canonical encoding --- + + /// Little-endian 32-byte encoding of the field modulus `p`. + fn modulus_le() -> [u8; 32] { + use ark_ff::{BigInteger, PrimeField}; + // p as bytes: encode -1 (= p-1) then add 1 with carry. + let p_minus_1 = (-Fr::from(1u64)).into_bigint().to_bytes_le(); + let mut p = [0u8; 32]; + p[..p_minus_1.len()].copy_from_slice(&p_minus_1); + let mut carry = 1u16; + for b in p.iter_mut() { + let v = *b as u16 + carry; + *b = (v & 0xff) as u8; + carry = v >> 8; + } + p + } + + #[test] + fn canonical_accepts_small_values() { + let mut b = [0u8; 32]; + b[0] = 5; // little-endian 5 + assert!(FieldElement::is_canonical_le(&b)); + assert!(FieldElement::from_canonical_le(&b).is_some()); + } + + #[test] + fn canonical_accepts_zero() { + assert!(FieldElement::is_canonical_le(&[0u8; 32])); + } + + #[test] + fn canonical_rejects_modulus_and_above() { + let p = modulus_le(); + // p itself is non-canonical (reduces to 0). + assert!(!FieldElement::is_canonical_le(&p)); + assert!(FieldElement::from_canonical_le(&p).is_none()); + // All-ones (0xff..ff) is way above p → non-canonical. + assert!(!FieldElement::is_canonical_le(&[0xff; 32])); + assert!(FieldElement::from_canonical_le(&[0xff; 32]).is_none()); + } + + #[test] + fn canonical_n_and_n_plus_p_differ() { + // n and n+p are different byte strings but reduce to the same field element. + use ark_ff::{BigInteger, PrimeField}; + let n = 7u64; + let n_bytes = { + let mut b = [0u8; 32]; + let le = Fr::from(n).into_bigint().to_bytes_le(); + b[..le.len()].copy_from_slice(&le); + b + }; + // n+p as bytes = modulus + n (may overflow 32 bytes; if so, skip — but for + // small n it fits since p < 2^254). + let p = modulus_le(); + let mut n_plus_p = [0u8; 32]; + let mut carry = 0u16; + for i in 0..32 { + let v = p[i] as u16 + n_bytes[i] as u16 + carry; + n_plus_p[i] = (v & 0xff) as u8; + carry = v >> 8; + } + assert_eq!(carry, 0, "n+p must fit in 32 bytes for this test"); + // Same reduced field element... + assert_eq!( + Fr::from_le_bytes_mod_order(&n_bytes), + Fr::from_le_bytes_mod_order(&n_plus_p) + ); + // ...but n is canonical and n+p is NOT. + assert!(FieldElement::is_canonical_le(&n_bytes)); + assert!(!FieldElement::is_canonical_le(&n_plus_p)); + } + #[test] fn field_element_from_fr_roundtrip() { let fr = Fr::from(123u64); @@ -261,6 +363,26 @@ mod tests { assert!(Note::zero().is_zero()); } + #[test] + fn note_is_dummy_by_value_only() { + // A value-zero note is dummy even with non-zero asset/pubkey/blinding. + let dummy = Note::new( + 0, + 7, + OwnerPubkey::from(Fr::from(123u64)), + Blinding::from(Fr::from(456u64)), + ); + assert!(dummy.is_zero()); + // A non-zero value is never dummy, regardless of the other fields. + let real = Note::new( + 1, + 0, + OwnerPubkey::from(Fr::from(0u64)), + Blinding::from(Fr::from(0u64)), + ); + assert!(!real.is_zero()); + } + #[test] fn note_new_preserves_fields() { let pk = OwnerPubkey::from(Fr::from(10u64)); diff --git a/primitives/zk-core/tests/poseidon_vectors.rs b/primitives/zk-core/tests/poseidon_vectors.rs new file mode 100644 index 00000000..be8bfb97 --- /dev/null +++ b/primitives/zk-core/tests/poseidon_vectors.rs @@ -0,0 +1,120 @@ +//! Known-answer vectors for the Poseidon hashers and native/WASM equivalence. +//! +//! The expected values are the canonical circomlib (iden3) Poseidon outputs over +//! BN254 for the given inputs, encoded big-endian. If `light-poseidon-nostd` ever +//! ships different parameters than the compiled Circom circuit, these vectors fail +//! — which is the whole point: every proof and every Merkle root in the system +//! depends on this hash matching the circuit exactly. + +use ark_ff::{BigInteger, PrimeField}; +use orbinum_zk_core::{poseidon_hash_1, FieldElement, LightPoseidonHasher, PoseidonHasher}; + +/// Big-endian 32-byte hex of a field element. +fn to_hex_be(fe: FieldElement) -> String { + let bytes = fe.inner().into_bigint().to_bytes_be(); + let mut s = String::from("0x"); + for b in &bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +fn fe(n: u64) -> FieldElement { + FieldElement::from_u64(n) +} + +// ─── Known-answer vectors (canonical circomlib BN254 Poseidon) ───────────────── + +/// Poseidon([1]) — single input (used for owner_hash in the value_proof circuit). +const P1_1: &str = "0x29176100eaa962bdc1fe6c654d6a3c130e96a4d1168b33848b897dc502820133"; +/// Poseidon([1, 2]) — arity 2 (Merkle node / nullifier). +const P2_1_2: &str = "0x115cc0f5e7d690413df64c6b9662e9cf2a3617f2743245519e19607a4417189a"; +/// Poseidon([1, 2, 3, 4]) — arity 4 (note commitment). +const P4_1_2_3_4: &str = "0x299c867db6c1fdd79dcefa40e4510b9837e60ebb1ce0663dbaa525df65250465"; +/// Poseidon([1, 2, 3, 4, 5]) — arity 5 (EdDSA challenge). +const P5_1_2_3_4_5: &str = "0x0dab9449e4a1398a15224c0b15a49d598b2174d305a316c918125f8feeb123c0"; + +#[test] +fn poseidon_1_matches_circomlib() { + assert_eq!(to_hex_be(poseidon_hash_1(fe(1))), P1_1); +} + +#[test] +fn poseidon_2_matches_circomlib() { + let h = LightPoseidonHasher; + assert_eq!(to_hex_be(h.hash_2([fe(1), fe(2)])), P2_1_2); +} + +#[test] +fn poseidon_4_matches_circomlib() { + let h = LightPoseidonHasher; + assert_eq!( + to_hex_be(h.hash_4([fe(1), fe(2), fe(3), fe(4)])), + P4_1_2_3_4 + ); +} + +#[test] +fn poseidon_5_matches_circomlib() { + let h = LightPoseidonHasher; + assert_eq!( + to_hex_be(h.hash_5([fe(1), fe(2), fe(3), fe(4), fe(5)])), + P5_1_2_3_4_5 + ); +} + +// ─── Native ≡ WASM equivalence ───────────────────────────────────────────────── +// +// `NativePoseidonHasher` routes through the `sp-runtime-interface` host function, +// which serializes field elements to bytes and back. This exercises that +// bytes↔field boundary — the place a native/WASM divergence would actually surface +// — against the WASM `LightPoseidonHasher` for a spread of inputs. + +#[cfg(feature = "poseidon-native")] +#[test] +fn native_matches_wasm_hash_2() { + use orbinum_zk_core::NativePoseidonHasher; + let native = NativePoseidonHasher; + let wasm = LightPoseidonHasher; + for (a, b) in [(0u64, 0u64), (1, 2), (7, 11), (u64::MAX, 1), (12345, 67890)] { + assert_eq!( + native.hash_2([fe(a), fe(b)]), + wasm.hash_2([fe(a), fe(b)]), + "native≠wasm for hash_2({a},{b})" + ); + } +} + +#[cfg(feature = "poseidon-native")] +#[test] +fn native_matches_wasm_hash_4() { + use orbinum_zk_core::NativePoseidonHasher; + let native = NativePoseidonHasher; + let wasm = LightPoseidonHasher; + for base in [0u64, 1, 999, u64::MAX] { + let inputs = [ + fe(base), + fe(base ^ 0xff), + fe(base.wrapping_add(1)), + fe(base / 2), + ]; + assert_eq!( + native.hash_4(inputs), + wasm.hash_4(inputs), + "native≠wasm for hash_4 base={base}" + ); + } +} + +#[cfg(feature = "poseidon-native")] +#[test] +fn native_matches_wasm_and_vectors() { + use orbinum_zk_core::NativePoseidonHasher; + // Native path must also match the circomlib known-answer vectors. + let native = NativePoseidonHasher; + assert_eq!(to_hex_be(native.hash_2([fe(1), fe(2)])), P2_1_2); + assert_eq!( + to_hex_be(native.hash_4([fe(1), fe(2), fe(3), fe(4)])), + P4_1_2_3_4 + ); +}