diff --git a/server/Cargo.lock b/server/Cargo.lock index 25de024..c1c1664 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -745,6 +745,7 @@ dependencies = [ "memsafe", "mutants", "pem", + "proptest", "prost", "prost-types", "rand 0.10.0", @@ -3647,9 +3648,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 1c7050a..a94e5b8 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -63,5 +63,6 @@ mutants.workspace = true [dev-dependencies] insta = "1.46.3" +proptest = "1.11.0" rstest.workspace = true test-log = { version = "0.2", default-features = false, features = ["trace"] } diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1.rs b/server/crates/arbiter-server/src/crypto/integrity/v1.rs index 08bc3e7..afd8358 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1.rs @@ -1,4 +1,6 @@ -use crate::{actors::keyholder, crypto::integrity::hashing::Hashable, safe_cell::SafeCellHandle as _}; +use crate::{ + actors::keyholder, crypto::integrity::hashing::Hashable, safe_cell::SafeCellHandle as _, +}; use hmac::{Hmac, Mac as _}; use sha2::Sha256; @@ -43,7 +45,6 @@ pub enum Error { #[error("Integrity MAC mismatch for entity {entity_kind}")] MacMismatch { entity_kind: &'static str }, - } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -203,13 +204,19 @@ mod tests { use diesel::{ExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; use kameo::{actor::ActorRef, prelude::Spawn}; + use rand::seq::SliceRandom; use sha2::Digest; + use proptest::prelude::*; + use crate::{ - actors::keyholder::{Bootstrap, KeyHolder}, crypto::integrity::hashing::Hashable, db::{self, schema}, safe_cell::{SafeCell, SafeCellHandle as _} + actors::keyholder::{Bootstrap, KeyHolder}, + db::{self, schema}, + safe_cell::{SafeCell, SafeCellHandle as _}, }; use super::{Error, Integrable, sign_entity, verify_entity}; + use super::{hashing::Hashable, payload_hash}; #[derive(Clone)] struct DummyEntity { diff --git a/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs index 5a0e7da..d172359 100644 --- a/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs +++ b/server/crates/arbiter-server/src/crypto/integrity/v1/hashing.rs @@ -104,4 +104,4 @@ impl Hashable for chrono::DateTime { fn hash(&self, hasher: &mut H) { hasher.update(&self.timestamp_millis().to_be_bytes()); } -} \ No newline at end of file +} diff --git a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs index ab4e8f0..6549fb4 100644 --- a/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/ether_transfer/tests.rs @@ -84,8 +84,6 @@ fn shared() -> SharedGrantSettings { } } -// ── analyze ───────────────────────────────────────────────────────────── - #[test] fn analyze_matches_empty_calldata() { let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); @@ -102,8 +100,6 @@ fn analyze_rejects_nonempty_calldata() { assert!(EtherTransfer::analyze(&context).is_none()); } -// ── evaluate ──────────────────────────────────────────────────────────── - #[tokio::test] async fn evaluate_passes_for_allowed_target() { let db = db::create_test_pool().await; @@ -276,8 +272,6 @@ async fn evaluate_passes_at_exactly_volume_limit() { ); } -// ── try_find_grant ─────────────────────────────────────────────────────── - #[tokio::test] async fn try_find_grant_roundtrip() { let db = db::create_test_pool().await; @@ -336,7 +330,36 @@ async fn try_find_grant_wrong_target_returns_none() { assert!(found.is_none()); } -// ── find_all_grants ────────────────────────────────────────────────────── +proptest::proptest! { + #[test] + fn target_order_does_not_affect_hash( + raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8), + seed in proptest::prelude::any::(), + max_volume in proptest::prelude::any::(), + window_secs in 1i64..=86400, + ) { + use rand::{SeedableRng, seq::SliceRandom}; + use sha2::Digest; + use crate::crypto::integrity::hashing::Hashable; + + let addrs: Vec
= raw_addrs.iter().map(|b| Address::from(*b)).collect(); + let mut shuffled = addrs.clone(); + shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed)); + + let limit = VolumeRateLimit { + max_volume: U256::from(max_volume), + window: Duration::seconds(window_secs), + }; + + let mut h1 = sha2::Sha256::new(); + Settings { target: addrs, limit: limit.clone() }.hash(&mut h1); + + let mut h2 = sha2::Sha256::new(); + Settings { target: shuffled, limit }.hash(&mut h2); + + proptest::prop_assert_eq!(h1.finalize(), h2.finalize()); + } +} #[tokio::test] async fn find_all_grants_empty_db() { diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs index 04920b2..49e03b6 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/mod.rs @@ -1,15 +1,5 @@ use std::collections::HashMap; -use alloy::{ - primitives::{Address, U256}, - sol_types::SolCall, -}; -use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo}; -use chrono::{DateTime, Duration, Utc}; -use diesel::dsl::{auto_type, insert_into}; -use diesel::sqlite::Sqlite; -use diesel::{ExpressionMethods, prelude::*}; -use diesel_async::{AsyncConnection, RunQueryDsl}; use crate::db::schema::{ evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_token_transfer_volume_limit, @@ -30,6 +20,16 @@ use crate::{ }, evm::policies::CombinedSettings, }; +use alloy::{ + primitives::{Address, U256}, + sol_types::SolCall, +}; +use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo}; +use chrono::{DateTime, Duration, Utc}; +use diesel::dsl::{auto_type, insert_into}; +use diesel::sqlite::Sqlite; +use diesel::{ExpressionMethods, prelude::*}; +use diesel_async::{AsyncConnection, RunQueryDsl}; use super::{DatabaseID, EvalContext, EvalViolation}; diff --git a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs index c714afc..4f868e7 100644 --- a/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs +++ b/server/crates/arbiter-server/src/evm/policies/token_transfers/tests.rs @@ -101,8 +101,6 @@ fn shared() -> SharedGrantSettings { } } -// ── analyze ───────────────────────────────────────────────────────────── - #[test] fn analyze_known_token_valid_calldata() { let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); @@ -128,8 +126,6 @@ fn analyze_empty_calldata_returns_none() { assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none()); } -// ── evaluate ──────────────────────────────────────────────────────────── - #[tokio::test] async fn evaluate_rejects_nonzero_eth_value() { let db = db::create_test_pool().await; @@ -412,7 +408,39 @@ async fn try_find_grant_unknown_token_returns_none() { assert!(found.is_none()); } -// ── find_all_grants ────────────────────────────────────────────────────── +proptest::proptest! { + #[test] + fn volume_limits_order_does_not_affect_hash( + raw_limits in proptest::collection::vec( + (proptest::prelude::any::(), 1i64..=86400), + 0..8, + ), + seed in proptest::prelude::any::(), + ) { + use rand::{SeedableRng, seq::SliceRandom}; + use sha2::Digest; + use crate::crypto::integrity::hashing::Hashable; + + let limits: Vec = raw_limits + .iter() + .map(|(max_vol, window_secs)| VolumeRateLimit { + max_volume: U256::from(*max_vol), + window: Duration::seconds(*window_secs), + }) + .collect(); + + let mut shuffled = limits.clone(); + shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed)); + + let mut h1 = sha2::Sha256::new(); + Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1); + + let mut h2 = sha2::Sha256::new(); + Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2); + + proptest::prop_assert_eq!(h1.finalize(), h2.finalize()); + } +} #[tokio::test] async fn find_all_grants_empty_db() {