Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 2 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/poseidon/sponge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ where
}

pub(crate) fn absorb(&mut self, inputs: &Vec<U256>) {
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);
Expand All @@ -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>) -> U256 {
assert!(!inputs.is_empty(), "Poseidon: inputs cannot be empty");
self.reset_state();
self.absorb(inputs);
self.squeeze()
Expand Down
9 changes: 7 additions & 2 deletions src/poseidon2/sponge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,13 @@ where
}

pub(crate) fn absorb(&mut self, inputs: &Vec<U256>) {
// 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);
Expand Down
52 changes: 12 additions & 40 deletions src/tests/poseidon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,53 +711,31 @@ 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
}

// ============================================================================
// Failure mode tests
// ============================================================================

#[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();

Expand Down Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/tests/poseidon2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading