diff --git a/Cargo.lock b/Cargo.lock index ae47dd5..e7aa18b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,9 +1337,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d1bfa6f7d57307bf8241789b13d3703438e7afa0527aa098a357ef757d3a2" +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" dependencies = [ "serde", "serde_json", @@ -1358,9 +1358,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9953e782d6da30974eea520c2b5f624c28bbc518c3bb926ec581242dd3f9d2a2" +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" dependencies = [ "arbitrary", "bytes-lit", @@ -1382,9 +1382,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a8cecb6acc735670dad3303c6a9d2b47e51adfb11224ad5a8ced55fd7b0a600" +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" dependencies = [ "darling 0.20.11", "heck", @@ -1402,11 +1402,12 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79501d0636f86fe2c9b1dd7e88b9397415b3493a59b34f466abd7758c84b92b" +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" dependencies = [ "base64", + "sha2", "stellar-xdr", "thiserror", "wasmparser", @@ -1414,9 +1415,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b520b5fb013fde70796d9a6057591f53817aa0c38f8bad460126f97f59394af9" +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" dependencies = [ "prettyplease", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 19b20f0..9d1aad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ rust-version = "1.84.0" license = "Apache-2.0" [workspace.dependencies] -soroban-sdk = "25.0.2" +soroban-sdk = "25.3.0" soroban-poseidon = { path = "." } [package] diff --git a/README.md b/README.md index 3ec8299..87ed5ec 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ let hash2 = sponge.compute_hash(&inputs2); ## Limitations / Future Work -1. **Multi-round absorption**: Currently, inputs must fit within a single rate (i.e., `inputs.len() <= T - 1`). Future versions will support absorbing inputs larger than the state size across multiple permutation rounds. +1. **Multi-round absorption**: Currently, for Poseidon, inputs must exactly fill the rate (i.e., `inputs.len() == T - 1`), matching circom's behavior where `nInputs` determines `T = nInputs + 1`. Poseidon2 requires inputs to fit within a single rate (i.e., `inputs.len() <= T - 1`). Future versions will support absorbing inputs larger than the state size across multiple permutation rounds. 2. **Persistent parameters**: Make `PoseidonParams` / `Poseidon2Params` a `#[contracttype]` so they can be stored as contract data and reduce the contract size. diff --git a/src/lib.rs b/src/lib.rs index 33d402b..9786e7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,7 @@ impl Field for BlsScalar { /// /// # Type Parameters /// -/// - `T`: State size. Must be ≥ `inputs.len() + 1` (rate = T-1, capacity = 1). +/// - `T`: State size. Must equal `inputs.len() + 1` (rate = T-1, capacity = 1). /// - `F`: Field type. Use [`BnScalar`] for BN254 or [`BlsScalar`] for BLS12-381. /// /// # Supported Configurations @@ -72,8 +72,7 @@ impl Field for BlsScalar { /// /// # Panics /// -/// - if `inputs.is_empty()` -/// - if `inputs.len() > T - 1` (rate exceeded) +/// - if `inputs.len() != T - 1` /// - if any input value ≥ the field modulus (inputs must be valid field elements) /// /// # Example diff --git a/src/poseidon/sponge.rs b/src/poseidon/sponge.rs index 54f8e71..3e7ec56 100644 --- a/src/poseidon/sponge.rs +++ b/src/poseidon/sponge.rs @@ -217,7 +217,10 @@ where } pub(crate) fn absorb(&mut self, inputs: &Vec) { - assert!(inputs.len() <= Self::RATE); + assert!( + inputs.len() == Self::RATE, + "Poseidon: inputs.len() must equal rate (T - 1)" + ); let modulus = F::modulus(&self.env); for i in 0..inputs.len() { let v = inputs.get_unchecked(i); @@ -243,15 +246,13 @@ where /// implementation](https://github.com/iden3/circomlib/blob/master/circuits/poseidon.circom). /// /// # Panics - /// - if `inputs.is_empty()`. Empty inputs are not allowed because - /// `hash([])` would collide with `hash([0])`. Circom also disallows empty - /// inputs. - /// - if `inputs.len() > RATE` (i.e., `T - 1`). For larger inputs, - /// multi-round absorption would be needed (not yet implemented). + /// - if `inputs.len() != RATE` (i.e., must equal `T - 1` exactly). + /// This matches circom's Poseidon where `nInputs` determines + /// `T = nInputs + 1`, ensuring the rate is always fully used and + /// preventing suffix-zero collisions from implicit zero-padding. /// - if any input value is greater than or equal to the field modulus. /// All inputs must be valid field elements (i.e., less than the modulus). pub fn compute_hash(&mut self, inputs: &Vec) -> U256 { - assert!(!inputs.is_empty(), "Poseidon: inputs cannot be empty"); self.reset_state(); self.absorb(inputs); self.squeeze() diff --git a/src/poseidon2/sponge.rs b/src/poseidon2/sponge.rs index a45f27a..b7abdae 100644 --- a/src/poseidon2/sponge.rs +++ b/src/poseidon2/sponge.rs @@ -174,8 +174,13 @@ where } pub(crate) fn absorb(&mut self, inputs: &Vec) { - // Absorb into rate portion of state (positions 0..RATE) - assert!(inputs.len() <= Self::RATE); + // <= is safe here because IV = input_len << 64 provides domain + // separation for different-length inputs. This differs from Poseidon V1 + // (which uses IV=0 and therefore requires == RATE). + assert!( + inputs.len() <= Self::RATE, + "Poseidon2: inputs.len() must not exceed rate (T - 1)" + ); let modulus = F::modulus(&self.env); for i in 0..inputs.len() { let v = inputs.get_unchecked(i); diff --git a/src/tests/poseidon.rs b/src/tests/poseidon.rs index 9fe735a..aeaf8f3 100644 --- a/src/tests/poseidon.rs +++ b/src/tests/poseidon.rs @@ -711,45 +711,23 @@ fn test_poseidon_sponge_matches_hash_function() { } // ============================================================================ -// Partial rate tests (inputs.len() < RATE) +// Partial rate rejection tests // ============================================================================ -// Note: Circom's Poseidon always uses T = inputs.len() + 1 (full rate), so there are -// no reference test vectors for partial rate scenarios. These tests verify that: -// 1. Our implementation handles partial rate correctly (zero-padding) -// 2. Results are deterministic -// 3. Different T values produce different results (as expected) +// Poseidon V1 requires inputs.len() == RATE (full rate), matching circom's +// behavior where nInputs determines T = nInputs + 1. Partial-rate inputs are +// rejected to prevent suffix-zero collisions from implicit zero-padding. -// Test hashing 1 input with T=3 (rate=2) - partial rate usage -// This verifies zero-padding works correctly when inputs don't fill the rate #[test] -fn test_poseidon_bn254_partial_rate_t3_1_input() { +#[should_panic(expected = "inputs.len() must equal rate")] +fn test_poseidon_bn254_rejects_partial_rate() { let env = Env::default(); - // 1 input with T=3 (rate=2) - only half the rate is used - let inputs = vec![ - &env, - U256::from_be_bytes( - &env, - &bytesn!( - &env, - 0x0000000000000000000000000000000000000000000000000000000000000001 - ) - .into(), - ), - ]; + // 1 input with T=3 (rate=2) - partial rate must be rejected + let inputs = vec![&env, U256::from_u32(&env, 1)]; let mut sponge = PoseidonSponge::<3, BnScalar>::new(&env); - let result = sponge.compute_hash(&inputs); - - // Result should be deterministic - let result2 = sponge.compute_hash(&inputs); - assert_eq!(result, result2); - - // Verify it's different from using T=2 (full rate with 1 input) - let mut sponge_t2 = PoseidonSponge::<2, BnScalar>::new(&env); - let result_t2 = sponge_t2.compute_hash(&inputs); - assert_ne!(result, result_t2); + sponge.compute_hash(&inputs); // should panic } // ============================================================================ @@ -757,7 +735,7 @@ fn test_poseidon_bn254_partial_rate_t3_1_input() { // ============================================================================ #[test] -#[should_panic(expected = "assertion failed")] +#[should_panic(expected = "inputs.len() must equal rate")] fn test_poseidon_sponge_inputs_exceed_rate() { let env = Env::default(); @@ -896,15 +874,9 @@ fn test_poseidon_bls12_381_input_below_modulus_accepted() { let _ = sponge.compute_hash(&inputs); } -// Empty inputs are explicitly rejected in Poseidon because: -// 1. Circom rejects them -// 2. With IV=0, hash([]) would collide with hash([0]) since both result in -// permuting state [0, 0, ...] -// -// This differs from Poseidon2, which uses IV = `input_len << 64`, making -// hash([]) and hash([0]) produce different outputs. +// Empty inputs are rejected because inputs.len() must equal RATE (>= 1). #[test] -#[should_panic(expected = "Poseidon: inputs cannot be empty")] +#[should_panic(expected = "inputs.len() must equal rate")] fn test_poseidon_bn254_empty_inputs_rejected() { let env = Env::default(); diff --git a/src/tests/poseidon2.rs b/src/tests/poseidon2.rs index 9423013..3492130 100644 --- a/src/tests/poseidon2.rs +++ b/src/tests/poseidon2.rs @@ -852,7 +852,7 @@ fn test_poseidon2_bn254_partial_rate_t4_2_inputs() { // ============================================================================ #[test] -#[should_panic(expected = "assertion failed")] +#[should_panic(expected = "Poseidon2: inputs.len() must not exceed rate (T - 1)")] fn test_poseidon2_sponge_inputs_exceed_rate_t4() { let env = Env::default();