diff --git a/Cargo.lock b/Cargo.lock index 02a5c983e4..8af244f2c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6392,6 +6392,7 @@ dependencies = [ "prometheus", "prometheus-metric-storage", "rand 0.9.4", + "rand_chacha 0.9.0", "rate-limit", "request-sharing", "reqwest 0.13.2", diff --git a/Cargo.toml b/Cargo.toml index 7ad6cdcfab..3eaba96cdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,6 +147,7 @@ tracing-subscriber = { version = "0.3.22", features = ["json"] } url = "2.5.0" vergen = "8" winner-selection = { path = "crates/winner-selection" } +rand_chacha = { version = "0.9.0" } [workspace.lints] clippy.cast_possible_wrap = "deny" diff --git a/crates/price-estimation/Cargo.toml b/crates/price-estimation/Cargo.toml index 42f08ca145..e117f5396e 100644 --- a/crates/price-estimation/Cargo.toml +++ b/crates/price-estimation/Cargo.toml @@ -61,6 +61,7 @@ mockall = { workspace = true } testlib = { workspace = true } token-info = { workspace = true, features = ["test-util"] } toml = { workspace = true } +rand_chacha = { workspace = true } [features] test-util = ["dep:mockall"] diff --git a/crates/price-estimation/src/native_price_cache.rs b/crates/price-estimation/src/native_price_cache.rs index 95768316e4..4c17663f23 100644 --- a/crates/price-estimation/src/native_price_cache.rs +++ b/crates/price-estimation/src/native_price_cache.rs @@ -193,13 +193,21 @@ impl Cache { self.0.max_age } - /// Returns a randomized `updated_at` timestamp that is 50-90% of max_age - /// in the past, to avoid spikes of expired prices all being fetched at - /// once. + /// Returns a timestamp that is a `percentage` of `max_age` + /// in the past. Panics if `percentage` > 100. Clamps to `now` if an + /// underflow occurs. + fn updated_at_percentage(max_age: Duration, now: Instant, percentage: u32) -> Instant { + assert!(percentage <= 100, "percentage > 100"); + let age = max_age.saturating_mul(percentage) / 100; + now.checked_sub(age).unwrap_or(now) + } + + /// Returns a randomized `updated_at_percentage` timestamp that is 50–90% of + /// `max_age` in the past, to avoid spikes of expired prices all being + /// fetched at once. fn random_updated_at(max_age: Duration, now: Instant, rng: &mut impl Rng) -> Instant { - let percent_expired = rng.random_range(50..=90); - let age = max_age.as_secs() * percent_expired / 100; - now - Duration::from_secs(age) + let percent_expired: u32 = rng.random_range(50..=90); + Self::updated_at_percentage(max_age, now, percent_expired) } fn len(&self) -> usize { @@ -544,6 +552,7 @@ mod tests { anyhow::anyhow, futures::FutureExt, num::ToPrimitive, + rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}, }; fn token(u: u64) -> Address { @@ -1143,4 +1152,55 @@ mod tests { anyhow!("protocol") )))); } + + #[test] + fn random_updated_at_underflow_check() { + let now = Instant::now(); + let max_age = Duration::MAX; + let mut rng = ChaCha20Rng::from_seed(Default::default()); + + let updated_at = Cache::random_updated_at(max_age, now, &mut rng); + assert_eq!(updated_at, now); + } + + #[test] + #[should_panic(expected = "percentage > 100")] + fn updated_at_percent_panic() { + let now = Instant::now(); + let max_age = Duration::MAX; + Cache::updated_at_percentage(max_age, now, 101); + } + + #[test] + fn updated_at_percent_edges() { + let now = Instant::now(); + let max_age = Duration::from_secs(600); + let cases = [ + (0, now), // 0% + (50, now - Duration::from_secs(300)), // 50% + (90, now - Duration::from_secs(540)), // 90% + (100, now - Duration::from_secs(600)), // 100% + ]; + + for (percentage, expected) in cases { + assert_eq!( + Cache::updated_at_percentage(max_age, now, percentage), + expected, + ); + } + } + + #[test] + fn random_updated_at_range() { + let now = Instant::now(); + let max_age = Duration::from_secs(600); + let mut rng = ChaCha20Rng::from_seed(Default::default()); + let min = now - (max_age * 90 / 100); + let max = now - (max_age * 50 / 100); + + for _ in 0..100 { + let updated_at = Cache::random_updated_at(max_age, now, &mut rng); + assert!(updated_at >= min && updated_at <= max); + } + } }