diff --git a/Cargo.lock b/Cargo.lock index eab4848..40eb240 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2374,6 +2374,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stellar-devkit" +version = "0.1.0" +dependencies = [ + "axum", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "stellar-fee-tracker" version = "0.1.0" diff --git a/packages/core/src/api/fees.rs b/packages/core/src/api/fees.rs index 9d2dee4..c7197fa 100644 --- a/packages/core/src/api/fees.rs +++ b/packages/core/src/api/fees.rs @@ -511,11 +511,16 @@ mod tests { avg_fee: "213".into(), percentiles: PercentileFees { p10: "100".into(), - p25: "100".into(), + p20: "100".into(), + p30: "120".into(), + p40: "140".into(), p50: "150".into(), - p75: "300".into(), + p60: "200".into(), + p70: "300".into(), + p80: "400".into(), p90: "500".into(), p95: "800".into(), + p99: "1200".into(), }, }; @@ -527,17 +532,24 @@ mod tests { } #[test] - fn percentile_fees_has_all_six_fields() { + fn percentile_fees_has_all_fields() { let p = PercentileFees { p10: "100".into(), - p25: "100".into(), + p20: "100".into(), + p30: "120".into(), + p40: "140".into(), p50: "150".into(), - p75: "300".into(), + p60: "200".into(), + p70: "300".into(), + p80: "400".into(), p90: "500".into(), p95: "800".into(), + p99: "1200".into(), }; let json = serde_json::to_value(&p).unwrap(); - for field in &["p10", "p25", "p50", "p75", "p90", "p95"] { + for field in &[ + "p10", "p20", "p30", "p40", "p50", "p60", "p70", "p80", "p90", "p95", "p99", + ] { assert!(json.get(field).is_some(), "missing field: {}", field); assert!(!json[field].as_str().unwrap().is_empty()); } diff --git a/packages/core/src/repository.rs b/packages/core/src/repository.rs index 8f7f128..fb32ea2 100644 --- a/packages/core/src/repository.rs +++ b/packages/core/src/repository.rs @@ -494,11 +494,16 @@ mod tests { max: "5000".into(), avg: "213".into(), p10: "100".into(), - p25: "100".into(), + p20: "100".into(), + p30: "120".into(), + p40: "140".into(), p50: "150".into(), - p75: "300".into(), + p60: "200".into(), + p70: "300".into(), + p80: "400".into(), p90: "500".into(), p95: "800".into(), + p99: "1200".into(), }, }; diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index 18100ee..8b20ee6 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -260,24 +260,34 @@ mod tests { let json = r#"{ "min": "100", "max": "5000", - "avg": "213", + "mode": "213", "p10": "100", - "p25": "100", + "p20": "100", + "p30": "120", + "p40": "140", "p50": "150", - "p75": "300", + "p60": "200", + "p70": "300", + "p80": "400", "p90": "500", - "p95": "800" + "p95": "800", + "p99": "1200" }"#; let fc: FeeCharged = serde_json::from_str(json).unwrap(); assert_eq!(fc.min, "100"); assert_eq!(fc.max, "5000"); assert_eq!(fc.avg, "213"); assert_eq!(fc.p10, "100"); - assert_eq!(fc.p25, "100"); + assert_eq!(fc.p20, "100"); + assert_eq!(fc.p30, "120"); + assert_eq!(fc.p40, "140"); assert_eq!(fc.p50, "150"); - assert_eq!(fc.p75, "300"); + assert_eq!(fc.p60, "200"); + assert_eq!(fc.p70, "300"); + assert_eq!(fc.p80, "400"); assert_eq!(fc.p90, "500"); assert_eq!(fc.p95, "800"); + assert_eq!(fc.p99, "1200"); } #[test] @@ -287,18 +297,24 @@ mod tests { "fee_charged": { "min": "100", "max": "5000", - "avg": "213", + "mode": "213", "p10": "100", - "p25": "100", + "p20": "100", + "p30": "120", + "p40": "140", "p50": "150", - "p75": "300", + "p60": "200", + "p70": "300", + "p80": "400", "p90": "500", - "p95": "800" + "p95": "800", + "p99": "1200" } }"#; let stats: HorizonFeeStats = serde_json::from_str(json).unwrap(); assert_eq!(stats.last_ledger_base_fee, "100"); assert_eq!(stats.fee_charged.p50, "150"); assert_eq!(stats.fee_charged.p95, "800"); + assert_eq!(stats.fee_charged.p99, "1200"); } } diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 571f9e8..218024c 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -35,6 +35,38 @@ cargo test -p stellar-devkit cargo test -p stellar-devkit --test harness_congested ``` +## Mock Horizon Server + +The harness exposes canned `GET /fee_stats` payloads through `HorizonMock` and the JSON fixtures in `src/harness/scenarios/`. + +```bash +# Start with the baseline fixture +cargo test -p stellar-devkit --test harness_normal -- --nocapture + +# Swap to a higher-pressure fixture +cargo test -p stellar-devkit --test harness_congested -- --nocapture +``` + +Scenario flags map directly to the fixture you load in your test setup: + +- `normal` for a low-fee baseline +- `congested` for sustained high-fee demand +- `spike` for a sudden short-lived fee jump +- `recovery` for a return from congestion toward baseline + +```rust +use std::path::Path; + +use stellar_devkit::harness::{ + horizon_mock::HorizonMock, + scenarios::load_from_file, +}; + +let payload = load_from_file(Path::new("src/harness/scenarios/spike.json"))?; +let mock = HorizonMock::new(payload); +assert!(mock.fee_stats_payload().contains("\"scenario\": \"spike\"")); +``` + ## Adding to Your Crate ```toml diff --git a/packages/devkit/src/harness/horizon_mock.rs b/packages/devkit/src/harness/horizon_mock.rs index 6c38d2f..4bbbee6 100644 --- a/packages/devkit/src/harness/horizon_mock.rs +++ b/packages/devkit/src/harness/horizon_mock.rs @@ -6,7 +6,9 @@ pub struct HorizonMock { impl HorizonMock { pub fn new(fee_stats_response: impl Into) -> Self { - Self { fee_stats_response: fee_stats_response.into() } + Self { + fee_stats_response: fee_stats_response.into(), + } } /// Returns the canned response body for `GET /fee_stats`. diff --git a/packages/devkit/src/harness/scenarios/mod.rs b/packages/devkit/src/harness/scenarios/mod.rs index c29c326..3e0f82a 100644 --- a/packages/devkit/src/harness/scenarios/mod.rs +++ b/packages/devkit/src/harness/scenarios/mod.rs @@ -1,5 +1,7 @@ //! Pre-built test scenarios for the Stellar fee tracker harness. +use std::path::Path; + /// Cycles through a list of scenario names, returning the next one each call. pub struct ScenarioRotator { scenarios: Vec, @@ -8,7 +10,10 @@ pub struct ScenarioRotator { impl ScenarioRotator { pub fn new(scenarios: Vec) -> Self { - Self { scenarios, index: 0 } + Self { + scenarios, + index: 0, + } } /// Returns the current scenario name and advances to the next. @@ -20,7 +25,7 @@ impl ScenarioRotator { self.index = (self.index + 1) % self.scenarios.len(); Some(current) } -use std::path::Path; +} /// Loads a scenario JSON file from the given path and returns its contents. pub fn load_from_file(path: &Path) -> std::io::Result { diff --git a/packages/devkit/src/simulation/fee_model.rs b/packages/devkit/src/simulation/fee_model.rs index cb64205..fa9ff4a 100644 --- a/packages/devkit/src/simulation/fee_model.rs +++ b/packages/devkit/src/simulation/fee_model.rs @@ -1,2 +1,224 @@ -/// Models for simulating Stellar transaction fee behaviour. -pub struct FeeModel; +use std::f64::consts::TAU; + +use crate::error::DevkitError; + +const DEFAULT_SEED: u64 = 0x5eed_f00d_dead_beef; + +/// Configurable parameters for simulated fee generation. +#[derive(Clone, Debug, PartialEq)] +pub struct FeeModelConfig { + /// Baseline fee level, in stroops, around which samples are generated. + pub base_fee: u64, + /// Probability in the range `[0.0, 1.0]` that a generated point becomes a spike. + pub spike_probability: f64, + /// Multiplier applied when a spike is injected. + pub spike_multiplier: f64, + /// Standard deviation of the gaussian noise as a fraction of `base_fee`. + pub noise_factor: f64, +} + +impl FeeModelConfig { + /// Validates that the configuration can produce sensible fee samples. + pub fn validate(&self) -> Result<(), DevkitError> { + if self.base_fee == 0 { + return Err(DevkitError::Simulation( + "base_fee must be greater than zero".to_string(), + )); + } + + if !self.spike_probability.is_finite() || !(0.0..=1.0).contains(&self.spike_probability) { + return Err(DevkitError::Simulation( + "spike_probability must be a finite value between 0.0 and 1.0".to_string(), + )); + } + + if !self.spike_multiplier.is_finite() || self.spike_multiplier < 1.0 { + return Err(DevkitError::Simulation( + "spike_multiplier must be a finite value greater than or equal to 1.0".to_string(), + )); + } + + if !self.noise_factor.is_finite() || self.noise_factor < 0.0 { + return Err(DevkitError::Simulation( + "noise_factor must be a finite value greater than or equal to 0.0".to_string(), + )); + } + + Ok(()) + } +} + +impl Default for FeeModelConfig { + fn default() -> Self { + Self { + base_fee: 100, + spike_probability: 0.0, + spike_multiplier: 10.0, + noise_factor: 0.05, + } + } +} + +/// Models Stellar fee behaviour using gaussian baseline noise plus optional spikes. +#[derive(Clone, Debug)] +pub struct FeeModel { + config: FeeModelConfig, + rng: LcgRng, +} + +impl FeeModel { + /// Creates a fee model with a deterministic default seed. + pub fn new(config: FeeModelConfig) -> Result { + Self::with_seed(config, DEFAULT_SEED) + } + + /// Creates a fee model with an explicit seed for reproducible simulations. + pub fn with_seed(config: FeeModelConfig, seed: u64) -> Result { + config.validate()?; + Ok(Self { + config, + rng: LcgRng::new(seed), + }) + } + + /// Returns the active simulation configuration. + pub fn config(&self) -> &FeeModelConfig { + &self.config + } + + /// Generates a single fee sample in stroops. + pub fn sample_fee(&mut self) -> u64 { + let base_fee = self.config.base_fee as f64; + let noise = if self.config.noise_factor == 0.0 { + 0.0 + } else { + self.rng.next_standard_normal() * self.config.noise_factor * base_fee + }; + + let mut fee = (base_fee + noise).max(1.0); + if self.should_inject_spike() { + fee *= self.config.spike_multiplier; + } + + fee.round().max(1.0) as u64 + } + + /// Generates a sequence of fee samples using the current configuration. + pub fn generate_fees(&mut self, count: usize) -> Vec { + (0..count).map(|_| self.sample_fee()).collect() + } + + fn should_inject_spike(&mut self) -> bool { + self.config.spike_probability > 0.0 + && self.rng.next_unit_f64() < self.config.spike_probability + } +} + +#[derive(Clone, Debug)] +struct LcgRng { + state: u64, +} + +impl LcgRng { + fn new(seed: u64) -> Self { + let seed = if seed == 0 { DEFAULT_SEED } else { seed }; + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + self.state + } + + fn next_unit_f64(&mut self) -> f64 { + let raw = (self.next_u64() >> 11) as f64 / ((1_u64 << 53) as f64); + raw.clamp(f64::EPSILON, 1.0 - f64::EPSILON) + } + + fn next_standard_normal(&mut self) -> f64 { + let u1 = self.next_unit_f64(); + let u2 = self.next_unit_f64(); + (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos() + } +} + +#[cfg(test)] +mod tests { + use super::{FeeModel, FeeModelConfig}; + + #[test] + fn config_validation_rejects_invalid_probability() { + let err = FeeModelConfig { + spike_probability: 1.5, + ..FeeModelConfig::default() + } + .validate() + .expect_err("config should reject invalid probability"); + + assert_eq!( + err.to_string(), + "simulation error: spike_probability must be a finite value between 0.0 and 1.0" + ); + } + + #[test] + fn baseline_generator_without_noise_returns_base_fee() { + let mut model = FeeModel::with_seed( + FeeModelConfig { + base_fee: 150, + spike_probability: 0.0, + spike_multiplier: 4.0, + noise_factor: 0.0, + }, + 42, + ) + .expect("valid config"); + + assert_eq!(model.generate_fees(4), vec![150, 150, 150, 150]); + } + + #[test] + fn baseline_generator_with_noise_stays_centered_near_base_fee() { + let mut model = FeeModel::with_seed( + FeeModelConfig { + base_fee: 1_000, + spike_probability: 0.0, + spike_multiplier: 5.0, + noise_factor: 0.10, + }, + 7, + ) + .expect("valid config"); + + let samples = model.generate_fees(512); + let average = samples.iter().copied().sum::() as f64 / samples.len() as f64; + + assert!( + (average - 1_000.0).abs() < 40.0, + "expected average near 1000, got {average}" + ); + assert!( + samples.windows(2).any(|pair| pair[0] != pair[1]), + "expected gaussian noise to vary the generated samples" + ); + } + + #[test] + fn spike_injection_applies_multiplier() { + let mut model = FeeModel::with_seed( + FeeModelConfig { + base_fee: 250, + spike_probability: 1.0, + spike_multiplier: 8.0, + noise_factor: 0.0, + }, + 99, + ) + .expect("valid config"); + + assert_eq!(model.generate_fees(3), vec![2_000, 2_000, 2_000]); + } +} diff --git a/packages/devkit/tests/harness_congested.rs b/packages/devkit/tests/harness_congested.rs index 29efc35..cb159e1 100644 --- a/packages/devkit/tests/harness_congested.rs +++ b/packages/devkit/tests/harness_congested.rs @@ -1,9 +1,7 @@ /// Integration test: load congested scenario and assert p95 fee_charged > 100,000 stroops. #[test] fn congested_scenario_p95_exceeds_100k() { - let path = std::path::Path::new( - "src/harness/scenarios/congested.json", - ); + let path = std::path::Path::new("src/harness/scenarios/congested.json"); let raw = std::fs::read_to_string(path).expect("congested.json not found"); let json: serde_json::Value = serde_json::from_str(&raw).expect("invalid JSON"); diff --git a/packages/devkit/tests/harness_normal.rs b/packages/devkit/tests/harness_normal.rs index 41c36f0..dfb0b1e 100644 --- a/packages/devkit/tests/harness_normal.rs +++ b/packages/devkit/tests/harness_normal.rs @@ -1,9 +1,7 @@ /// Integration test: load normal scenario and assert p50 fee_charged == 100 stroops. #[test] fn normal_scenario_p50_is_baseline() { - let path = std::path::Path::new( - "src/harness/scenarios/normal.json", - ); + let path = std::path::Path::new("src/harness/scenarios/normal.json"); let raw = std::fs::read_to_string(path).expect("normal.json not found"); let json: serde_json::Value = serde_json::from_str(&raw).expect("invalid JSON");