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
3 changes: 3 additions & 0 deletions packages/devkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ description = "Developer toolkit for testing and simulating the Stellar fee trac

[dependencies]
thiserror = "1"

[dev-dependencies]
serde_json = "1"
36 changes: 26 additions & 10 deletions packages/devkit/src/harness/horizon_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ pub struct HorizonMock {
pub scenario: String,
/// Optional simulated response delay in milliseconds.
pub delay_ms: Option<u64>,
/// Probability [0.0, 1.0] of returning a 500/503 error response.
pub error_rate: f64,
}

impl HorizonMock {
pub fn new(scenario: impl Into<String>) -> Self {
Self { scenario: scenario.into(), delay_ms: None }
Self { scenario: scenario.into(), delay_ms: None, error_rate: 0.0 }
}

/// Sets the simulated network latency delay.
Expand All @@ -24,12 +26,22 @@ impl HorizonMock {
}
}

/// Sets the error injection rate (0.0 = never, 1.0 = always).
pub fn with_error_rate(mut self, rate: f64) -> Self {
self.error_rate = rate.clamp(0.0, 1.0);
self
}

/// Returns true if this request should be failed based on the configured error rate.
pub fn should_inject_error(&self) -> bool {
self.error_rate > 0.0 && rand_f64() < self.error_rate
}

/// Switches to the next scenario from the rotator and updates the active scenario.
pub fn rotate(&mut self, rotator: &mut crate::harness::scenarios::ScenarioRotator) {
if let Some(next) = rotator.next() {
self.scenario = next.to_string();
}
Self { scenario: scenario.into() }
}

/// Logs a request to stdout with timestamp, method, path, and active scenario name.
Expand All @@ -44,17 +56,21 @@ impl HorizonMock {
/// Returns the JSON body for `GET /health`.
pub fn health_payload(&self) -> String {
format!(r#"{{"status":"ok","scenario":"{}"}}"#, self.scenario)
/// Path to the scenario JSON file to serve.
pub scenario_path: std::path::PathBuf,
}

impl HorizonMock {
pub fn new(scenario_path: impl Into<std::path::PathBuf>) -> Self {
Self { scenario_path: scenario_path.into() }
}

/// Loads and returns the scenario JSON to be served at `GET /fee_stats`.
pub fn fee_stats_payload(&self) -> std::io::Result<String> {
crate::harness::scenarios::load_from_file(&self.scenario_path)
crate::harness::scenarios::load_from_file(
std::path::Path::new(&format!("src/harness/scenarios/{}.json", self.scenario)),
)
}
}

/// Minimal pseudo-random float in [0.0, 1.0) using system time as entropy.
fn rand_f64() -> f64 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
(nanos % 1_000_000) as f64 / 1_000_000.0
}
2 changes: 1 addition & 1 deletion packages/devkit/src/harness/scenarios/congested.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"scenario": "congested",
"description": "High-load network: p95 > 100,000 stroops with multiple fee spikes",
"fee_stats": {
"last_ledger": "1000",
"last_ledger": "1001",
"last_ledger_base_fee": "100",
"ledger_capacity_usage": "0.97",
"fee_charged": {
Expand Down
41 changes: 41 additions & 0 deletions packages/devkit/src/harness/scenarios/normal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"scenario": "normal",
"description": "Baseline fee environment: low fees, no spikes, p50 ≈ 100 stroops",
"fee_stats": {
"last_ledger": "1000",
"last_ledger_base_fee": "100",
"ledger_capacity_usage": "0.10",
"fee_charged": {
"max": "200",
"min": "100",
"mode": "100",
"p10": "100",
"p20": "100",
"p30": "100",
"p40": "100",
"p50": "100",
"p60": "100",
"p70": "100",
"p80": "100",
"p90": "100",
"p95": "150",
"p99": "200"
},
"max_fee": {
"max": "500",
"min": "100",
"mode": "100",
"p10": "100",
"p20": "100",
"p30": "100",
"p40": "100",
"p50": "100",
"p60": "100",
"p70": "100",
"p80": "100",
"p90": "200",
"p95": "300",
"p99": "500"
}
}
}
2 changes: 1 addition & 1 deletion packages/devkit/src/harness/scenarios/recovery.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"scenario": "recovery",
"description": "Post-spike normalisation: fees declining from high back to baseline",
"fee_stats": {
"last_ledger": "1001",
"last_ledger": "1003",
"last_ledger_base_fee": "100",
"ledger_capacity_usage": "0.45",
"fee_charged": {
Expand Down
17 changes: 17 additions & 0 deletions packages/devkit/tests/harness_congested.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// 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 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");

let p95: u64 = json["fee_stats"]["fee_charged"]["p95"]
.as_str()
.expect("p95 missing")
.parse()
.expect("p95 not a number");

assert!(p95 > 100_000, "expected p95 > 100000, got {}", p95);
}
17 changes: 17 additions & 0 deletions packages/devkit/tests/harness_normal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// 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 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");

let p50: u64 = json["fee_stats"]["fee_charged"]["p50"]
.as_str()
.expect("p50 missing")
.parse()
.expect("p50 not a number");

assert_eq!(p50, 100, "expected p50 == 100, got {}", p50);
}
Loading