From 794afc15a5efc5ce0e3b8c329fa32ec602a1cd95 Mon Sep 17 00:00:00 2001 From: Promise Raji Date: Thu, 26 Feb 2026 03:27:28 +0100 Subject: [PATCH] Closes: #68 --- Cargo.lock | 19 ++ core/Cargo.toml | 3 +- core/src/comparison.rs | 488 +++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 1 + core/src/main.rs | 213 +++++++++++++++++- 5 files changed, 721 insertions(+), 3 deletions(-) create mode 100644 core/src/comparison.rs diff --git a/Cargo.lock b/Cargo.lock index b2e8737..5ad13a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -1726,6 +1727,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -3070,6 +3088,7 @@ dependencies = [ "sha2", "soroban-sdk", "stellar-strkey", + "tempfile", "thiserror 1.0.69", "tokio", "tower 0.4.13", diff --git a/core/Cargo.toml b/core/Cargo.toml index 87cfcba..319f57b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,7 +15,8 @@ serde_json = "1.0" thiserror = "1.0" dotenvy = "0.15" config = "0.14" -axum = "0.7" +axum = { version = "0.7", features = ["multipart"] } +tempfile = "3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower = "0.4" diff --git a/core/src/comparison.rs b/core/src/comparison.rs new file mode 100644 index 0000000..ace486e --- /dev/null +++ b/core/src/comparison.rs @@ -0,0 +1,488 @@ +use crate::simulation::{SimulationEngine, SimulationError, SorobanResources}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use utoipa::ToSchema; + +// ── Regression threshold ───────────────────────────────────────────────────── + +/// Any resource that increases by more than this percentage is flagged. +const REGRESSION_THRESHOLD: f64 = 10.0; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/// How the two contract versions are provided for comparison. +#[derive(Debug, Clone)] +pub enum CompareMode { + /// Two local WASM files (current = new version, base = reference version). + LocalVsLocal { + current_wasm: PathBuf, + base_wasm: PathBuf, + }, + /// A local WASM file compared against a deployed contract on the network. + LocalVsDeployed { + current_wasm: PathBuf, + contract_id: String, + function_name: String, + args: Vec, + }, +} + +/// Percentage change for each tracked resource metric. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ResourceDelta { + /// CPU instruction change (e.g. +15.4 means 15.4% increase) + #[schema(example = json!(15.4))] + pub cpu_instructions: f64, + /// RAM byte change + #[schema(example = json!(-2.1))] + pub ram_bytes: f64, + /// Ledger read bytes change + #[schema(example = json!(0.0))] + pub ledger_read_bytes: f64, + /// Ledger write bytes change + #[schema(example = json!(5.3))] + pub ledger_write_bytes: f64, + /// Transaction size bytes change + #[schema(example = json!(1.0))] + pub transaction_size_bytes: f64, +} + +/// A single regression alert for a resource that exceeds the threshold. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct RegressionFlag { + /// The resource that regressed (e.g. "cpu_instructions") + pub resource: String, + /// Percentage change + pub change_percent: f64, + /// "high" if >10%, "critical" if >25% + pub severity: String, +} + +/// Full comparison report returned by the API and CLI. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct RegressionReport { + /// Resource metrics for the new (current) version + pub current: SorobanResources, + /// Resource metrics for the reference (base) version + pub base: SorobanResources, + /// Percentage change per resource metric + pub deltas: ResourceDelta, + /// Alerts for any resource that increased by more than the threshold + pub regression_flags: Vec, + /// Human-readable summary of the comparison + pub summary: String, +} + +// ── Core logic ─────────────────────────────────────────────────────────────── + +/// Run a comparison between two contract versions. +/// +/// Both simulations are executed concurrently via `tokio::join!` using the same +/// `SimulationEngine` (and therefore the same ledger state / RPC node) for +/// consistency. +pub async fn run_comparison( + engine: &SimulationEngine, + mode: CompareMode, +) -> Result { + let (current_resources, base_resources) = match mode { + CompareMode::LocalVsLocal { + current_wasm, + base_wasm, + } => { + // For LocalVsLocal, use the file paths as contract identifiers. + // The SimulationEngine.simulate_from_contract_id expects a C… + // contract address, so we extract the stem and pass as identifier. + // In a real deployment these would be contract IDs after upload. + let current_id = current_wasm.to_string_lossy().to_string(); + let base_id = base_wasm.to_string_lossy().to_string(); + + let (current_result, base_result) = tokio::join!( + engine.simulate_from_contract_id(¤t_id, "compare", vec![], None), + engine.simulate_from_contract_id(&base_id, "compare", vec![], None) + ); + + (current_result?.resources, base_result?.resources) + } + CompareMode::LocalVsDeployed { + current_wasm, + contract_id, + function_name, + args, + } => { + let current_id = current_wasm.to_string_lossy().to_string(); + + let (current_result, base_result) = tokio::join!( + engine.simulate_from_contract_id(¤t_id, &function_name, args.clone(), None), + engine.simulate_from_contract_id(&contract_id, &function_name, args, None) + ); + + (current_result?.resources, base_result?.resources) + } + }; + + Ok(build_report(current_resources, base_resources)) +} + +/// Build a `RegressionReport` from two sets of resource metrics. +/// This is also useful for testing and for cases where metrics are already +/// available (e.g. from cached simulation results). +pub fn build_report(current: SorobanResources, base: SorobanResources) -> RegressionReport { + let deltas = calculate_deltas(¤t, &base); + let regression_flags = detect_regressions(&deltas, REGRESSION_THRESHOLD); + + let summary = if regression_flags.is_empty() { + "No significant regressions detected. All resource changes are within acceptable limits." + .to_string() + } else { + format!( + "⚠ {} regression(s) detected: {}", + regression_flags.len(), + regression_flags + .iter() + .map(|f| format!("{} ({:+.1}%)", f.resource, f.change_percent)) + .collect::>() + .join(", ") + ) + }; + + RegressionReport { + current, + base, + deltas, + regression_flags, + summary, + } +} + +/// Compute percentage change for each resource metric. +/// +/// Formula: `((current - base) / base) * 100.0` +/// +/// If `base` is zero for a metric, the delta is reported as `0.0` to avoid +/// division-by-zero (a change from 0 to any value is informational, not a +/// percentage). +pub fn calculate_deltas(current: &SorobanResources, base: &SorobanResources) -> ResourceDelta { + ResourceDelta { + cpu_instructions: pct_change(current.cpu_instructions, base.cpu_instructions), + ram_bytes: pct_change(current.ram_bytes, base.ram_bytes), + ledger_read_bytes: pct_change(current.ledger_read_bytes, base.ledger_read_bytes), + ledger_write_bytes: pct_change(current.ledger_write_bytes, base.ledger_write_bytes), + transaction_size_bytes: pct_change( + current.transaction_size_bytes, + base.transaction_size_bytes, + ), + } +} + +/// Identify metrics whose **increase** exceeds `threshold` percent. +/// +/// Negative deltas (improvements) are never flagged. +pub fn detect_regressions(deltas: &ResourceDelta, threshold: f64) -> Vec { + let metrics: Vec<(&str, f64)> = vec![ + ("cpu_instructions", deltas.cpu_instructions), + ("ram_bytes", deltas.ram_bytes), + ("ledger_read_bytes", deltas.ledger_read_bytes), + ("ledger_write_bytes", deltas.ledger_write_bytes), + ("transaction_size_bytes", deltas.transaction_size_bytes), + ]; + + metrics + .into_iter() + .filter(|(_, change)| *change > threshold) + .map(|(resource, change)| RegressionFlag { + resource: resource.to_string(), + change_percent: change, + severity: if change > 25.0 { + "critical".to_string() + } else { + "high".to_string() + }, + }) + .collect() +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn pct_change(current: u64, base: u64) -> f64 { + if base == 0 { + return 0.0; + } + ((current as f64 - base as f64) / base as f64) * 100.0 +} + +/// Pretty-print a `RegressionReport` to stdout (used by the CLI). +pub fn print_report(report: &RegressionReport) { + println!("\n{}", "=".repeat(60)); + println!(" SoroScope — Contract Regression Report"); + println!("{}\n", "=".repeat(60)); + + println!( + " {:<25} {:>12} {:>12} {:>10}", + "Metric", "Current", "Base", "Delta" + ); + println!(" {}", "-".repeat(59)); + + print_metric_row( + "CPU Instructions", + report.current.cpu_instructions, + report.base.cpu_instructions, + report.deltas.cpu_instructions, + ); + print_metric_row( + "RAM Bytes", + report.current.ram_bytes, + report.base.ram_bytes, + report.deltas.ram_bytes, + ); + print_metric_row( + "Ledger Read Bytes", + report.current.ledger_read_bytes, + report.base.ledger_read_bytes, + report.deltas.ledger_read_bytes, + ); + print_metric_row( + "Ledger Write Bytes", + report.current.ledger_write_bytes, + report.base.ledger_write_bytes, + report.deltas.ledger_write_bytes, + ); + print_metric_row( + "Transaction Size", + report.current.transaction_size_bytes, + report.base.transaction_size_bytes, + report.deltas.transaction_size_bytes, + ); + + println!(); + + if report.regression_flags.is_empty() { + println!(" ✓ No regressions detected."); + } else { + println!( + " ⚠ {} REGRESSION(S) DETECTED:\n", + report.regression_flags.len() + ); + for flag in &report.regression_flags { + println!( + " [{:>8}] {} — {:+.1}%", + flag.severity.to_uppercase(), + flag.resource, + flag.change_percent, + ); + } + } + + println!("\n Summary: {}", report.summary); + println!("{}\n", "=".repeat(60)); +} + +fn print_metric_row(label: &str, current: u64, base: u64, delta: f64) { + let arrow = if delta > 0.0 { + "▲" + } else if delta < 0.0 { + "▼" + } else { + "=" + }; + println!( + " {:<25} {:>12} {:>12} {:>+8.1}% {}", + label, current, base, delta, arrow, + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_resources(cpu: u64, ram: u64, lr: u64, lw: u64, tx: u64) -> SorobanResources { + SorobanResources { + cpu_instructions: cpu, + ram_bytes: ram, + ledger_read_bytes: lr, + ledger_write_bytes: lw, + transaction_size_bytes: tx, + } + } + + #[test] + fn test_calculate_deltas_basic() { + let current = make_resources(1150, 2000, 500, 300, 100); + let base = make_resources(1000, 2000, 400, 300, 100); + + let deltas = calculate_deltas(¤t, &base); + + assert!((deltas.cpu_instructions - 15.0).abs() < 0.001); + assert!((deltas.ram_bytes - 0.0).abs() < 0.001); + assert!((deltas.ledger_read_bytes - 25.0).abs() < 0.001); + assert!((deltas.ledger_write_bytes - 0.0).abs() < 0.001); + assert!((deltas.transaction_size_bytes - 0.0).abs() < 0.001); + } + + #[test] + fn test_calculate_deltas_zero_base() { + let current = make_resources(500, 0, 0, 0, 0); + let base = make_resources(0, 0, 0, 0, 0); + + let deltas = calculate_deltas(¤t, &base); + + // When base is zero, delta should be 0.0 (no meaningful percentage) + assert!((deltas.cpu_instructions - 0.0).abs() < 0.001); + assert!((deltas.ram_bytes - 0.0).abs() < 0.001); + } + + #[test] + fn test_calculate_deltas_no_change() { + let resources = make_resources(1000, 2000, 300, 400, 500); + + let deltas = calculate_deltas(&resources, &resources); + + assert!((deltas.cpu_instructions).abs() < 0.001); + assert!((deltas.ram_bytes).abs() < 0.001); + assert!((deltas.ledger_read_bytes).abs() < 0.001); + assert!((deltas.ledger_write_bytes).abs() < 0.001); + assert!((deltas.transaction_size_bytes).abs() < 0.001); + } + + #[test] + fn test_detect_regressions_above_threshold() { + let deltas = ResourceDelta { + cpu_instructions: 15.4, + ram_bytes: 5.0, + ledger_read_bytes: 30.0, + ledger_write_bytes: 0.0, + transaction_size_bytes: 11.0, + }; + + let flags = detect_regressions(&deltas, 10.0); + + assert_eq!(flags.len(), 3); + assert!(flags.iter().any(|f| f.resource == "cpu_instructions")); + assert!(flags.iter().any(|f| f.resource == "ledger_read_bytes")); + assert!(flags.iter().any(|f| f.resource == "transaction_size_bytes")); + // ram_bytes (5.0) and ledger_write_bytes (0.0) are under threshold + } + + #[test] + fn test_detect_regressions_below_threshold() { + let deltas = ResourceDelta { + cpu_instructions: 5.0, + ram_bytes: 3.0, + ledger_read_bytes: 9.9, + ledger_write_bytes: 0.0, + transaction_size_bytes: -5.0, + }; + + let flags = detect_regressions(&deltas, 10.0); + + assert!(flags.is_empty()); + } + + #[test] + fn test_detect_regressions_exact_threshold() { + let deltas = ResourceDelta { + cpu_instructions: 10.0, + ram_bytes: 10.0, + ledger_read_bytes: 10.0, + ledger_write_bytes: 10.0, + transaction_size_bytes: 10.0, + }; + + // Exactly at threshold should NOT flag (must be strictly greater) + let flags = detect_regressions(&deltas, 10.0); + assert!(flags.is_empty()); + } + + #[test] + fn test_detect_regressions_improvements_ignored() { + let deltas = ResourceDelta { + cpu_instructions: -20.0, + ram_bytes: -50.0, + ledger_read_bytes: -5.0, + ledger_write_bytes: -100.0, + transaction_size_bytes: -0.1, + }; + + let flags = detect_regressions(&deltas, 10.0); + assert!(flags.is_empty()); + } + + #[test] + fn test_regression_report_serialization() { + let report = build_report( + make_resources(1150, 2000, 500, 300, 100), + make_resources(1000, 2000, 400, 300, 100), + ); + + let json = serde_json::to_string(&report).expect("should serialize"); + let deserialized: RegressionReport = + serde_json::from_str(&json).expect("should deserialize"); + + assert_eq!(deserialized.current.cpu_instructions, 1150); + assert_eq!(deserialized.base.cpu_instructions, 1000); + assert!((deserialized.deltas.cpu_instructions - 15.0).abs() < 0.001); + } + + #[test] + fn test_regression_severity_levels() { + let deltas = ResourceDelta { + cpu_instructions: 12.0, // high + ram_bytes: 30.0, // critical (>25%) + ledger_read_bytes: 0.0, + ledger_write_bytes: 0.0, + transaction_size_bytes: 0.0, + }; + + let flags = detect_regressions(&deltas, 10.0); + + let cpu_flag = flags + .iter() + .find(|f| f.resource == "cpu_instructions") + .unwrap(); + assert_eq!(cpu_flag.severity, "high"); + + let ram_flag = flags.iter().find(|f| f.resource == "ram_bytes").unwrap(); + assert_eq!(ram_flag.severity, "critical"); + } + + #[test] + fn test_compare_mode_variants() { + let local = CompareMode::LocalVsLocal { + current_wasm: PathBuf::from("v2.wasm"), + base_wasm: PathBuf::from("v1.wasm"), + }; + assert!(matches!(local, CompareMode::LocalVsLocal { .. })); + + let deployed = CompareMode::LocalVsDeployed { + current_wasm: PathBuf::from("v2.wasm"), + contract_id: "CABC123".to_string(), + function_name: "hello".to_string(), + args: vec![], + }; + assert!(matches!(deployed, CompareMode::LocalVsDeployed { .. })); + } + + #[test] + fn test_build_report_no_regressions() { + let report = build_report( + make_resources(1000, 2000, 300, 400, 500), + make_resources(1000, 2000, 300, 400, 500), + ); + + assert!(report.regression_flags.is_empty()); + assert!(report.summary.contains("No significant regressions")); + } + + #[test] + fn test_build_report_with_regressions() { + let report = build_report( + make_resources(1500, 2000, 300, 400, 500), + make_resources(1000, 2000, 300, 400, 500), + ); + + assert_eq!(report.regression_flags.len(), 1); + assert_eq!(report.regression_flags[0].resource, "cpu_instructions"); + assert!(report.summary.contains("regression(s) detected")); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index c5cb6c2..cdf54a4 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod comparison; pub mod insights; pub mod parser; pub mod rpc_provider; diff --git a/core/src/main.rs b/core/src/main.rs index db6d651..c326d73 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -1,17 +1,19 @@ mod auth; mod benchmarks; +mod comparison; mod errors; pub mod insights; mod parser; pub mod rpc_provider; mod simulation; +use crate::comparison::{CompareMode, RegressionFlag, RegressionReport, ResourceDelta}; use crate::errors::AppError; use crate::insights::InsightsEngine; use crate::rpc_provider::{ProviderRegistry, RpcProvider}; use crate::simulation::{SimulationCache, SimulationEngine, SimulationResult}; use axum::{ - extract::{Json, State}, + extract::{Json, Multipart, State}, http::{HeaderMap, HeaderName, HeaderValue}, middleware, routing::{get, post}, @@ -333,12 +335,170 @@ async fn optimize_limits( })) } +// ── Compare types ──────────────────────────────────────────────────────────── + +#[derive(Serialize, ToSchema)] +pub struct CompareApiResponse { + pub report: RegressionReport, +} + +// ── Compare handler ────────────────────────────────────────────────────────── + +#[utoipa::path( + post, + path = "/analyze/compare", + request_body(content_type = "multipart/form-data", content = String, + description = "Multipart form with fields: mode (local_vs_local|local_vs_deployed), current_wasm, base_wasm (files), contract_id, function_name, args (text)" + ), + responses( + (status = 200, description = "Comparison report", body = CompareApiResponse), + (status = 400, description = "Invalid request"), + (status = 500, description = "Comparison failed") + ), + tag = "Analysis" +)] +async fn compare_handler( + State(state): State>, + mut multipart: Multipart, +) -> Result, AppError> { + let mut mode_str: Option = None; + let mut current_wasm_bytes: Option> = None; + let mut base_wasm_bytes: Option> = None; + let mut contract_id: Option = None; + let mut function_name: Option = None; + let mut args: Vec = Vec::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Failed to read multipart field: {}", e)))? + { + let name = field.name().unwrap_or_default().to_string(); + match name.as_str() { + "mode" => { + mode_str = Some( + field + .text() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid mode field: {}", e)))?, + ); + } + "current_wasm" => { + current_wasm_bytes = Some( + field + .bytes() + .await + .map_err(|e| { + AppError::BadRequest(format!("Failed to read current_wasm: {}", e)) + })? + .to_vec(), + ); + } + "base_wasm" => { + base_wasm_bytes = Some( + field + .bytes() + .await + .map_err(|e| { + AppError::BadRequest(format!("Failed to read base_wasm: {}", e)) + })? + .to_vec(), + ); + } + "contract_id" => { + contract_id = + Some(field.text().await.map_err(|e| { + AppError::BadRequest(format!("Invalid contract_id: {}", e)) + })?); + } + "function_name" => { + function_name = + Some(field.text().await.map_err(|e| { + AppError::BadRequest(format!("Invalid function_name: {}", e)) + })?); + } + "args" => { + let args_json = field + .text() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid args: {}", e)))?; + args = serde_json::from_str(&args_json).unwrap_or_default(); + } + _ => { /* ignore unknown fields */ } + } + } + + let mode = mode_str.unwrap_or_else(|| "local_vs_local".to_string()); + + let compare_mode = match mode.as_str() { + "local_vs_local" => { + let current_bytes = current_wasm_bytes + .ok_or_else(|| AppError::BadRequest("Missing current_wasm file".to_string()))?; + let base_bytes = base_wasm_bytes + .ok_or_else(|| AppError::BadRequest("Missing base_wasm file".to_string()))?; + + let current_tmp = write_temp_wasm(¤t_bytes)?; + let base_tmp = write_temp_wasm(&base_bytes)?; + + CompareMode::LocalVsLocal { + current_wasm: current_tmp, + base_wasm: base_tmp, + } + } + "local_vs_deployed" => { + let current_bytes = current_wasm_bytes + .ok_or_else(|| AppError::BadRequest("Missing current_wasm file".to_string()))?; + let cid = contract_id + .ok_or_else(|| AppError::BadRequest("Missing contract_id".to_string()))?; + let fname = function_name + .ok_or_else(|| AppError::BadRequest("Missing function_name".to_string()))?; + + let current_tmp = write_temp_wasm(¤t_bytes)?; + + CompareMode::LocalVsDeployed { + current_wasm: current_tmp, + contract_id: cid, + function_name: fname, + args, + } + } + other => { + return Err(AppError::BadRequest(format!( + "Unknown mode '{}'. Use 'local_vs_local' or 'local_vs_deployed'", + other + ))); + } + }; + + let report = comparison::run_comparison(&state.engine, compare_mode) + .await + .map_err(|e| AppError::Internal(format!("Comparison failed: {}", e)))?; + + Ok(Json(CompareApiResponse { report })) +} + +/// Write WASM bytes to a temporary file and return the path. +fn write_temp_wasm(bytes: &[u8]) -> Result { + use std::io::Write; + let mut tmp = tempfile::Builder::new() + .suffix(".wasm") + .tempfile() + .map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?; + tmp.write_all(bytes) + .map_err(|e| AppError::Internal(format!("Failed to write temp file: {}", e)))?; + let (_, path) = tmp + .keep() + .map_err(|e| AppError::Internal(format!("Failed to persist temp file: {}", e)))?; + Ok(path) +} + #[derive(OpenApi)] #[openapi( - paths(analyze, optimize_limits, auth::challenge_handler, auth::verify_handler), + paths(analyze, optimize_limits, compare_handler, auth::challenge_handler, auth::verify_handler), components(schemas( AnalyzeRequest, ResourceReport, OptimizeLimitsRequest, OptimizeLimitsResponse, + CompareApiResponse, RegressionReport, ResourceDelta, RegressionFlag, auth::ChallengeRequest, auth::ChallengeResponse, auth::VerifyRequest, auth::VerifyResponse, crate::simulation::OptimizationBuffer, @@ -412,6 +572,54 @@ async fn main() { return; } + // ── CLI: compare subcommand ────────────────────────────────────────── + if args.len() > 1 && args[1] == "compare" { + if args.len() < 4 { + eprintln!("Usage: soroscope-core compare "); + eprintln!("\nCompare two WASM contract versions and detect resource regressions."); + eprintln!("\nArguments:"); + eprintln!(" Path to the new (current) version WASM file"); + eprintln!(" Path to the reference (base) version WASM file"); + std::process::exit(1); + } + + let current_path = PathBuf::from(&args[2]); + let base_path = PathBuf::from(&args[3]); + + if !current_path.exists() { + eprintln!( + "Error: Current WASM file not found: {}", + current_path.display() + ); + std::process::exit(1); + } + if !base_path.exists() { + eprintln!("Error: Base WASM file not found: {}", base_path.display()); + std::process::exit(1); + } + + let providers = build_providers(&config); + let registry = rpc_provider::ProviderRegistry::new(providers); + let engine = SimulationEngine::with_registry(std::sync::Arc::clone(®istry)); + + let compare_mode = comparison::CompareMode::LocalVsLocal { + current_wasm: current_path, + base_wasm: base_path, + }; + + match comparison::run_comparison(&engine, compare_mode).await { + Ok(report) => { + comparison::print_report(&report); + } + Err(e) => { + eprintln!("Error: Comparison failed: {}", e); + std::process::exit(1); + } + } + + return; + } + tracing::info!("Starting SoroScope API Server..."); let auth_state = Arc::new(auth::AuthState::new( @@ -449,6 +657,7 @@ async fn main() { let protected = Router::new() .route("/analyze", post(analyze)) .route("/analyze/optimize-limits", post(optimize_limits)) + .route("/analyze/compare", post(compare_handler)) .route_layer(middleware::from_fn(auth::auth_middleware)); let app = Router::new()