From f61ba037c6726e19be4f894d9447fe396df95417 Mon Sep 17 00:00:00 2001 From: Akosh Farkash Date: Thu, 10 Oct 2024 14:08:10 +0100 Subject: [PATCH] feat(test): Fuzz test poseidon2 hash equivalence (#6265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description ## Problem\* Related to #6141 ## Summary\* Adds a fuzz test for `poseidon2.nr` comparing the results to [bn254_blackbox_solver::poseidon_hash](https://github.com/noir-lang/noir/blob/70cbeb4322a0b11c1c167ab27bf0408d04fe7b7d/acvm-repo/bn254_blackbox_solver/src/poseidon2.rs#L547), which says it's `"Performs a poseidon hash with a sponge construction equivalent to the one in poseidon2.nr"` To pass the test the Rust implementation was given a new `is_variable_length` parameter to inform it whether it needs to append an extra 1 like Noir and Berratenberg do. ## Additional Context The test initially failed: ```console ❯ cargo test -p nargo_cli --test stdlib-props poseidon test fuzz_poseidon2_equivalence ... FAILED ---- fuzz_poseidon2_equivalence stdout ---- Test failed: assertion failed: `(left == right)` left: `Field(1187863985434533916290764679013201786939267142671550539990974992402592744116)`, right: `Field(11250791130336988991462250958918728798886439319225016858543557054782819955502)`: max_len = 1 at tooling/nargo_cli/tests/stdlib-props.rs:106. minimal failing input: io = SnippetInputOutput { description: "max_len = 1", inputs: { "input": Vec( [ Field( 0, ), ], ), "message_size": Field( 0, ), }, expected_output: Field( 11250791130336988991462250958918728798886439319225016858543557054782819955502, ), } ``` So we pass in `input = [0; 1]` with `message_size=0`. It fails because the Noir code treats the case where the `message_size` is different from the maximum length differently by [appending](https://github.com/noir-lang/noir/blob/70cbeb4322a0b11c1c167ab27bf0408d04fe7b7d/noir_stdlib/src/hash/poseidon2.nr#L75-L80) an extra 1, to keep variable and fixed length hashes distinct. The Rust implementation doesn't do this, nor did the other hashes tested so far. I'm not sure if it's worth noting that the hash will not depend on how much shorter the message is than the maximum, just that it's shorter. ## Documentation\* Check one: - [ ] No documentation needed. - [ ] Documentation included in this PR. - [ ] **[For Experimental Features]** Documentation to be submitted in a separate PR. # PR Checklist\* - [x] I have tested the changes locally. - [ ] I have formatted the changes with [Prettier](https://prettier.io/) and/or `cargo fmt` on default settings. --- .../bn254_blackbox_solver/src/poseidon2.rs | 14 +++++- tooling/nargo_cli/tests/stdlib-props.rs | 44 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/acvm-repo/bn254_blackbox_solver/src/poseidon2.rs b/acvm-repo/bn254_blackbox_solver/src/poseidon2.rs index 64823e37029..3aa735388ca 100644 --- a/acvm-repo/bn254_blackbox_solver/src/poseidon2.rs +++ b/acvm-repo/bn254_blackbox_solver/src/poseidon2.rs @@ -544,13 +544,23 @@ impl<'a> Poseidon2<'a> { } /// Performs a poseidon hash with a sponge construction equivalent to the one in poseidon2.nr -pub fn poseidon_hash(inputs: &[FieldElement]) -> Result { +/// +/// The `is_variable_length` parameter is there to so we can produce an equivalent hash with +/// the Barretenberg implementation which distinguishes between variable and fixed length inputs. +/// Set it to true if the input length matches the static size expected by the Noir function. +pub fn poseidon_hash( + inputs: &[FieldElement], + is_variable_length: bool, +) -> Result { let two_pow_64 = 18446744073709551616_u128.into(); let iv = FieldElement::from(inputs.len()) * two_pow_64; let mut sponge = Poseidon2Sponge::new(iv, 3); for input in inputs.iter() { sponge.absorb(*input)?; } + if is_variable_length { + sponge.absorb(FieldElement::from(1u32))?; + } sponge.squeeze() } @@ -640,7 +650,7 @@ mod test { FieldElement::from(3u128), FieldElement::from(4u128), ]; - let result = super::poseidon_hash(&fields).expect("should hash successfully"); + let result = super::poseidon_hash(&fields, false).expect("should hash successfully"); assert_eq!( result, field_from_hex("130bf204a32cac1f0ace56c78b731aa3809f06df2731ebcf6b3464a15788b1b9"), diff --git a/tooling/nargo_cli/tests/stdlib-props.rs b/tooling/nargo_cli/tests/stdlib-props.rs index d1ea5bbfaf6..9347f596257 100644 --- a/tooling/nargo_cli/tests/stdlib-props.rs +++ b/tooling/nargo_cli/tests/stdlib-props.rs @@ -1,6 +1,7 @@ use std::{cell::RefCell, collections::BTreeMap, path::Path}; -use acvm::{acir::native_types::WitnessStack, FieldElement}; +use acvm::{acir::native_types::WitnessStack, AcirField, FieldElement}; +use iter_extended::vecmap; use nargo::{ ops::{execute_program, DefaultForeignCallExecutor}, parse_all, @@ -254,6 +255,47 @@ fn fuzz_sha512_equivalence() { ); } +#[test] +fn fuzz_poseidon2_equivalence() { + use bn254_blackbox_solver::poseidon_hash; + + for max_len in [0, 1, 3, 4, 511, 512] { + let source = format!( + "fn main(input: [Field; {max_len}], message_size: u32) -> pub Field {{ + std::hash::poseidon2::Poseidon2::hash(input, message_size) + }}" + ); + + let strategy = (0..=max_len) + .prop_flat_map(|len: usize| { + // Generate Field elements from random 32 byte vectors. + let field = prop::collection::vec(any::(), 32) + .prop_map(|bytes| FieldElement::from_be_bytes_reduce(&bytes)); + + prop::collection::vec(field, len) + }) + .prop_map(move |mut msg| { + // The output hash is a single field element. + let output = poseidon_hash(&msg, msg.len() < max_len).expect("failed to hash"); + + // The input has to be padded to the maximum length. + let msg_size = msg.len(); + msg.resize(max_len, FieldElement::from(0u64)); + + let inputs = vec![ + ("input", InputValue::Vec(vecmap(msg, InputValue::Field))), + ("message_size", InputValue::Field(FieldElement::from(msg_size))), + ]; + + SnippetInputOutput::new(inputs, InputValue::Field(output)) + .with_description(format!("max_len = {max_len}")) + }) + .boxed(); + + run_snippet_proptest(source.clone(), false, strategy); + } +} + fn bytes_input(bytes: &[u8]) -> InputValue { InputValue::Vec( bytes.iter().map(|b| InputValue::Field(FieldElement::from(*b as u32))).collect(),