diff --git a/BENCHMARKS.md b/BENCHMARKS.md index da40e7c..11dac16 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -48,23 +48,23 @@ Regenerate with `cargo build-sbf --manifest-path tests/program/Cargo.toml -- --f | Function | Total CU | Net CU | | ------------------------------ | ---------- | ---------- | -| `verify_with_bsb22_commitment` | 211,461 | 211,461 | +| `verify_with_bsb22_commitment` | 144,138 | 144,138 | ## 6. Groth16-bsb22 - 2 public inputs | Function | Total CU | Net CU | | ------------------------------ | ---------- | ---------- | -| `verify_with_bsb22_commitment` | 215,912 | 215,912 | +| `verify_with_bsb22_commitment` | 148,581 | 148,581 | ## 7. Groth16-bsb22 - 4 public inputs | Function | Total CU | Net CU | | ------------------------------ | ---------- | ---------- | -| `verify_with_bsb22_commitment` | 224,681 | 224,681 | +| `verify_with_bsb22_commitment` | 157,300 | 157,300 | ## 8. Groth16-bsb22 - 8 public inputs | Function | Total CU | Net CU | | ------------------------------ | ---------- | ---------- | -| `verify_with_bsb22_commitment` | 241,985 | 241,985 | +| `verify_with_bsb22_commitment` | 174,621 | 174,621 | diff --git a/README.md b/README.md index 5d4cc08..53e710b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Groth16 zero-knowledge proof verification with Solana alt_bn128 syscalls. A plain Groth16 verify costs 78,293–108,762 CU and a BSB22 verify -211,461–241,985 CU, for 1 to 8 public inputs (see +144,138–174,621 CU, for 1 to 8 public inputs (see [Benchmarks](#benchmarks)). The syscalls ship with Solana 1.18 onward and are active on mainnet-beta. @@ -49,10 +49,10 @@ End-to-end verification cost (proof parsing, verifier construction, | Public inputs | Groth16 | Groth16-BSB22 | |--------------:|--------:|--------------:| -| 1 | 78,293 | 211,461 | -| 2 | 82,704 | 215,912 | -| 4 | 91,448 | 224,681 | -| 8 | 108,762 | 241,985 | +| 1 | 78,293 | 144,138 | +| 2 | 82,704 | 148,581 | +| 4 | 91,448 | 157,300 | +| 8 | 108,762 | 174,621 | diff --git a/src/hash_to_field.rs b/src/hash_to_field.rs index a94cbb5..4f16c7f 100644 --- a/src/hash_to_field.rs +++ b/src/hash_to_field.rs @@ -8,13 +8,17 @@ //! (16 + 32 — the BN254 Fr modulus is 254 bits, plus a 128-bit //! security margin). //! 2. Interpret the 48-byte output as a big-endian integer and reduce -//! mod r via `ark_bn254::Fr::from_le_bytes_mod_order` (over the -//! reversed buffer — the BE wrapper heap-allocates, the LE path -//! does not). +//! mod r as two 24-byte halves, `high * 2^192 + low`. //! //! gnark's `fr.Hash(msg, dst, 1)` returns the resulting field element //! marshalled as 32 big-endian bytes; we return the same. //! +//! Halving is a cost choice, not a semantic one. +//! `from_le_bytes_mod_order` converts only the leading +//! `modulus_bytes - 1` bytes directly and folds every remaining byte +//! through a field multiply and add. A half stays under that limit and +//! skips the loop. +//! //! The implementation is **allocation-free**: the three SHA-256 //! preimage buffers used inside `expand_message_xmd` are //! stack-allocated `[u8; MAX_SCRATCH]` arrays, the modular reduction @@ -28,7 +32,7 @@ use crate::syscalls::sha256; use ark_bn254::Fr; -use ark_ff::PrimeField; +use ark_ff::{MontFp, PrimeField}; /// SHA-256 output size in bytes. const B_IN_BYTES: usize = 32; @@ -36,6 +40,12 @@ const B_IN_BYTES: usize = 32; const R_IN_BYTES: usize = 64; /// Per-element output length: 16-byte security margin + 32-byte modulus. const L: usize = 48; +/// Must stay under the modulus byte width so a half skips the +/// byte-at-a-time window loop in `from_le_bytes_mod_order`. +const HALF_L: usize = L / 2; +/// `2^(8 * HALF_L)`. Pinned against `HALF_L` by +/// `high_half_weight_matches_half_l_shift`. +const HIGH_HALF_WEIGHT: Fr = MontFp!("6277101735386680763835789423207666416102355444464034512896"); /// Upper bound on the `expand_message_xmd` preimage buffer size. Big /// enough for any BSB22 call: `z_pad (64) + msg + l_i_b_str (2) + /// 0x00 (1) + dst_prime (dst + 1)` with `msg <= ~150` bytes and `dst @@ -132,12 +142,18 @@ pub fn hash_to_field_bn254_fr( msg: &[u8; MSG_LEN], dst: &[u8; DST_LEN], ) -> [u8; 32] { - let mut raw = expand_message_xmd_sha256_l48(msg, dst); - // ark's `from_be_bytes_mod_order` copies its input to a heap Vec - // just to reverse it; reverse the stack buffer in place and take - // the LE path, which is allocation-free. - raw.reverse(); - let fr_elem = Fr::from_le_bytes_mod_order(&raw); + let raw = expand_message_xmd_sha256_l48(msg, dst); + // `raw` is big-endian, so `raw[..HALF_L]` is the high half. ark's + // `from_be_bytes_mod_order` heap-allocates to reverse, so reverse + // each half on the stack and take the LE path. + let mut high = [0u8; HALF_L]; + let mut low = [0u8; HALF_L]; + high.copy_from_slice(&raw[..HALF_L]); + low.copy_from_slice(&raw[HALF_L..]); + high.reverse(); + low.reverse(); + let fr_elem = + Fr::from_le_bytes_mod_order(&high) * HIGH_HALF_WEIGHT + Fr::from_le_bytes_mod_order(&low); // `BigInt::to_bytes_be` returns a Vec; serialize the four // little-endian u64 limbs to big-endian bytes on the stack instead. let limbs = fr_elem.into_bigint().0; @@ -355,6 +371,55 @@ mod tests { expander_shape_proptest!(prop_expander_bsb22_shape, 64, 16); expander_shape_proptest!(prop_expander_max_msg, 187, 1); + /// A half must stay under arkworks' direct-conversion limit. + #[test] + fn half_stays_under_the_direct_conversion_limit() { + let modulus_bytes = Fr::MODULUS_BIT_SIZE.div_ceil(8) as usize; + assert!(HALF_L < modulus_bytes); + } + + /// A decimal literal, so pin it to the shift it stands for. + #[test] + fn high_half_weight_matches_half_l_shift() { + let mut shift = [0u8; HALF_L + 1]; + shift[HALF_L] = 1; + assert_eq!(HIGH_HALF_WEIGHT, Fr::from_le_bytes_mod_order(&shift)); + } + + /// Oracle for the halved form: reduce the whole buffer at once. + fn reduce_single_buffer(raw: &[u8; L]) -> Fr { + let mut le = *raw; + le.reverse(); + Fr::from_le_bytes_mod_order(&le) + } + + fn reduce_halved(raw: &[u8; L]) -> Fr { + let mut high = [0u8; HALF_L]; + let mut low = [0u8; HALF_L]; + high.copy_from_slice(&raw[..HALF_L]); + low.copy_from_slice(&raw[HALF_L..]); + high.reverse(); + low.reverse(); + Fr::from_le_bytes_mod_order(&high) * HIGH_HALF_WEIGHT + Fr::from_le_bytes_mod_order(&low) + } + + // A wrong weight or a swapped half shows up here. + proptest! { + #![proptest_config(ProptestConfig::with_cases(proptest_cases()))] + #[test] + fn prop_halved_reduction_matches_single_buffer(raw in any::<[u8; L]>()) { + prop_assert_eq!(reduce_halved(&raw), reduce_single_buffer(&raw)); + } + } + + /// All-ones reduces furthest. + #[test] + fn halved_reduction_matches_single_buffer_at_bounds() { + for raw in [[0u8; L], [0xffu8; L]] { + assert_eq!(reduce_halved(&raw), reduce_single_buffer(&raw)); + } + } + fn hex_to_vec(s: &str) -> Vec { assert!(s.len().is_multiple_of(2)); s.as_bytes() diff --git a/src/lib.rs b/src/lib.rs index c041f00..5334f38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Groth16 zero-knowledge proof verification with Solana alt_bn128 syscalls. //! //! A plain Groth16 verify costs 78,293–108,762 CU and a BSB22 verify -//! 211,461–241,985 CU, for 1 to 8 public inputs (see +//! 144,138–174,621 CU, for 1 to 8 public inputs (see //! [Benchmarks](#benchmarks)). //! //! The syscalls ship with Solana 1.18 onward and are active on mainnet-beta. @@ -45,10 +45,10 @@ //! //! | Public inputs | Groth16 | Groth16-BSB22 | //! |--------------:|--------:|--------------:| -//! | 1 | 78,293 | 211,461 | -//! | 2 | 82,704 | 215,912 | -//! | 4 | 91,448 | 224,681 | -//! | 8 | 108,762 | 241,985 | +//! | 1 | 78,293 | 144,138 | +//! | 2 | 82,704 | 148,581 | +//! | 4 | 91,448 | 157,300 | +//! | 8 | 108,762 | 174,621 | #![no_std]