diff --git a/core/src/lib.rs b/core/src/lib.rs index a36a823..9edf4eb 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,2 +1,3 @@ +pub mod network_config; pub mod parser; pub mod simulation; diff --git a/core/src/main.rs b/core/src/main.rs index ae707a0..e337d01 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -1,10 +1,12 @@ mod auth; mod benchmarks; mod errors; +pub mod network_config; mod parser; mod simulation; use crate::errors::AppError; +use crate::network_config::NetworkConfig; use crate::simulation::{SimulationCache, SimulationEngine, SimulationResult}; use axum::{ extract::{Json, State}, @@ -72,6 +74,23 @@ pub struct AnalyzeRequest { pub args: Option>, /// Map of Key-Base64 to Value-Base64 ledger entry overrides pub ledger_overrides: Option>, + /// Shadow network configuration for protocol upgrade impact analysis. + /// + /// Accepts either: + /// - A preset name: `"protocol_21"`, `"p21"`, `"current"`, + /// `"protocol_22"`, `"p22"`, `"next"`, `"custom"`, `"private"` + /// - A full `NetworkConfig` JSON object with custom parameters + pub network_config: Option, +} + +/// Flexible input: either a preset name or a full custom config object. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum NetworkConfigInput { + /// A preset name like `"protocol_22"` or `"next"`. + Preset(String), + /// A full custom configuration object. + Custom(NetworkConfig), } #[derive(Serialize, ToSchema)] @@ -93,6 +112,10 @@ pub struct ResourceReport { pub transaction_size_bytes: u64, /// Report showing which data was injected vs live pub state_dependency: Option>, + /// Protocol upgrade impact comparison (present when `network_config` was + /// supplied in the request). + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol_impact: Option, } #[derive(Serialize, ToSchema, Debug)] @@ -117,6 +140,10 @@ fn to_report(result: &SimulationResult) -> ResourceReport { }) .collect() }), + protocol_impact: result + .protocol_impact + .as_ref() + .and_then(|impact| serde_json::to_value(impact).ok()), } } @@ -145,6 +172,23 @@ async fn analyze( ); let args = payload.args.clone().unwrap_or_default(); + + // Resolve the optional shadow network config. + let shadow_config: Option = match &payload.network_config { + Some(NetworkConfigInput::Preset(name)) => { + let cfg = network_config::resolve_preset(name).ok_or_else(|| { + AppError::BadRequest(format!( + "Unknown network config preset '{}'. \ + Valid presets: protocol_21, p21, current, protocol_22, p22, next, upcoming, custom, private", + name + )) + })?; + Some(cfg) + } + Some(NetworkConfigInput::Custom(cfg)) => Some(cfg.clone()), + None => None, + }; + let cache_key = SimulationCache::generate_key(&payload.contract_id, &payload.function_name, &args); @@ -159,6 +203,7 @@ async fn analyze( &payload.function_name, args, payload.ledger_overrides.clone(), + shadow_config, ) .await .map_err(|e| AppError::Internal(format!("Simulation failed: {}", e)))?; diff --git a/core/src/network_config.rs b/core/src/network_config.rs new file mode 100644 index 0000000..aad6ff6 --- /dev/null +++ b/core/src/network_config.rs @@ -0,0 +1,397 @@ +use serde::{Deserialize, Serialize}; + +use crate::simulation::SorobanResources; + +// ── Protocol cost parameters ────────────────────────────────────────────────── + +/// Network-level cost parameters that govern how resource consumption maps to +/// fees (stroops). Different protocol versions use different rates. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NetworkConfig { + /// Human-readable label for this configuration. + pub name: String, + /// Protocol version number (e.g. 21, 22). + pub protocol_version: u32, + + // ── Fee rates ───────────────────────────────────────────────────────── + /// CPU instructions per fee unit (higher = cheaper per instruction). + pub cpu_insns_per_fee_unit: u64, + /// Memory bytes per fee unit. + pub mem_bytes_per_fee_unit: u64, + /// Ledger I/O bytes per fee unit. + pub ledger_bytes_per_fee_unit: u64, + /// Transaction size bytes per fee unit. + pub tx_size_bytes_per_fee_unit: u64, + + // ── Resource limits (per transaction) ───────────────────────────────── + /// Maximum CPU instructions a single transaction may consume. + pub tx_max_instructions: u64, + /// Maximum memory bytes a single transaction may consume. + pub tx_max_memory_bytes: u64, + /// Maximum ledger read bytes per transaction. + pub tx_max_read_bytes: u64, + /// Maximum ledger write bytes per transaction. + pub tx_max_write_bytes: u64, + /// Maximum transaction envelope size in bytes. + pub tx_max_size_bytes: u64, +} + +impl NetworkConfig { + /// Calculate the total fee (stroops) for a given resource footprint under + /// this configuration's cost rates. + pub fn calculate_cost(&self, resources: &SorobanResources) -> u64 { + let cpu_fee = resources.cpu_instructions / self.cpu_insns_per_fee_unit; + let mem_fee = resources.ram_bytes / self.mem_bytes_per_fee_unit; + let ledger_fee = (resources.ledger_read_bytes + resources.ledger_write_bytes) + / self.ledger_bytes_per_fee_unit; + let size_fee = resources.transaction_size_bytes / self.tx_size_bytes_per_fee_unit; + cpu_fee + mem_fee + ledger_fee + size_fee + } + + /// Check which resource limits would be exceeded under this configuration. + pub fn check_limits(&self, resources: &SorobanResources) -> Vec { + let mut exceeded = Vec::new(); + if resources.cpu_instructions > self.tx_max_instructions { + exceeded.push(LimitExceeded { + resource: "cpu_instructions".to_string(), + used: resources.cpu_instructions, + limit: self.tx_max_instructions, + }); + } + if resources.ram_bytes > self.tx_max_memory_bytes { + exceeded.push(LimitExceeded { + resource: "ram_bytes".to_string(), + used: resources.ram_bytes, + limit: self.tx_max_memory_bytes, + }); + } + if resources.ledger_read_bytes > self.tx_max_read_bytes { + exceeded.push(LimitExceeded { + resource: "ledger_read_bytes".to_string(), + used: resources.ledger_read_bytes, + limit: self.tx_max_read_bytes, + }); + } + if resources.ledger_write_bytes > self.tx_max_write_bytes { + exceeded.push(LimitExceeded { + resource: "ledger_write_bytes".to_string(), + used: resources.ledger_write_bytes, + limit: self.tx_max_write_bytes, + }); + } + if resources.transaction_size_bytes > self.tx_max_size_bytes { + exceeded.push(LimitExceeded { + resource: "transaction_size_bytes".to_string(), + used: resources.transaction_size_bytes, + limit: self.tx_max_size_bytes, + }); + } + exceeded + } +} + +/// A single resource limit that was exceeded. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LimitExceeded { + pub resource: String, + pub used: u64, + pub limit: u64, +} + +// ── Pre-set protocol configurations ─────────────────────────────────────────── + +/// Protocol 21 — Current Testnet (baseline). +/// +/// Cost rates match the hardcoded values previously in +/// `SimulationEngine::calculate_cost`. +pub fn protocol_21() -> NetworkConfig { + NetworkConfig { + name: "Protocol 21 (Current Testnet)".to_string(), + protocol_version: 21, + cpu_insns_per_fee_unit: 10_000, + mem_bytes_per_fee_unit: 1_024, + ledger_bytes_per_fee_unit: 1_024, + tx_size_bytes_per_fee_unit: 1_024, + tx_max_instructions: 100_000_000, + tx_max_memory_bytes: 40 * 1024 * 1024, // 40 MiB + tx_max_read_bytes: 200 * 1024, // 200 KiB + tx_max_write_bytes: 65_536, // 64 KiB + tx_max_size_bytes: 71_680, // 70 KiB + } +} + +/// Protocol 22 — Upcoming / Next. +/// +/// Models an anticipated upgrade with higher CPU/memory budgets but tighter +/// per-unit costs to incentivise efficient contracts. +pub fn protocol_22() -> NetworkConfig { + NetworkConfig { + name: "Protocol 22 (Upcoming/Next)".to_string(), + protocol_version: 22, + // Slightly cheaper CPU (larger divisor → lower per-unit fee). + cpu_insns_per_fee_unit: 12_500, + // Memory cost stays the same. + mem_bytes_per_fee_unit: 1_024, + // Ledger I/O gets more expensive (smaller divisor → higher fee). + ledger_bytes_per_fee_unit: 768, + tx_size_bytes_per_fee_unit: 1_024, + // Higher CPU budget — allows more complex contracts. + tx_max_instructions: 200_000_000, + tx_max_memory_bytes: 64 * 1024 * 1024, // 64 MiB + tx_max_read_bytes: 200 * 1024, + tx_max_write_bytes: 131_072, // 128 KiB + tx_max_size_bytes: 71_680, + } +} + +/// Custom / Private Network — sensible defaults that can be overridden via +/// the API request body. +pub fn custom_private() -> NetworkConfig { + NetworkConfig { + name: "Custom Private Network".to_string(), + protocol_version: 21, + cpu_insns_per_fee_unit: 10_000, + mem_bytes_per_fee_unit: 1_024, + ledger_bytes_per_fee_unit: 1_024, + tx_size_bytes_per_fee_unit: 1_024, + tx_max_instructions: 500_000_000, // generous + tx_max_memory_bytes: 128 * 1024 * 1024, // 128 MiB + tx_max_read_bytes: 1024 * 1024, // 1 MiB + tx_max_write_bytes: 512 * 1024, // 512 KiB + tx_max_size_bytes: 256 * 1024, // 256 KiB + } +} + +/// Resolve a preset name to the corresponding `NetworkConfig`. +/// +/// Recognised names (case-insensitive): +/// - `"protocol_21"` / `"p21"` / `"current"` +/// - `"protocol_22"` / `"p22"` / `"next"` / `"upcoming"` +/// - `"custom"` / `"private"` +pub fn resolve_preset(name: &str) -> Option { + match name.to_lowercase().as_str() { + "protocol_21" | "p21" | "current" => Some(protocol_21()), + "protocol_22" | "p22" | "next" | "upcoming" => Some(protocol_22()), + "custom" | "private" => Some(custom_private()), + _ => None, + } +} + +// ── Impact comparison ───────────────────────────────────────────────────────── + +/// Side-by-side comparison of a transaction's cost under two protocol configs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProtocolImpact { + pub baseline: ProtocolCostSnapshot, + pub shadow: ProtocolCostSnapshot, + /// Signed difference: `shadow.cost_stroops - baseline.cost_stroops`. + /// Positive means the shadow config is *more* expensive. + pub cost_difference_stroops: i64, + /// Percentage change: `(shadow - baseline) / baseline * 100`. + pub cost_change_pct: f64, +} + +/// Cost snapshot under a single protocol configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProtocolCostSnapshot { + pub config_name: String, + pub protocol_version: u32, + pub cost_stroops: u64, + pub limits_exceeded: Vec, +} + +/// Compare a resource footprint across two configurations and produce an +/// impact report. +pub fn compare( + resources: &SorobanResources, + baseline: &NetworkConfig, + shadow: &NetworkConfig, +) -> ProtocolImpact { + let baseline_cost = baseline.calculate_cost(resources); + let shadow_cost = shadow.calculate_cost(resources); + + let diff = shadow_cost as i64 - baseline_cost as i64; + let pct = if baseline_cost > 0 { + (diff as f64 / baseline_cost as f64) * 100.0 + } else { + 0.0 + }; + + ProtocolImpact { + baseline: ProtocolCostSnapshot { + config_name: baseline.name.clone(), + protocol_version: baseline.protocol_version, + cost_stroops: baseline_cost, + limits_exceeded: baseline.check_limits(resources), + }, + shadow: ProtocolCostSnapshot { + config_name: shadow.name.clone(), + protocol_version: shadow.protocol_version, + cost_stroops: shadow_cost, + limits_exceeded: shadow.check_limits(resources), + }, + cost_difference_stroops: diff, + cost_change_pct: pct, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_resources() -> SorobanResources { + SorobanResources { + cpu_instructions: 1_000_000, + ram_bytes: 2_048, + ledger_read_bytes: 512, + ledger_write_bytes: 256, + transaction_size_bytes: 1_024, + } + } + + #[test] + fn test_protocol_21_cost_matches_legacy() { + let r = sample_resources(); + let cfg = protocol_21(); + // Legacy formula: cpu/10000 + ram/1024 + (read+write)/1024 + let legacy = r.cpu_instructions / 10_000 + + r.ram_bytes / 1_024 + + (r.ledger_read_bytes + r.ledger_write_bytes) / 1_024 + + r.transaction_size_bytes / 1_024; + assert_eq!(cfg.calculate_cost(&r), legacy); + } + + #[test] + fn test_protocol_22_cheaper_cpu() { + let r = sample_resources(); + let p21 = protocol_21(); + let p22 = protocol_22(); + // P22 has a higher cpu_insns_per_fee_unit, so CPU portion is cheaper. + let p21_cpu = r.cpu_instructions / p21.cpu_insns_per_fee_unit; + let p22_cpu = r.cpu_instructions / p22.cpu_insns_per_fee_unit; + assert!(p22_cpu < p21_cpu, "P22 should have cheaper CPU fees"); + } + + #[test] + fn test_protocol_22_more_expensive_ledger() { + let r = sample_resources(); + let p21 = protocol_21(); + let p22 = protocol_22(); + let p21_ledger = + (r.ledger_read_bytes + r.ledger_write_bytes) / p21.ledger_bytes_per_fee_unit; + let p22_ledger = + (r.ledger_read_bytes + r.ledger_write_bytes) / p22.ledger_bytes_per_fee_unit; + assert!( + p22_ledger >= p21_ledger, + "P22 should have same or higher ledger fees" + ); + } + + #[test] + fn test_compare_produces_correct_diff() { + let r = sample_resources(); + let impact = compare(&r, &protocol_21(), &protocol_22()); + let expected_diff = impact.shadow.cost_stroops as i64 - impact.baseline.cost_stroops as i64; + assert_eq!(impact.cost_difference_stroops, expected_diff); + } + + #[test] + fn test_compare_percentage() { + let r = sample_resources(); + let impact = compare(&r, &protocol_21(), &protocol_22()); + let expected_pct = + (impact.cost_difference_stroops as f64 / impact.baseline.cost_stroops as f64) * 100.0; + assert!((impact.cost_change_pct - expected_pct).abs() < 0.001); + } + + #[test] + fn test_check_limits_within_budget() { + let r = sample_resources(); + assert!(protocol_21().check_limits(&r).is_empty()); + assert!(protocol_22().check_limits(&r).is_empty()); + assert!(custom_private().check_limits(&r).is_empty()); + } + + #[test] + fn test_check_limits_exceeded() { + let r = SorobanResources { + cpu_instructions: 500_000_000, // exceeds P21 limit of 100M + ram_bytes: 2_048, + ledger_read_bytes: 512, + ledger_write_bytes: 512, + transaction_size_bytes: 1_024, + }; + let exceeded = protocol_21().check_limits(&r); + assert_eq!(exceeded.len(), 1); + assert_eq!(exceeded[0].resource, "cpu_instructions"); + assert_eq!(exceeded[0].used, 500_000_000); + assert_eq!(exceeded[0].limit, 100_000_000); + } + + #[test] + fn test_resolve_preset_case_insensitive() { + assert!(resolve_preset("protocol_21").is_some()); + assert!(resolve_preset("P21").is_some()); + assert!(resolve_preset("CURRENT").is_some()); + assert!(resolve_preset("protocol_22").is_some()); + assert!(resolve_preset("Next").is_some()); + assert!(resolve_preset("custom").is_some()); + assert!(resolve_preset("unknown").is_none()); + } + + #[test] + fn test_resolve_preset_returns_correct_version() { + let p21 = resolve_preset("p21").unwrap(); + assert_eq!(p21.protocol_version, 21); + let p22 = resolve_preset("p22").unwrap(); + assert_eq!(p22.protocol_version, 22); + } + + #[test] + fn test_custom_private_generous_limits() { + let cfg = custom_private(); + assert!(cfg.tx_max_instructions > protocol_21().tx_max_instructions); + assert!(cfg.tx_max_memory_bytes > protocol_21().tx_max_memory_bytes); + } + + #[test] + fn test_network_config_serialization() { + let cfg = protocol_21(); + let json = serde_json::to_string(&cfg).unwrap(); + let deserialized: NetworkConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(cfg, deserialized); + } + + #[test] + fn test_protocol_impact_serialization() { + let r = sample_resources(); + let impact = compare(&r, &protocol_21(), &protocol_22()); + let json = serde_json::to_string(&impact).unwrap(); + let deserialized: ProtocolImpact = serde_json::from_str(&json).unwrap(); + assert_eq!(impact.baseline, deserialized.baseline); + assert_eq!(impact.shadow, deserialized.shadow); + assert_eq!( + impact.cost_difference_stroops, + deserialized.cost_difference_stroops + ); + // f64 round-trip through JSON may introduce tiny precision differences. + assert!((impact.cost_change_pct - deserialized.cost_change_pct).abs() < 1e-10); + } + + #[test] + fn test_zero_resources_zero_cost() { + let r = SorobanResources::default(); + assert_eq!(protocol_21().calculate_cost(&r), 0); + assert_eq!(protocol_22().calculate_cost(&r), 0); + } + + #[test] + fn test_compare_identical_configs() { + let r = sample_resources(); + let impact = compare(&r, &protocol_21(), &protocol_21()); + assert_eq!(impact.cost_difference_stroops, 0); + assert!((impact.cost_change_pct - 0.0).abs() < 0.001); + } +} diff --git a/core/src/simulation.rs b/core/src/simulation.rs index 502ab93..d904027 100644 --- a/core/src/simulation.rs +++ b/core/src/simulation.rs @@ -1,3 +1,4 @@ +use crate::network_config::{self, NetworkConfig, ProtocolImpact}; use crate::parser::ArgParser; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use reqwest::Client; @@ -69,6 +70,10 @@ pub struct SimulationResult { pub cost_stroops: u64, #[serde(skip_serializing_if = "Option::is_none")] pub state_dependency: Option>, + /// Present when a shadow network config was requested — shows cost + /// comparison between the baseline (Protocol 21) and the shadow config. + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol_impact: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,6 +178,7 @@ impl SimulationEngine { function_name: &str, args: Vec, ledger_overrides: Option>, + shadow_config: Option, ) -> Result { if contract_id.is_empty() { return Err(SimulationError::NodeError( @@ -182,14 +188,25 @@ impl SimulationEngine { if let Some(overrides) = ledger_overrides { if !overrides.is_empty() { - return self + let mut result = self .simulate_locally(contract_id, function_name, args, overrides) - .await; + .await?; + if let Some(shadow) = shadow_config { + result.protocol_impact = + Some(self.compute_protocol_impact(&result.resources, &shadow)); + } + return Ok(result); } } let transaction_xdr = self.create_invoke_transaction(contract_id, function_name, args)?; - self.simulate_transaction(&transaction_xdr).await + let mut result = self.simulate_transaction(&transaction_xdr).await?; + + if let Some(shadow) = shadow_config { + result.protocol_impact = Some(self.compute_protocol_impact(&result.resources, &shadow)); + } + + Ok(result) } async fn simulate_transaction( @@ -299,6 +316,7 @@ impl SimulationEngine { latest_ledger: rpc_result.latest_ledger, cost_stroops, state_dependency: None, + protocol_impact: None, }) } @@ -357,6 +375,7 @@ impl SimulationEngine { total_bytes } + #[allow(clippy::only_used_in_recursion)] fn estimate_scval_size(&self, scval: &soroban_sdk::xdr::ScVal) -> u64 { use soroban_sdk::xdr::ScVal; match scval { @@ -390,10 +409,18 @@ impl SimulationEngine { } fn calculate_cost(&self, resources: &SorobanResources) -> u64 { - let cpu_cost = resources.cpu_instructions / 10000; - let ram_cost = resources.ram_bytes / 1024; - let ledger_cost = (resources.ledger_read_bytes + resources.ledger_write_bytes) / 1024; - cpu_cost + ram_cost + ledger_cost + network_config::protocol_21().calculate_cost(resources) + } + + /// Compare the resource footprint against the baseline (Protocol 21) and + /// the caller-supplied shadow configuration. + fn compute_protocol_impact( + &self, + resources: &SorobanResources, + shadow: &NetworkConfig, + ) -> ProtocolImpact { + let baseline = network_config::protocol_21(); + network_config::compare(resources, &baseline, shadow) } /// Create invoke transaction for contract call @@ -726,7 +753,7 @@ mod tests { async fn test_simulate_from_contract_id_empty() { let engine = SimulationEngine::new("https://test.com".to_string()); let result = engine - .simulate_from_contract_id("", "test_function", vec![], None) + .simulate_from_contract_id("", "test_function", vec![], None, None) .await; assert!(matches!(result, Err(SimulationError::NodeError(_)))); } @@ -918,6 +945,7 @@ mod tests { latest_ledger: 42, cost_stroops: 10, state_dependency: None, + protocol_impact: None, } }