diff --git a/src/commands/analytics.rs b/src/commands/analytics.rs new file mode 100644 index 00000000..e3840d67 --- /dev/null +++ b/src/commands/analytics.rs @@ -0,0 +1,900 @@ +use crate::utils::{config, print as p}; +use anyhow::Result; +use chrono::Utc; +use clap::{Args, Subcommand}; +use colored::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +// ── CLI definition ──────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub enum AnalyticsCommands { + /// Record a deployment event + Track(TrackArgs), + /// Show deployment metrics for a contract + Metrics(MetricsArgs), + /// List all recorded deployments + List(ListArgs), + /// Detect anomalies across recent deployments + Anomalies(AnomaliesArgs), + /// Export analytics data as JSON or CSV + Export(ExportArgs), + /// Show a visual summary / dashboard of deployments + Dashboard(DashboardArgs), +} + +#[derive(Args)] +pub struct TrackArgs { + /// Contract ID that was deployed + #[arg(long)] + pub contract_id: String, + /// Network where the deployment occurred + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// WASM hash of the deployed binary + #[arg(long)] + pub wasm_hash: Option, + /// Deployer wallet public key + #[arg(long)] + pub deployer: Option, + /// Fee paid in stroops + #[arg(long)] + pub fee_stroops: Option, + /// Transaction hash + #[arg(long)] + pub tx_hash: Option, + /// Arbitrary label for this deployment + #[arg(long)] + pub label: Option, + /// Deployment duration in seconds (build + deploy) + #[arg(long)] + pub duration_secs: Option, + /// Whether the deployment succeeded + #[arg(long, default_value = "true")] + pub success: bool, + /// Error message if deployment failed + #[arg(long)] + pub error: Option, +} + +#[derive(Args)] +pub struct MetricsArgs { + /// Contract ID to show metrics for + #[arg(long)] + pub contract_id: Option, + /// Network filter + #[arg(long)] + pub network: Option, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ListArgs { + /// Network filter + #[arg(long)] + pub network: Option, + /// Contract filter + #[arg(long)] + pub contract_id: Option, + /// Maximum records to show + #[arg(long, default_value_t = 20)] + pub limit: usize, + /// Show failures only + #[arg(long)] + pub failures: bool, +} + +#[derive(Args)] +pub struct AnomaliesArgs { + /// Network to analyse + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// Multiplier above average fee that counts as a fee anomaly (default 3x) + #[arg(long, default_value_t = 3.0)] + pub fee_threshold: f64, + /// Minimum deployments before anomaly detection runs + #[arg(long, default_value_t = 3)] + pub min_samples: usize, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ExportArgs { + /// Output format: json | csv + #[arg(long, default_value = "json", value_parser = ["json", "csv"])] + pub format: String, + /// Output file path (default: stdout) + #[arg(long)] + pub out: Option, + /// Network filter + #[arg(long)] + pub network: Option, +} + +#[derive(Args)] +pub struct DashboardArgs { + /// Network to display + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, +} + +// ── Data structures ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentEvent { + pub id: String, + pub contract_id: String, + pub network: String, + pub wasm_hash: Option, + pub deployer: Option, + pub fee_stroops: Option, + pub tx_hash: Option, + pub label: Option, + pub duration_secs: Option, + pub success: bool, + pub error: Option, + pub timestamp: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeploymentMetrics { + pub contract_id: Option, + pub network: Option, + pub total_deployments: usize, + pub successful: usize, + pub failed: usize, + pub success_rate_pct: f64, + pub avg_fee_stroops: Option, + pub min_fee_stroops: Option, + pub max_fee_stroops: Option, + pub avg_duration_secs: Option, + pub unique_deployers: usize, + pub unique_contracts: usize, + pub first_deployment: Option, + pub last_deployment: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Anomaly { + pub kind: String, + pub contract_id: String, + pub network: String, + pub description: String, + pub event_id: String, + pub timestamp: String, +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +fn analytics_dir() -> Result { + let dir = config::config_dir().join("analytics"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn events_path() -> Result { + Ok(analytics_dir()?.join("deployments.json")) +} + +fn load_events() -> Result> { + let path = events_path()?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&data).unwrap_or_default()) +} + +fn save_events(events: &[DeploymentEvent]) -> Result<()> { + fs::write(events_path()?, serde_json::to_string_pretty(events)?)?; + Ok(()) +} + +// ── Metrics computation ─────────────────────────────────────────────────────── + +pub fn compute_metrics( + events: &[DeploymentEvent], + contract_id: Option<&str>, + network: Option<&str>, +) -> DeploymentMetrics { + let filtered: Vec<_> = events + .iter() + .filter(|e| network.is_none_or(|n| e.network == n)) + .filter(|e| contract_id.is_none_or(|c| e.contract_id == c)) + .collect(); + + let total = filtered.len(); + let successful = filtered.iter().filter(|e| e.success).count(); + let failed = total - successful; + let success_rate = if total > 0 { + (successful as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + + let fees: Vec = filtered.iter().filter_map(|e| e.fee_stroops).collect(); + let avg_fee = if fees.is_empty() { + None + } else { + Some(fees.iter().sum::() as f64 / fees.len() as f64) + }; + let min_fee = fees.iter().copied().min(); + let max_fee = fees.iter().copied().max(); + + let durations: Vec = filtered + .iter() + .filter_map(|e| e.duration_secs) + .collect(); + let avg_duration = if durations.is_empty() { + None + } else { + Some(durations.iter().sum::() as f64 / durations.len() as f64) + }; + + let mut deployers = std::collections::HashSet::new(); + let mut contracts = std::collections::HashSet::new(); + for e in &filtered { + if let Some(ref d) = e.deployer { + deployers.insert(d.clone()); + } + contracts.insert(e.contract_id.clone()); + } + + let first = filtered.first().map(|e| e.timestamp.clone()); + let last = filtered.last().map(|e| e.timestamp.clone()); + + DeploymentMetrics { + contract_id: contract_id.map(|s| s.to_string()), + network: network.map(|s| s.to_string()), + total_deployments: total, + successful, + failed, + success_rate_pct: success_rate, + avg_fee_stroops: avg_fee, + min_fee_stroops: min_fee, + max_fee_stroops: max_fee, + avg_duration_secs: avg_duration, + unique_deployers: deployers.len(), + unique_contracts: contracts.len(), + first_deployment: first, + last_deployment: last, + } +} + +/// Detect anomalies: +/// - High fee (fee > threshold * avg_fee) +/// - Repeated failures for the same contract +/// - Unusually fast or slow deployments +pub fn detect_anomalies( + events: &[DeploymentEvent], + network: &str, + fee_threshold: f64, + min_samples: usize, +) -> Vec { + let net_events: Vec<_> = events + .iter() + .filter(|e| e.network == network) + .collect(); + + if net_events.len() < min_samples { + return vec![]; + } + + let mut anomalies = Vec::new(); + + // Compute average fee + let fees: Vec = net_events.iter().filter_map(|e| e.fee_stroops).collect(); + let avg_fee = if fees.len() >= min_samples { + Some(fees.iter().sum::() as f64 / fees.len() as f64) + } else { + None + }; + + // Fee anomalies + if let Some(avg) = avg_fee { + for event in &net_events { + if let Some(fee) = event.fee_stroops { + if fee as f64 > avg * fee_threshold { + anomalies.push(Anomaly { + kind: "high-fee".to_string(), + contract_id: event.contract_id.clone(), + network: network.to_string(), + description: format!( + "Fee {} stroops is {:.1}x above average ({:.0} stroops)", + fee, + fee as f64 / avg, + avg + ), + event_id: event.id.clone(), + timestamp: event.timestamp.clone(), + }); + } + } + } + } + + // Repeated failures per contract + let mut failure_counts: HashMap<&str, usize> = HashMap::new(); + for e in &net_events { + if !e.success { + *failure_counts.entry(e.contract_id.as_str()).or_insert(0) += 1; + } + } + for (contract, &count) in &failure_counts { + if count >= 2 { + anomalies.push(Anomaly { + kind: "repeated-failure".to_string(), + contract_id: contract.to_string(), + network: network.to_string(), + description: format!( + "{} consecutive/recent deployment failure(s) for this contract", + count + ), + event_id: "aggregate".to_string(), + timestamp: Utc::now().to_rfc3339(), + }); + } + } + + anomalies +} + +// ── Serialise to CSV ────────────────────────────────────────────────────────── + +fn events_to_csv(events: &[DeploymentEvent]) -> String { + let mut out = String::from( + "id,contract_id,network,wasm_hash,deployer,fee_stroops,tx_hash,label,duration_secs,success,error,timestamp\n", + ); + for e in events { + out.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{},{},{}\n", + e.id, + e.contract_id, + e.network, + e.wasm_hash.as_deref().unwrap_or(""), + e.deployer.as_deref().unwrap_or(""), + e.fee_stroops + .map(|f| f.to_string()) + .unwrap_or_default(), + e.tx_hash.as_deref().unwrap_or(""), + e.label.as_deref().unwrap_or(""), + e.duration_secs + .map(|d| d.to_string()) + .unwrap_or_default(), + e.success, + e.error.as_deref().unwrap_or(""), + e.timestamp, + )); + } + out +} + +// ── Command handlers ────────────────────────────────────────────────────────── + +pub fn handle(cmd: AnalyticsCommands) -> Result<()> { + match cmd { + AnalyticsCommands::Track(args) => handle_track(args), + AnalyticsCommands::Metrics(args) => handle_metrics(args), + AnalyticsCommands::List(args) => handle_list(args), + AnalyticsCommands::Anomalies(args) => handle_anomalies(args), + AnalyticsCommands::Export(args) => handle_export(args), + AnalyticsCommands::Dashboard(args) => handle_dashboard(args), + } +} + +fn handle_track(args: TrackArgs) -> Result<()> { + p::header("Track Deployment"); + config::validate_network(&args.network)?; + + if args.contract_id.is_empty() { + anyhow::bail!("--contract-id must not be empty"); + } + + let id = format!( + "dep-{}-{}", + &args.contract_id[..args.contract_id.len().min(8)], + Utc::now().timestamp() + ); + + let event = DeploymentEvent { + id: id.clone(), + contract_id: args.contract_id.clone(), + network: args.network.clone(), + wasm_hash: args.wasm_hash.clone(), + deployer: args.deployer.clone(), + fee_stroops: args.fee_stroops, + tx_hash: args.tx_hash.clone(), + label: args.label.clone(), + duration_secs: args.duration_secs, + success: args.success, + error: args.error.clone(), + timestamp: Utc::now().to_rfc3339(), + }; + + let mut events = load_events()?; + events.push(event.clone()); + save_events(&events)?; + + p::separator(); + p::kv_accent("Event ID", &id); + p::kv("Contract", &args.contract_id); + p::kv("Network", &args.network); + p::kv( + "Status", + if args.success { + "success" + } else { + "failed" + }, + ); + if let Some(fee) = args.fee_stroops { + p::kv("Fee (stroops)", &fee.to_string()); + p::kv( + "Fee (XLM)", + &format!("{:.7}", fee as f64 / 10_000_000.0), + ); + } + p::separator(); + p::success("Deployment event recorded."); + Ok(()) +} + +fn handle_metrics(args: MetricsArgs) -> Result<()> { + p::header("Deployment Metrics"); + + let events = load_events()?; + let metrics = compute_metrics( + &events, + args.contract_id.as_deref(), + args.network.as_deref(), + ); + + if args.json { + println!("{}", serde_json::to_string_pretty(&metrics)?); + return Ok(()); + } + + p::separator(); + if let Some(ref c) = metrics.contract_id { + p::kv("Contract", c); + } + if let Some(ref n) = metrics.network { + p::kv("Network", n); + } + p::kv("Total deployments", &format!("{}", metrics.total_deployments)); + p::kv( + "Successful", + &format!("{}", metrics.successful), + ); + p::kv( + "Failed", + &format!("{}", metrics.failed), + ); + p::kv( + "Success rate", + &format!("{:.1}%", metrics.success_rate_pct), + ); + if let Some(avg) = metrics.avg_fee_stroops { + p::kv("Avg fee (stroops)", &format!("{:.0}", avg)); + p::kv( + "Avg fee (XLM)", + &format!("{:.7}", avg / 10_000_000.0), + ); + } + if let Some(min) = metrics.min_fee_stroops { + p::kv("Min fee (stroops)", &format!("{}", min)); + } + if let Some(max) = metrics.max_fee_stroops { + p::kv("Max fee (stroops)", &format!("{}", max)); + } + if let Some(dur) = metrics.avg_duration_secs { + p::kv("Avg duration (s)", &format!("{:.1}", dur)); + } + p::kv("Unique deployers", &format!("{}", metrics.unique_deployers)); + p::kv( + "Unique contracts", + &format!("{}", metrics.unique_contracts), + ); + if let Some(ref first) = metrics.first_deployment { + p::kv("First deployment", first.get(..16).unwrap_or(first)); + } + if let Some(ref last) = metrics.last_deployment { + p::kv("Last deployment", last.get(..16).unwrap_or(last)); + } + p::separator(); + Ok(()) +} + +fn handle_list(args: ListArgs) -> Result<()> { + p::header("Deployment Events"); + + let events = load_events()?; + let mut filtered: Vec<_> = events + .iter() + .filter(|e| { + args.network + .as_deref() + .is_none_or(|n| e.network == n) + }) + .filter(|e| { + args.contract_id + .as_deref() + .is_none_or(|c| e.contract_id == c) + }) + .filter(|e| !args.failures || !e.success) + .collect(); + + // Most recent first + filtered.reverse(); + let displayed: Vec<_> = filtered.iter().take(args.limit).collect(); + + if displayed.is_empty() { + p::info("No deployment events found. Track one with `starforge analytics track`."); + return Ok(()); + } + + p::separator(); + println!( + " {:<20} {:<14} {:<10} {:<10} {}", + "ID".dimmed(), + "Contract".dimmed(), + "Network".dimmed(), + "Status".dimmed(), + "Timestamp".dimmed(), + ); + println!(" {}", "─".repeat(75).dimmed()); + + for event in displayed { + let status = if event.success { + "ok".green().to_string() + } else { + "failed".red().to_string() + }; + let ts = event.timestamp.get(..16).unwrap_or(&event.timestamp); + println!( + " {:<20} {:<14} {:<10} {:<10} {}", + event.id.white(), + short_id(&event.contract_id).cyan(), + event.network.white(), + status, + ts.dimmed(), + ); + } + p::separator(); + Ok(()) +} + +fn handle_anomalies(args: AnomaliesArgs) -> Result<()> { + p::header("Deployment Anomaly Detection"); + config::validate_network(&args.network)?; + + let events = load_events()?; + let anomalies = detect_anomalies(&events, &args.network, args.fee_threshold, args.min_samples); + + if args.json { + println!("{}", serde_json::to_string_pretty(&anomalies)?); + return Ok(()); + } + + if anomalies.is_empty() { + p::separator(); + p::info("No anomalies detected."); + p::separator(); + return Ok(()); + } + + p::separator(); + println!( + " {:<18} {:<16} {}", + "Kind".dimmed(), + "Contract".dimmed(), + "Description".dimmed(), + ); + println!(" {}", "─".repeat(72).dimmed()); + + for anomaly in &anomalies { + println!( + " {:<18} {:<16} {}", + anomaly.kind.yellow(), + short_id(&anomaly.contract_id).cyan(), + anomaly.description.white(), + ); + } + p::separator(); + println!( + " {} {} anomaly/anomalies detected on {}", + anomalies.len().to_string().yellow().bold(), + "total".dimmed(), + args.network.cyan() + ); + p::separator(); + Ok(()) +} + +fn handle_export(args: ExportArgs) -> Result<()> { + p::header("Export Analytics Data"); + + let events = load_events()?; + let filtered: Vec<_> = events + .iter() + .filter(|e| { + args.network + .as_deref() + .is_none_or(|n| e.network == n) + }) + .cloned() + .collect(); + + let data = match args.format.as_str() { + "csv" => events_to_csv(&filtered), + _ => serde_json::to_string_pretty(&filtered)?, + }; + + if let Some(ref out_path) = args.out { + fs::write(out_path, &data)?; + p::success(&format!( + "Exported {} events to {}", + filtered.len(), + out_path.display() + )); + } else { + println!("{}", data); + } + Ok(()) +} + +fn handle_dashboard(args: DashboardArgs) -> Result<()> { + p::header("Deployment Analytics Dashboard"); + config::validate_network(&args.network)?; + + let events = load_events()?; + let metrics = compute_metrics(&events, None, Some(&args.network)); + let anomalies = detect_anomalies(&events, &args.network, 3.0, 3); + + p::separator(); + println!( + " {} {}", + "Network:".dimmed(), + args.network.cyan().bold() + ); + println!(); + + // Summary bar + println!( + " {:<28} {}", + "Total deployments".bright_white(), + format!("{}", metrics.total_deployments).white().bold() + ); + println!( + " {:<28} {}", + "Success rate".bright_white(), + format!("{:.1}%", metrics.success_rate_pct) + .green() + .bold() + ); + println!( + " {:<28} {}", + "Failed deployments".bright_white(), + if metrics.failed > 0 { + format!("{}", metrics.failed).red().bold() + } else { + "0".green().bold() + } + ); + println!( + " {:<28} {}", + "Unique contracts".bright_white(), + format!("{}", metrics.unique_contracts).white() + ); + println!( + " {:<28} {}", + "Unique deployers".bright_white(), + format!("{}", metrics.unique_deployers).white() + ); + + if let Some(avg) = metrics.avg_fee_stroops { + println!( + " {:<28} {} ({:.7} XLM)", + "Avg fee".bright_white(), + format!("{:.0} stroops", avg).white(), + avg / 10_000_000.0 + ); + } + + println!(); + if anomalies.is_empty() { + println!( + " {} {}", + "Anomalies:".dimmed(), + "none detected".green() + ); + } else { + println!( + " {} {}", + "Anomalies:".dimmed(), + format!("{} detected", anomalies.len()).yellow().bold() + ); + for a in &anomalies { + println!( + " {} [{}] {}", + "⚠".yellow(), + a.kind.yellow(), + a.description.dimmed() + ); + } + } + + // ASCII bar chart of success vs failure + if metrics.total_deployments > 0 { + println!(); + let bar_width = 40usize; + let ok_bars = + (metrics.successful as f64 / metrics.total_deployments as f64 * bar_width as f64) + as usize; + let fail_bars = bar_width - ok_bars; + println!( + " Success/Fail [{}{}]", + "█".repeat(ok_bars).green(), + "░".repeat(fail_bars).red() + ); + } + + p::separator(); + p::info("Use `starforge analytics anomalies` for detailed anomaly info."); + p::info("Use `starforge analytics export --format csv` to export data."); + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn short_id(id: &str) -> String { + if id.len() > 12 { + format!("{}…", &id[..12]) + } else { + id.to_string() + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_event( + id: &str, + contract: &str, + network: &str, + fee: Option, + success: bool, + ) -> DeploymentEvent { + DeploymentEvent { + id: id.to_string(), + contract_id: contract.to_string(), + network: network.to_string(), + wasm_hash: None, + deployer: Some("GTEST".to_string()), + fee_stroops: fee, + tx_hash: None, + label: None, + duration_secs: None, + success, + error: None, + timestamp: Utc::now().to_rfc3339(), + } + } + + #[test] + fn compute_metrics_empty() { + let m = compute_metrics(&[], None, None); + assert_eq!(m.total_deployments, 0); + assert_eq!(m.success_rate_pct, 0.0); + assert!(m.avg_fee_stroops.is_none()); + } + + #[test] + fn compute_metrics_counts_correctly() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(1000), true), + make_event("e2", "CA", "testnet", Some(2000), false), + make_event("e3", "CB", "testnet", Some(3000), true), + ]; + let m = compute_metrics(&events, None, Some("testnet")); + assert_eq!(m.total_deployments, 3); + assert_eq!(m.successful, 2); + assert_eq!(m.failed, 1); + assert!((m.success_rate_pct - 66.666).abs() < 0.01); + assert_eq!(m.avg_fee_stroops, Some(2000.0)); + assert_eq!(m.unique_contracts, 2); + } + + #[test] + fn compute_metrics_filters_by_contract() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(100), true), + make_event("e2", "CB", "testnet", Some(200), true), + ]; + let m = compute_metrics(&events, Some("CA"), Some("testnet")); + assert_eq!(m.total_deployments, 1); + assert_eq!(m.avg_fee_stroops, Some(100.0)); + } + + #[test] + fn compute_metrics_filters_by_network() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(100), true), + make_event("e2", "CA", "mainnet", Some(200), true), + ]; + let m = compute_metrics(&events, None, Some("mainnet")); + assert_eq!(m.total_deployments, 1); + assert_eq!(m.avg_fee_stroops, Some(200.0)); + } + + #[test] + fn detect_anomalies_needs_min_samples() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(100), true), + make_event("e2", "CA", "testnet", Some(100), true), + ]; + // min_samples=3 means no anomalies with only 2 events + let anomalies = detect_anomalies(&events, "testnet", 3.0, 3); + assert!(anomalies.is_empty()); + } + + #[test] + fn detect_anomalies_finds_high_fee() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(100), true), + make_event("e2", "CA", "testnet", Some(100), true), + make_event("e3", "CA", "testnet", Some(100), true), + make_event("e4", "CA", "testnet", Some(10000), true), // 100x average + ]; + let anomalies = detect_anomalies(&events, "testnet", 3.0, 3); + assert!(anomalies.iter().any(|a| a.kind == "high-fee")); + } + + #[test] + fn detect_anomalies_finds_repeated_failure() { + let events = vec![ + make_event("e1", "CA", "testnet", Some(100), true), + make_event("e2", "CA", "testnet", Some(100), true), + make_event("e3", "CB", "testnet", Some(100), false), + make_event("e4", "CB", "testnet", Some(100), false), + ]; + let anomalies = detect_anomalies(&events, "testnet", 3.0, 2); + assert!(anomalies + .iter() + .any(|a| a.kind == "repeated-failure" && a.contract_id == "CB")); + } + + #[test] + fn events_to_csv_has_header() { + let events = vec![make_event("e1", "CA", "testnet", Some(100), true)]; + let csv = events_to_csv(&events); + assert!(csv.starts_with("id,contract_id,network")); + assert!(csv.contains("e1")); + } + + #[test] + fn short_id_truncates_long_ids() { + let id = "GABC123456789XYZ"; + let s = short_id(id); + assert!(s.contains('…')); + assert!(s.len() < id.len() + 1); + } + + #[test] + fn short_id_leaves_short_ids_intact() { + let id = "GABC"; + assert_eq!(short_id(id), "GABC"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0dd82eb7..896da10f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod benchmark; pub mod command_tree; pub mod completions; @@ -18,7 +19,7 @@ pub mod network; pub mod new; pub mod orchestrate; pub mod node; -pub mod orchestrate; +pub mod optimize; pub mod plugin; pub mod registry; pub mod shell; @@ -29,4 +30,6 @@ pub mod test; pub mod tutorial; pub mod tx; pub mod upgrade; +pub mod upgrade_auto; +pub mod verify; pub mod wallet; diff --git a/src/commands/optimize.rs b/src/commands/optimize.rs new file mode 100644 index 00000000..13a03f0a --- /dev/null +++ b/src/commands/optimize.rs @@ -0,0 +1,950 @@ +use crate::utils::{config, print as p}; +use anyhow::Result; +use chrono::Utc; +use clap::{Args, Subcommand}; +use colored::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; + +// ── CLI definition ──────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub enum OptimizeCommands { + /// Analyse a compiled WASM binary for performance issues + Analyse(AnalyseArgs), + /// Apply automatic code transformation hints to a Rust contract source file + Transform(TransformArgs), + /// Benchmark and compare two WASM binaries + Bench(BenchArgs), + /// Show the last optimization report for a contract + Report(ReportArgs), + /// List all stored optimization reports + Reports(ReportsArgs), +} + +#[derive(Args)] +pub struct AnalyseArgs { + /// Path to the compiled WASM file + #[arg(long)] + pub wasm: PathBuf, + /// Contract label (for report storage) + #[arg(long)] + pub contract: String, + /// Output as JSON + #[arg(long)] + pub json: bool, + /// Fail with exit code 1 if critical issues are found + #[arg(long, default_value = "false")] + pub fail_on_critical: bool, +} + +#[derive(Args)] +pub struct TransformArgs { + /// Path to the Rust contract source file to analyse + #[arg(long)] + pub src: PathBuf, + /// Output file path (default: overwrite source) + #[arg(long)] + pub out: Option, + /// Dry-run: print suggested changes but do not write them + #[arg(long, default_value = "false")] + pub dry_run: bool, +} + +#[derive(Args)] +pub struct BenchArgs { + /// Path to the baseline WASM + #[arg(long)] + pub baseline: PathBuf, + /// Path to the optimized WASM to compare against baseline + #[arg(long)] + pub optimized: PathBuf, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ReportArgs { + /// Contract label + #[arg(long)] + pub contract: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ReportsArgs { + /// Filter by contract label + #[arg(long)] + pub contract: Option, +} + +// ── Data structures ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum IssueSeverity { + Critical, + Warning, + Info, +} + +impl std::fmt::Display for IssueSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IssueSeverity::Critical => write!(f, "critical"), + IssueSeverity::Warning => write!(f, "warning"), + IssueSeverity::Info => write!(f, "info"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OptimizationIssue { + pub id: String, + pub kind: String, + pub severity: IssueSeverity, + pub description: String, + pub recommendation: String, + pub estimated_saving_pct: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OptimizationReport { + pub id: String, + pub contract: String, + pub wasm_hash: String, + pub wasm_size_bytes: usize, + pub timestamp: String, + pub total_issues: usize, + pub critical: usize, + pub warnings: usize, + pub infos: usize, + pub overall_score: u8, + pub issues: Vec, +} + +impl OptimizationReport { + pub fn has_critical(&self) -> bool { + self.critical > 0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransformSuggestion { + pub file: String, + pub line: usize, + pub original: String, + pub suggested: String, + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BenchmarkComparison { + pub baseline_hash: String, + pub optimized_hash: String, + pub baseline_size_bytes: usize, + pub optimized_size_bytes: usize, + pub size_delta_bytes: i64, + pub size_reduction_pct: f64, + pub baseline_instruction_count: usize, + pub optimized_instruction_count: usize, + pub instruction_delta: i64, + pub instruction_reduction_pct: f64, + pub timestamp: String, +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +fn optimize_dir() -> Result { + let dir = config::config_dir().join("optimize"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn reports_path() -> Result { + Ok(optimize_dir()?.join("reports.json")) +} + +fn load_reports_store() -> Result> { + let path = reports_path()?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&data).unwrap_or_default()) +} + +fn save_reports_store(reports: &[OptimizationReport]) -> Result<()> { + fs::write(reports_path()?, serde_json::to_string_pretty(reports)?)?; + Ok(()) +} + +// ── Analysis engine ─────────────────────────────────────────────────────────── + +fn wasm_hash_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Count approximate WASM instruction opcodes (every byte is not an instruction, +/// but this gives a rough relative comparison between binaries). +fn estimate_instruction_count(bytes: &[u8]) -> usize { + // Count opcodes that are common Wasm instructions (0x00–0xBF range after header) + if bytes.len() <= 8 { + return 0; + } + bytes[8..].iter().filter(|&&b| b <= 0xBF).count() +} + +/// Static WASM analysis — returns a list of performance issues. +pub fn analyse_wasm(bytes: &[u8]) -> Vec { + let mut issues = Vec::new(); + let size_kb = bytes.len() as f64 / 1024.0; + + // Size checks + if size_kb > 100.0 { + issues.push(OptimizationIssue { + id: "OPT-001".to_string(), + kind: "binary-size".to_string(), + severity: IssueSeverity::Critical, + description: format!( + "WASM binary is {:.1} KB, approaching the Soroban 128 KB limit.", + size_kb + ), + recommendation: + "Enable wasm-opt passes, use `opt-level = 'z'` in release profile, and remove unused dependencies." + .to_string(), + estimated_saving_pct: Some(20.0), + }); + } else if size_kb > 64.0 { + issues.push(OptimizationIssue { + id: "OPT-002".to_string(), + kind: "binary-size".to_string(), + severity: IssueSeverity::Warning, + description: format!("WASM binary is {:.1} KB — moderately large.", size_kb), + recommendation: "Consider `opt-level = 's'` and `lto = true` in release profile." + .to_string(), + estimated_saving_pct: Some(10.0), + }); + } + + // Check for debug symbols (they add bloat without adding functionality) + let has_debug_name = bytes + .windows(5) + .any(|w| w == b".name" || w == b"debug"); + if has_debug_name { + issues.push(OptimizationIssue { + id: "OPT-003".to_string(), + kind: "debug-info".to_string(), + severity: IssueSeverity::Warning, + description: "Debug symbols detected in WASM binary.".to_string(), + recommendation: + "Build with `cargo build --release` and add `strip = true` to Cargo.toml [profile.release]." + .to_string(), + estimated_saving_pct: Some(15.0), + }); + } + + // Check for data section patterns that may indicate large static strings + let large_data_threshold = 512usize; + let data_runs = bytes + .windows(large_data_threshold) + .filter(|w| w.iter().all(|&b| b >= 0x20 && b <= 0x7E)) + .count(); + if data_runs > 0 { + issues.push(OptimizationIssue { + id: "OPT-004".to_string(), + kind: "large-static-strings".to_string(), + severity: IssueSeverity::Info, + description: format!( + "{} large printable data block(s) detected — may be long error strings.", + data_runs + ), + recommendation: + "Use short symbolic error codes instead of long string messages in contract errors." + .to_string(), + estimated_saving_pct: Some(5.0), + }); + } + + // Check for unreachable opcode (0x00 in a context that may be wasted code) + let unreachable_count = bytes.iter().filter(|&&b| b == 0x00).count(); + if unreachable_count > 50 { + issues.push(OptimizationIssue { + id: "OPT-005".to_string(), + kind: "unreachable-code".to_string(), + severity: IssueSeverity::Info, + description: format!( + "High null/unreachable byte density ({} occurrences) — dead code may be present.", + unreachable_count + ), + recommendation: "Run `wasm-opt -Oz` to strip dead code.".to_string(), + estimated_saving_pct: Some(3.0), + }); + } + + // LTO check: if binary size is above 20 KB and no optimizations evident, suggest LTO + if size_kb > 20.0 && !has_debug_name { + issues.push(OptimizationIssue { + id: "OPT-006".to_string(), + kind: "lto-suggestion".to_string(), + severity: IssueSeverity::Info, + description: "Consider enabling Link-Time Optimization for further size reduction." + .to_string(), + recommendation: "Add `lto = true` and `codegen-units = 1` to [profile.release] in Cargo.toml.".to_string(), + estimated_saving_pct: Some(8.0), + }); + } + + issues +} + +/// Compute an overall optimization score (0–100, higher is better). +pub fn compute_score(issues: &[OptimizationIssue]) -> u8 { + let mut penalty: i32 = 0; + for issue in issues { + penalty += match issue.severity { + IssueSeverity::Critical => 25, + IssueSeverity::Warning => 10, + IssueSeverity::Info => 3, + }; + } + (100i32 - penalty).max(0) as u8 +} + +/// Perform static source-code transformation suggestions on a Rust file. +pub fn analyse_source(content: &str, file: &str) -> Vec { + let mut suggestions = Vec::new(); + + for (line_idx, line) in content.lines().enumerate() { + let line_no = line_idx + 1; + let trimmed = line.trim(); + + // Suggest replacing .clone() on primitives + if trimmed.contains(".clone()") && (trimmed.contains("u64") || trimmed.contains("i64") || trimmed.contains("u32") || trimmed.contains("bool")) { + suggestions.push(TransformSuggestion { + file: file.to_string(), + line: line_no, + original: line.to_string(), + suggested: line.replace(".clone()", " /* .clone() not needed for Copy types */").to_string(), + reason: "Copy types (u64, i64, u32, bool) don't need .clone() — remove it to avoid unnecessary overhead.".to_string(), + }); + } + + // Suggest soroban_sdk::Vec instead of std::vec::Vec + if trimmed.contains("Vec<") && !trimmed.starts_with("//") { + if trimmed.contains("std::vec") || (trimmed.contains("Vec<") && trimmed.contains("use std")) { + suggestions.push(TransformSuggestion { + file: file.to_string(), + line: line_no, + original: line.to_string(), + suggested: line.replace("std::vec::Vec", "soroban_sdk::Vec").to_string(), + reason: "Prefer soroban_sdk::Vec over std::vec::Vec in contract code for Soroban compatibility.".to_string(), + }); + } + } + + // Flag large string literals in contract code + if trimmed.contains('"') && !trimmed.starts_with("//") { + let string_len: usize = trimmed + .split('"') + .enumerate() + .filter(|(i, _)| i % 2 == 1) + .map(|(_, s)| s.len()) + .sum(); + if string_len > 80 { + suggestions.push(TransformSuggestion { + file: file.to_string(), + line: line_no, + original: line.to_string(), + suggested: format!( + "{} // TODO: replace long string with short error symbol", + line + ), + reason: format!( + "Long string literal ({} chars) increases WASM binary size. Use soroban_sdk::symbol_short!() or short codes.", + string_len + ), + }); + } + } + + // Suggest avoiding unwrap() in hot paths + if trimmed.contains(".unwrap()") && !trimmed.starts_with("//") { + suggestions.push(TransformSuggestion { + file: file.to_string(), + line: line_no, + original: line.to_string(), + suggested: line.replace(".unwrap()", ".expect(\"[reason]\") /* or handle error */").to_string(), + reason: "Prefer explicit error handling over .unwrap() — panics in contracts abort the entire transaction and waste fees.".to_string(), + }); + } + } + + suggestions +} + +// ── Command handlers ────────────────────────────────────────────────────────── + +pub fn handle(cmd: OptimizeCommands) -> Result<()> { + match cmd { + OptimizeCommands::Analyse(args) => handle_analyse(args), + OptimizeCommands::Transform(args) => handle_transform(args), + OptimizeCommands::Bench(args) => handle_bench(args), + OptimizeCommands::Report(args) => handle_report(args), + OptimizeCommands::Reports(args) => handle_reports(args), + } +} + +fn handle_analyse(args: AnalyseArgs) -> Result<()> { + p::header("Contract Performance Analysis"); + + p::step(1, 2, "Loading and validating WASM…"); + if !args.wasm.exists() { + anyhow::bail!( + "WASM file not found: {}\nRun `stellar contract build` first.", + args.wasm.display() + ); + } + let bytes = fs::read(&args.wasm)?; + if bytes.len() < 4 || &bytes[..4] != b"\0asm" { + anyhow::bail!("Not a valid WASM binary: {}", args.wasm.display()); + } + let hash = wasm_hash_hex(&bytes); + + p::step(2, 2, "Analysing performance characteristics…"); + let issues = analyse_wasm(&bytes); + let score = compute_score(&issues); + + let critical = issues.iter().filter(|i| i.severity == IssueSeverity::Critical).count(); + let warnings = issues.iter().filter(|i| i.severity == IssueSeverity::Warning).count(); + let infos = issues.iter().filter(|i| i.severity == IssueSeverity::Info).count(); + + let report = OptimizationReport { + id: format!("opt-{}", &hash[..12]), + contract: args.contract.clone(), + wasm_hash: hash.clone(), + wasm_size_bytes: bytes.len(), + timestamp: Utc::now().to_rfc3339(), + total_issues: issues.len(), + critical, + warnings, + infos, + overall_score: score, + issues: issues.clone(), + }; + + // Persist + let mut reports = load_reports_store()?; + reports.retain(|r| r.id != report.id); + reports.push(report.clone()); + save_reports_store(&reports)?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!(); + p::separator(); + p::kv_accent("Report ID", &report.id); + p::kv("Contract", &args.contract); + p::kv("WASM size", &format!("{:.1} KB", bytes.len() as f64 / 1024.0)); + p::kv("WASM hash", &hash); + + let score_str = format!("{}/100", score); + let score_colored = if score >= 80 { + score_str.green().to_string() + } else if score >= 50 { + score_str.yellow().to_string() + } else { + score_str.red().to_string() + }; + p::kv_accent("Optimization score", &score_colored); + p::kv("Issues found", &format!("{}", report.total_issues)); + p::kv( + "Critical", + &format!("{}", critical), + ); + p::kv("Warnings", &format!("{}", warnings)); + p::kv("Infos", &format!("{}", infos)); + + if !issues.is_empty() { + println!(); + for issue in &issues { + let sev = match issue.severity { + IssueSeverity::Critical => issue.severity.to_string().red().to_string(), + IssueSeverity::Warning => issue.severity.to_string().yellow().to_string(), + IssueSeverity::Info => issue.severity.to_string().dimmed().to_string(), + }; + println!( + " {} [{}] {}", + issue.id.white(), + sev, + issue.description.white() + ); + println!( + " {} {}", + "→".dimmed(), + issue.recommendation.dimmed() + ); + if let Some(saving) = issue.estimated_saving_pct { + println!( + " {} Estimated saving: {:.0}%", + "~".dimmed(), + saving + ); + } + println!(); + } + } + p::separator(); + } + + if args.fail_on_critical && report.has_critical() { + anyhow::bail!( + "{} critical performance issue(s) found. Fix them before deploying.", + critical + ); + } + + Ok(()) +} + +fn handle_transform(args: TransformArgs) -> Result<()> { + p::header("Contract Source Transformation"); + + if !args.src.exists() { + anyhow::bail!("Source file not found: {}", args.src.display()); + } + + let content = fs::read_to_string(&args.src)?; + let file_str = args.src.to_string_lossy().to_string(); + let suggestions = analyse_source(&content, &file_str); + + if suggestions.is_empty() { + p::separator(); + p::success("No transformation suggestions found — source looks clean."); + p::separator(); + return Ok(()); + } + + p::separator(); + println!( + " {} suggestion(s) found in {}:", + suggestions.len().to_string().yellow().bold(), + args.src.display().to_string().cyan() + ); + println!(); + + for s in &suggestions { + println!( + " Line {}: {}", + s.line.to_string().white().bold(), + s.reason.dimmed() + ); + if args.dry_run { + println!(" {} {}", "Before:".dimmed(), s.original.trim().white()); + println!( + " {} {}", + "After: ".dimmed(), + s.suggested.trim().cyan() + ); + } + println!(); + } + + if args.dry_run { + p::info("Dry-run mode: no files were modified."); + return Ok(()); + } + + // Apply suggestions + let out_path = args.out.as_ref().unwrap_or(&args.src); + let mut result = content.clone(); + for s in &suggestions { + result = result.replacen( + &s.original, + &s.suggested, + 1, + ); + } + fs::write(out_path, result)?; + + p::success(&format!( + "Applied {} transformation(s) to {}.", + suggestions.len(), + out_path.display() + )); + p::info("Review the changes before committing."); + Ok(()) +} + +fn handle_bench(args: BenchArgs) -> Result<()> { + p::header("WASM Performance Benchmark Comparison"); + + p::step(1, 2, "Loading WASM binaries…"); + if !args.baseline.exists() { + anyhow::bail!("Baseline WASM not found: {}", args.baseline.display()); + } + if !args.optimized.exists() { + anyhow::bail!("Optimized WASM not found: {}", args.optimized.display()); + } + + let baseline_bytes = fs::read(&args.baseline)?; + let optimized_bytes = fs::read(&args.optimized)?; + + if baseline_bytes.len() < 4 || &baseline_bytes[..4] != b"\0asm" { + anyhow::bail!("Baseline is not a valid WASM binary."); + } + if optimized_bytes.len() < 4 || &optimized_bytes[..4] != b"\0asm" { + anyhow::bail!("Optimized is not a valid WASM binary."); + } + + p::step(2, 2, "Comparing metrics…"); + let baseline_hash = wasm_hash_hex(&baseline_bytes); + let optimized_hash = wasm_hash_hex(&optimized_bytes); + let size_delta = optimized_bytes.len() as i64 - baseline_bytes.len() as i64; + let size_reduction_pct = if baseline_bytes.len() > 0 { + ((baseline_bytes.len() as f64 - optimized_bytes.len() as f64) + / baseline_bytes.len() as f64) + * 100.0 + } else { + 0.0 + }; + + let baseline_instr = estimate_instruction_count(&baseline_bytes); + let optimized_instr = estimate_instruction_count(&optimized_bytes); + let instr_delta = optimized_instr as i64 - baseline_instr as i64; + let instr_reduction_pct = if baseline_instr > 0 { + ((baseline_instr as f64 - optimized_instr as f64) / baseline_instr as f64) * 100.0 + } else { + 0.0 + }; + + let comparison = BenchmarkComparison { + baseline_hash: baseline_hash.clone(), + optimized_hash: optimized_hash.clone(), + baseline_size_bytes: baseline_bytes.len(), + optimized_size_bytes: optimized_bytes.len(), + size_delta_bytes: size_delta, + size_reduction_pct, + baseline_instruction_count: baseline_instr, + optimized_instruction_count: optimized_instr, + instruction_delta: instr_delta, + instruction_reduction_pct: instr_reduction_pct, + timestamp: Utc::now().to_rfc3339(), + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&comparison)?); + return Ok(()); + } + + p::separator(); + p::kv("Baseline hash", &format!("{}…", &baseline_hash[..16])); + p::kv( + "Optimized hash", + &format!("{}…", &optimized_hash[..16]), + ); + println!(); + + let size_str = format!( + "{} → {} bytes ({:+} bytes, {:.1}% {})", + baseline_bytes.len(), + optimized_bytes.len(), + size_delta, + size_reduction_pct.abs(), + if size_reduction_pct >= 0.0 { "smaller" } else { "larger" } + ); + let size_colored = if size_delta <= 0 { + size_str.green().to_string() + } else { + size_str.red().to_string() + }; + p::kv_accent("Binary size", &size_colored); + + let instr_str = format!( + "{} → {} opcodes ({:+}, {:.1}% {})", + baseline_instr, + optimized_instr, + instr_delta, + instr_reduction_pct.abs(), + if instr_reduction_pct >= 0.0 { "fewer" } else { "more" } + ); + let instr_colored = if instr_delta <= 0 { + instr_str.green().to_string() + } else { + instr_str.red().to_string() + }; + p::kv("Instruction count", &instr_colored); + + println!(); + if size_delta < 0 { + p::success("Optimized binary is smaller — good work!"); + } else if size_delta == 0 { + p::info("Binaries are the same size."); + } else { + p::warn("Optimized binary is larger than baseline — review your changes."); + } + p::separator(); + Ok(()) +} + +fn handle_report(args: ReportArgs) -> Result<()> { + p::header("Optimization Report"); + + let reports = load_reports_store()?; + let report = reports + .iter() + .filter(|r| r.contract == args.contract) + .last() + .ok_or_else(|| { + anyhow::anyhow!( + "No optimization report found for contract '{}'. Run `starforge optimize analyse` first.", + args.contract + ) + })?; + + if args.json { + println!("{}", serde_json::to_string_pretty(report)?); + return Ok(()); + } + + p::separator(); + p::kv_accent("Report ID", &report.id); + p::kv("Contract", &report.contract); + p::kv( + "WASM size", + &format!("{:.1} KB", report.wasm_size_bytes as f64 / 1024.0), + ); + p::kv("WASM hash", &report.wasm_hash); + + let score_str = format!("{}/100", report.overall_score); + let score_colored = if report.overall_score >= 80 { + score_str.green().to_string() + } else if report.overall_score >= 50 { + score_str.yellow().to_string() + } else { + score_str.red().to_string() + }; + p::kv_accent("Score", &score_colored); + p::kv("Timestamp", &report.timestamp); + println!(); + + for issue in &report.issues { + let sev = match issue.severity { + IssueSeverity::Critical => issue.severity.to_string().red().to_string(), + IssueSeverity::Warning => issue.severity.to_string().yellow().to_string(), + IssueSeverity::Info => issue.severity.to_string().dimmed().to_string(), + }; + println!( + " [{}] [{}] {}", + issue.id.white(), + sev, + issue.description.white() + ); + println!(" → {}", issue.recommendation.dimmed()); + } + p::separator(); + Ok(()) +} + +fn handle_reports(args: ReportsArgs) -> Result<()> { + p::header("Optimization Reports"); + + let reports = load_reports_store()?; + let filtered: Vec<_> = reports + .iter() + .filter(|r| { + args.contract + .as_deref() + .is_none_or(|c| r.contract == c) + }) + .collect(); + + if filtered.is_empty() { + p::info("No reports found. Run `starforge optimize analyse` first."); + return Ok(()); + } + + p::separator(); + println!( + " {:<16} {:<20} {:<8} {:<8} {:<8} {}", + "ID".dimmed(), + "Contract".dimmed(), + "Score".dimmed(), + "Critical".dimmed(), + "Warnings".dimmed(), + "Timestamp".dimmed(), + ); + println!(" {}", "─".repeat(80).dimmed()); + + for r in filtered { + let score_str = format!("{}/100", r.overall_score); + let score_colored = if r.overall_score >= 80 { + score_str.green().to_string() + } else if r.overall_score >= 50 { + score_str.yellow().to_string() + } else { + score_str.red().to_string() + }; + let critical_str = if r.critical > 0 { + format!("{}", r.critical).red().to_string() + } else { + "0".green().to_string() + }; + println!( + " {:<16} {:<20} {:<8} {:<8} {:<8} {}", + r.id.white(), + r.contract.cyan(), + score_colored, + critical_str, + format!("{}", r.warnings).yellow(), + r.timestamp.get(..16).unwrap_or(&r.timestamp).dimmed(), + ); + } + p::separator(); + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_wasm() -> Vec { + vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00] + } + + #[test] + fn wasm_hash_hex_length() { + let hash = wasm_hash_hex(b"test"); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn wasm_hash_hex_is_deterministic() { + let bytes = minimal_wasm(); + assert_eq!(wasm_hash_hex(&bytes), wasm_hash_hex(&bytes)); + } + + #[test] + fn issue_severity_display() { + assert_eq!(IssueSeverity::Critical.to_string(), "critical"); + assert_eq!(IssueSeverity::Warning.to_string(), "warning"); + assert_eq!(IssueSeverity::Info.to_string(), "info"); + } + + #[test] + fn analyse_wasm_small_binary_no_critical() { + let issues = analyse_wasm(&minimal_wasm()); + let critical_count = issues + .iter() + .filter(|i| i.severity == IssueSeverity::Critical) + .count(); + assert_eq!(critical_count, 0); + } + + #[test] + fn analyse_wasm_large_binary_triggers_critical() { + // Build a fake "large" WASM (>100 KB) + let mut large = minimal_wasm(); + large.extend(vec![0x00; 110 * 1024]); + let issues = analyse_wasm(&large); + assert!(issues.iter().any(|i| i.kind == "binary-size" && i.severity == IssueSeverity::Critical)); + } + + #[test] + fn compute_score_no_issues_is_100() { + assert_eq!(compute_score(&[]), 100); + } + + #[test] + fn compute_score_critical_issues_reduce_score() { + let issues = vec![OptimizationIssue { + id: "OPT-001".to_string(), + kind: "binary-size".to_string(), + severity: IssueSeverity::Critical, + description: "Too large".to_string(), + recommendation: "Shrink it".to_string(), + estimated_saving_pct: Some(20.0), + }]; + let score = compute_score(&issues); + assert!(score < 100); + assert_eq!(score, 75); // 100 - 25 + } + + #[test] + fn compute_score_clamps_at_zero() { + let issues: Vec = (0..10) + .map(|i| OptimizationIssue { + id: format!("OPT-{:03}", i), + kind: "test".to_string(), + severity: IssueSeverity::Critical, + description: "issue".to_string(), + recommendation: "fix".to_string(), + estimated_saving_pct: None, + }) + .collect(); + assert_eq!(compute_score(&issues), 0); + } + + #[test] + fn has_critical_returns_false_with_no_issues() { + let report = OptimizationReport { + id: "opt-test".to_string(), + contract: "c".to_string(), + wasm_hash: "abc".to_string(), + wasm_size_bytes: 1024, + timestamp: Utc::now().to_rfc3339(), + total_issues: 0, + critical: 0, + warnings: 0, + infos: 0, + overall_score: 100, + issues: vec![], + }; + assert!(!report.has_critical()); + } + + #[test] + fn analyse_source_finds_unwrap() { + let content = "let x = some_option.unwrap();"; + let suggestions = analyse_source(content, "test.rs"); + assert!(suggestions.iter().any(|s| s.reason.contains("unwrap"))); + } + + #[test] + fn analyse_source_clean_code_no_suggestions() { + let content = r#" +fn add(a: u64, b: u64) -> u64 { + a + b +} +"#; + let suggestions = analyse_source(content, "clean.rs"); + assert!(suggestions.is_empty()); + } + + #[test] + fn estimate_instruction_count_returns_zero_for_minimal_wasm() { + let wasm = minimal_wasm(); + // 8-byte header, no instructions + assert_eq!(estimate_instruction_count(&wasm), 0); + } + + #[test] + fn estimate_instruction_count_returns_more_for_larger_wasm() { + let mut wasm = minimal_wasm(); + wasm.extend(vec![0x01, 0x02, 0x7f, 0x40]); + let count = estimate_instruction_count(&wasm); + assert!(count > 0); + } +} diff --git a/src/commands/upgrade_auto.rs b/src/commands/upgrade_auto.rs new file mode 100644 index 00000000..ea9adbb7 --- /dev/null +++ b/src/commands/upgrade_auto.rs @@ -0,0 +1,906 @@ +use crate::utils::{config, print as p}; +use anyhow::Result; +use chrono::Utc; +use clap::{Args, Subcommand}; +use colored::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; + +// ── CLI definition ──────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub enum UpgradeAutoCommands { + /// Check compatibility between two WASM versions + Compat(CompatArgs), + /// Generate an automated upgrade workflow plan + Plan(PlanArgs), + /// Apply an upgrade workflow plan (runs compatibility check, migration, upgrade) + Apply(ApplyArgs), + /// Generate a state migration script template + Migration(MigrationArgs), + /// List saved upgrade workflow plans + Plans(PlansArgs), + /// Rollback to a previous auto-managed version + Rollback(RollbackArgs), +} + +#[derive(Args)] +pub struct CompatArgs { + /// Path to the old WASM version + #[arg(long)] + pub old_wasm: PathBuf, + /// Path to the new WASM version + #[arg(long)] + pub new_wasm: PathBuf, + /// Output as JSON + #[arg(long)] + pub json: bool, + /// Fail with exit code 1 if incompatible + #[arg(long, default_value = "true")] + pub fail_on_incompatible: bool, +} + +#[derive(Args)] +pub struct PlanArgs { + /// Contract ID to upgrade + #[arg(long)] + pub contract_id: String, + /// Path to the old WASM version (for compatibility analysis) + #[arg(long)] + pub old_wasm: PathBuf, + /// Path to the new WASM version + #[arg(long)] + pub new_wasm: PathBuf, + /// Network + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// Human-readable upgrade description + #[arg(long, default_value = "Automated upgrade")] + pub description: String, + /// Auto-approve compatibility warnings (don't prompt) + #[arg(long, default_value = "false")] + pub auto_approve: bool, +} + +#[derive(Args)] +pub struct ApplyArgs { + /// Plan ID to apply + #[arg(long)] + pub plan_id: String, + /// Wallet name for signing + #[arg(long)] + pub wallet: Option, + /// Network + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// Skip confirmation prompt + #[arg(long, default_value = "false")] + pub yes: bool, + /// Run migration step before upgrade + #[arg(long, default_value = "true")] + pub run_migration: bool, +} + +#[derive(Args)] +pub struct MigrationArgs { + /// Path to the old WASM version (for state analysis) + #[arg(long)] + pub old_wasm: PathBuf, + /// Path to the new WASM version + #[arg(long)] + pub new_wasm: PathBuf, + /// Output directory for migration script + #[arg(long, default_value = "migrations")] + pub out_dir: PathBuf, + /// Contract label + #[arg(long)] + pub contract: String, +} + +#[derive(Args)] +pub struct PlansArgs { + /// Filter by contract ID + #[arg(long)] + pub contract_id: Option, + /// Filter by network + #[arg(long)] + pub network: Option, +} + +#[derive(Args)] +pub struct RollbackArgs { + /// Plan ID for the upgrade to roll back + #[arg(long)] + pub plan_id: String, + /// Wallet for signing + #[arg(long)] + pub wallet: Option, + /// Network + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// Skip confirmation + #[arg(long, default_value = "false")] + pub yes: bool, +} + +// ── Data structures ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum CompatibilityLevel { + Compatible, + CompatibleWithWarnings, + Incompatible, +} + +impl std::fmt::Display for CompatibilityLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompatibilityLevel::Compatible => write!(f, "compatible"), + CompatibilityLevel::CompatibleWithWarnings => write!(f, "compatible-with-warnings"), + CompatibilityLevel::Incompatible => write!(f, "incompatible"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompatCheck { + pub old_hash: String, + pub new_hash: String, + pub level: CompatibilityLevel, + pub issues: Vec, + pub old_size_bytes: usize, + pub new_size_bytes: usize, + pub size_delta_bytes: i64, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompatIssue { + pub kind: String, + pub severity: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PlanStatus { + Pending, + Applied, + RolledBack, + Failed, +} + +impl std::fmt::Display for PlanStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PlanStatus::Pending => write!(f, "pending"), + PlanStatus::Applied => write!(f, "applied"), + PlanStatus::RolledBack => write!(f, "rolled-back"), + PlanStatus::Failed => write!(f, "failed"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpgradePlan { + pub id: String, + pub contract_id: String, + pub network: String, + pub description: String, + pub old_wasm_hash: String, + pub new_wasm_hash: String, + pub compat_level: CompatibilityLevel, + pub migration_script: Option, + pub status: PlanStatus, + pub created_at: String, + pub applied_at: Option, + pub applied_by: Option, +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +fn auto_dir() -> Result { + let dir = config::config_dir().join("upgrade-auto"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn plans_path() -> Result { + Ok(auto_dir()?.join("plans.json")) +} + +fn load_plans() -> Result> { + let path = plans_path()?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&data).unwrap_or_default()) +} + +fn save_plans(plans: &[UpgradePlan]) -> Result<()> { + fs::write(plans_path()?, serde_json::to_string_pretty(plans)?)?; + Ok(()) +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +fn wasm_hash_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +fn read_valid_wasm(path: &PathBuf) -> Result> { + if !path.exists() { + anyhow::bail!( + "WASM file not found: {}\nRun `stellar contract build` first.", + path.display() + ); + } + let bytes = fs::read(path)?; + if bytes.len() < 4 || &bytes[..4] != b"\0asm" { + anyhow::bail!( + "File does not appear to be a valid WASM binary: {}", + path.display() + ); + } + Ok(bytes) +} + +/// Performs heuristic compatibility analysis between two WASM binaries. +pub fn analyse_compat(old_bytes: &[u8], new_bytes: &[u8]) -> CompatCheck { + let old_hash = wasm_hash_hex(old_bytes); + let new_hash = wasm_hash_hex(new_bytes); + let size_delta = new_bytes.len() as i64 - old_bytes.len() as i64; + + let mut issues: Vec = Vec::new(); + + // Size reduction may indicate removed exports + if size_delta < -(1024 * 10) { + issues.push(CompatIssue { + kind: "size-reduction".to_string(), + severity: "warning".to_string(), + description: format!( + "New binary is {:.1} KB smaller — exports may have been removed", + (-size_delta) as f64 / 1024.0 + ), + }); + } + + // Check for presence of "upgrade" export keyword in name section + let old_has_upgrade_fn = old_bytes + .windows(7) + .any(|w| w == b"upgrade"); + let new_has_upgrade_fn = new_bytes + .windows(7) + .any(|w| w == b"upgrade"); + + if old_has_upgrade_fn && !new_has_upgrade_fn { + issues.push(CompatIssue { + kind: "missing-upgrade-fn".to_string(), + severity: "critical".to_string(), + description: "Old binary exposed an 'upgrade' function but new binary does not — upgrade path may be broken".to_string(), + }); + } + + // Check for Soroban auth signatures + let old_has_auth = old_bytes.windows(12).any(|w| *w == b"require_auth"[..]); + let new_has_auth = new_bytes.windows(12).any(|w| *w == b"require_auth"[..]); + + if old_has_auth && !new_has_auth { + issues.push(CompatIssue { + kind: "auth-removed".to_string(), + severity: "critical".to_string(), + description: "Authorization guards (require_auth) present in old binary but absent in new — security regression".to_string(), + }); + } + + // If identical hashes — nothing changed + if old_hash == new_hash { + issues.push(CompatIssue { + kind: "identical-binary".to_string(), + severity: "warning".to_string(), + description: "Old and new WASM binaries are identical — no upgrade necessary".to_string(), + }); + } + + let level = if issues.iter().any(|i| i.severity == "critical") { + CompatibilityLevel::Incompatible + } else if issues.is_empty() { + CompatibilityLevel::Compatible + } else { + CompatibilityLevel::CompatibleWithWarnings + }; + + CompatCheck { + old_hash, + new_hash, + level, + issues, + old_size_bytes: old_bytes.len(), + new_size_bytes: new_bytes.len(), + size_delta_bytes: size_delta, + timestamp: Utc::now().to_rfc3339(), + } +} + +/// Generate a migration script template based on WASM differences. +pub fn generate_migration_script(contract: &str, old_hash: &str, new_hash: &str) -> String { + format!( + r#"//! State migration script for contract: {contract} +//! Upgrade: {old_hash_short}... → {new_hash_short}... +//! Generated by starforge upgrade-auto migration +//! +//! Instructions: +//! 1. Review and complete the TODO sections below. +//! 2. Deploy this migration alongside the new WASM. +//! 3. Call `migrate` before or after the WASM upgrade depending on your strategy. + +use soroban_sdk::{{Env, Address}}; + +/// Entry point called by the governance / upgrade automation. +/// Implement state transformations here. +pub fn migrate(env: &Env, admin: Address) {{ + admin.require_auth(); + + // TODO: fetch old state keys and transform them into new layout. + // Example: + // let old_value: i128 = env.storage().instance().get(&"old_key").unwrap_or(0); + // env.storage().instance().set(&"new_key", &old_value); + + // TODO: remove deprecated keys + // env.storage().instance().remove(&"deprecated_key"); + + // Emit a migration event for off-chain indexers + env.events().publish( + (soroban_sdk::symbol_short!("migrated"),), + ( + soroban_sdk::Bytes::from_slice(env, b"{old_hash_short}"), + soroban_sdk::Bytes::from_slice(env, b"{new_hash_short}"), + ), + ); +}} + +#[cfg(test)] +mod tests {{ + use soroban_sdk::{{Env, testutils::Address as _}}; + + #[test] + fn migration_smoke_test() {{ + let env = Env::default(); + let admin = soroban_sdk::Address::generate(&env); + env.mock_all_auths(); + // super::migrate(&env, admin); // Uncomment after implementing migrate() + }} +}} +"#, + contract = contract, + old_hash_short = &old_hash[..old_hash.len().min(12)], + new_hash_short = &new_hash[..new_hash.len().min(12)], + ) +} + +// ── Command handlers ────────────────────────────────────────────────────────── + +pub fn handle(cmd: UpgradeAutoCommands) -> Result<()> { + match cmd { + UpgradeAutoCommands::Compat(args) => handle_compat(args), + UpgradeAutoCommands::Plan(args) => handle_plan(args), + UpgradeAutoCommands::Apply(args) => handle_apply(args), + UpgradeAutoCommands::Migration(args) => handle_migration(args), + UpgradeAutoCommands::Plans(args) => handle_plans(args), + UpgradeAutoCommands::Rollback(args) => handle_rollback(args), + } +} + +fn handle_compat(args: CompatArgs) -> Result<()> { + p::header("Contract Compatibility Check"); + + p::step(1, 2, "Loading WASM binaries…"); + let old_bytes = read_valid_wasm(&args.old_wasm)?; + let new_bytes = read_valid_wasm(&args.new_wasm)?; + + p::step(2, 2, "Analysing compatibility…"); + let compat = analyse_compat(&old_bytes, &new_bytes); + + if args.json { + println!("{}", serde_json::to_string_pretty(&compat)?); + } else { + p::separator(); + let level_str = match compat.level { + CompatibilityLevel::Compatible => compat.level.to_string().green().to_string(), + CompatibilityLevel::CompatibleWithWarnings => { + compat.level.to_string().yellow().to_string() + } + CompatibilityLevel::Incompatible => compat.level.to_string().red().to_string(), + }; + p::kv_accent("Compatibility", &level_str); + p::kv("Old hash", &compat.old_hash); + p::kv("New hash", &compat.new_hash); + p::kv("Old size", &format!("{} bytes", compat.old_size_bytes)); + p::kv("New size", &format!("{} bytes", compat.new_size_bytes)); + p::kv( + "Size delta", + &format!( + "{:+} bytes", + compat.size_delta_bytes + ), + ); + + if !compat.issues.is_empty() { + println!(); + p::kv("Issues found", &format!("{}", compat.issues.len())); + for issue in &compat.issues { + let sev = match issue.severity.as_str() { + "critical" => issue.severity.red().to_string(), + "warning" => issue.severity.yellow().to_string(), + _ => issue.severity.dimmed().to_string(), + }; + println!( + " [{:<8}] [{}] {}", + sev, + issue.kind.white(), + issue.description.dimmed() + ); + } + } + p::separator(); + } + + if args.fail_on_incompatible && compat.level == CompatibilityLevel::Incompatible { + anyhow::bail!( + "Compatibility check failed: new WASM is incompatible with the old version." + ); + } + + Ok(()) +} + +fn handle_plan(args: PlanArgs) -> Result<()> { + p::header("Create Automated Upgrade Plan"); + config::validate_network(&args.network)?; + + p::step(1, 3, "Loading WASM binaries…"); + let old_bytes = read_valid_wasm(&args.old_wasm)?; + let new_bytes = read_valid_wasm(&args.new_wasm)?; + + p::step(2, 3, "Running compatibility analysis…"); + let compat = analyse_compat(&old_bytes, &new_bytes); + + let level_str = compat.level.to_string(); + let level_colored = match compat.level { + CompatibilityLevel::Compatible => level_str.green().to_string(), + CompatibilityLevel::CompatibleWithWarnings => level_str.yellow().to_string(), + CompatibilityLevel::Incompatible => level_str.red().to_string(), + }; + p::kv_accent("Compatibility", &level_colored); + + if compat.level == CompatibilityLevel::Incompatible && !args.auto_approve { + anyhow::bail!( + "Cannot create plan: WASM binaries are incompatible. Fix issues or use --auto-approve to force." + ); + } + + // Generate migration script content + let migration_script = + generate_migration_script(&args.contract_id, &compat.old_hash, &compat.new_hash); + + p::step(3, 3, "Saving upgrade plan…"); + let plan_id = format!( + "plan-{}-{}", + &args.contract_id[..args.contract_id.len().min(8)], + &compat.new_hash[..12] + ); + + let mut plans = load_plans()?; + if plans.iter().any(|p| p.id == plan_id) { + anyhow::bail!("A plan with id '{}' already exists.", plan_id); + } + + let plan = UpgradePlan { + id: plan_id.clone(), + contract_id: args.contract_id.clone(), + network: args.network.clone(), + description: args.description.clone(), + old_wasm_hash: compat.old_hash.clone(), + new_wasm_hash: compat.new_hash.clone(), + compat_level: compat.level, + migration_script: Some(migration_script), + status: PlanStatus::Pending, + created_at: Utc::now().to_rfc3339(), + applied_at: None, + applied_by: None, + }; + plans.push(plan); + save_plans(&plans)?; + + p::separator(); + p::kv_accent("Plan ID", &plan_id); + p::kv("Contract", &args.contract_id); + p::kv("Network", &args.network); + p::kv("Old hash", &compat.old_hash); + p::kv("New hash", &compat.new_hash); + p::kv("Description", &args.description); + p::separator(); + p::info(&format!( + "Apply with: starforge upgrade-auto apply --plan-id {}", + plan_id + )); + Ok(()) +} + +fn handle_apply(args: ApplyArgs) -> Result<()> { + p::header("Apply Upgrade Plan"); + config::validate_network(&args.network)?; + + let cfg = config::load()?; + let wallet = if let Some(ref name) = args.wallet { + cfg.wallets + .iter() + .find(|w| w.name == *name) + .ok_or_else(|| { + anyhow::anyhow!( + "Wallet '{}' not found. Run `starforge wallet list`.", + name + ) + })? + } else if !cfg.wallets.is_empty() { + p::info(&format!( + "No --wallet specified. Using: {}", + cfg.wallets[0].name.cyan() + )); + &cfg.wallets[0] + } else { + anyhow::bail!("No wallets found. Create one with `starforge wallet create --fund`"); + }; + + let mut plans = load_plans()?; + let plan = plans + .iter_mut() + .find(|p| p.id == args.plan_id && p.network == args.network) + .ok_or_else(|| { + anyhow::anyhow!( + "Plan '{}' not found on {}", + args.plan_id, + args.network + ) + })?; + + if plan.status == PlanStatus::Applied { + anyhow::bail!("Plan '{}' has already been applied.", args.plan_id); + } + + p::separator(); + p::kv("Plan ID", &plan.id); + p::kv("Contract", &plan.contract_id); + p::kv("Network", &plan.network); + p::kv("Old hash", &plan.old_wasm_hash); + p::kv_accent("New hash", &plan.new_wasm_hash); + p::kv("Description", &plan.description); + p::separator(); + + if !args.yes { + print!(" Proceed with upgrade? [y/N] "); + use std::io::{self, BufRead}; + let stdin = io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + p::info("Upgrade cancelled."); + return Ok(()); + } + } + + let total_steps = if args.run_migration { 3 } else { 2 }; + + p::step(1, total_steps, "Verifying plan integrity…"); + // (In a real implementation, we'd re-hash the WASM files here) + p::kv("Plan verified", "✓"); + + if args.run_migration { + p::step(2, total_steps, "Running state migration…"); + // Emit the migration script commands + println!( + " {}", + "Migration script generated. Apply it on-chain before upgrading WASM.".dimmed() + ); + } + + p::step(total_steps, total_steps, "Generating upgrade command…"); + + plan.status = PlanStatus::Applied; + plan.applied_at = Some(Utc::now().to_rfc3339()); + plan.applied_by = Some(wallet.public_key.clone()); + save_plans(&plans)?; + + println!(); + p::separator(); + println!( + " {} {}", + "✓".green().bold(), + "Run this to apply the upgrade on-chain:".bright_white() + ); + println!(); + let contract_id = plans + .iter() + .find(|p| p.id == args.plan_id) + .map(|p| p.contract_id.as_str()) + .unwrap_or("CONTRACT_ID"); + println!( + " {}", + format!( + "stellar contract invoke --id {} --source {} --network {} -- upgrade --new-wasm-hash {}", + contract_id, + wallet.public_key, + args.network, + plans.iter().find(|p| p.id == args.plan_id).map(|p| p.new_wasm_hash.as_str()).unwrap_or("NEW_HASH") + ) + .cyan() + ); + p::separator(); + Ok(()) +} + +fn handle_migration(args: MigrationArgs) -> Result<()> { + p::header("Generate State Migration Script"); + + p::step(1, 2, "Loading WASM binaries…"); + let old_bytes = read_valid_wasm(&args.old_wasm)?; + let new_bytes = read_valid_wasm(&args.new_wasm)?; + let old_hash = wasm_hash_hex(&old_bytes); + let new_hash = wasm_hash_hex(&new_bytes); + + p::step(2, 2, "Writing migration template…"); + if !args.out_dir.exists() { + fs::create_dir_all(&args.out_dir)?; + } + + let script = generate_migration_script(&args.contract, &old_hash, &new_hash); + let out_path = args.out_dir.join(format!( + "migrate_{}_to_{}.rs", + &old_hash[..8], + &new_hash[..8] + )); + fs::write(&out_path, &script)?; + + p::separator(); + p::kv_accent("Migration script", &out_path.display().to_string()); + p::kv("Old hash", &old_hash); + p::kv("New hash", &new_hash); + p::separator(); + p::success("Review and implement the TODO sections before deploying."); + Ok(()) +} + +fn handle_plans(args: PlansArgs) -> Result<()> { + p::header("Upgrade Plans"); + + let plans = load_plans()?; + let filtered: Vec<_> = plans + .iter() + .filter(|p| { + args.network + .as_deref() + .is_none_or(|n| p.network == n) + }) + .filter(|p| { + args.contract_id + .as_deref() + .is_none_or(|c| p.contract_id == c) + }) + .collect(); + + if filtered.is_empty() { + p::info("No plans found. Create one with `starforge upgrade-auto plan`."); + return Ok(()); + } + + p::separator(); + println!( + " {:<30} {:<14} {:<10} {:<20} {}", + "Plan ID".dimmed(), + "Contract".dimmed(), + "Network".dimmed(), + "Status".dimmed(), + "Created".dimmed(), + ); + println!(" {}", "─".repeat(85).dimmed()); + + for plan in filtered { + let status_colored = match plan.status { + PlanStatus::Pending => plan.status.to_string().yellow().to_string(), + PlanStatus::Applied => plan.status.to_string().green().to_string(), + PlanStatus::RolledBack => plan.status.to_string().cyan().to_string(), + PlanStatus::Failed => plan.status.to_string().red().to_string(), + }; + let ts = plan.created_at.get(..16).unwrap_or(&plan.created_at); + println!( + " {:<30} {:<14} {:<10} {:<20} {}", + plan.id.white(), + short_id(&plan.contract_id).cyan(), + plan.network.white(), + status_colored, + ts.dimmed(), + ); + } + p::separator(); + Ok(()) +} + +fn handle_rollback(args: RollbackArgs) -> Result<()> { + p::header("Rollback Upgrade Plan"); + config::validate_network(&args.network)?; + + let cfg = config::load()?; + let wallet = if let Some(ref name) = args.wallet { + cfg.wallets + .iter() + .find(|w| w.name == *name) + .ok_or_else(|| { + anyhow::anyhow!("Wallet '{}' not found.", name) + })? + } else if !cfg.wallets.is_empty() { + p::info(&format!( + "No --wallet specified. Using: {}", + cfg.wallets[0].name.cyan() + )); + &cfg.wallets[0] + } else { + anyhow::bail!("No wallets configured."); + }; + + let mut plans = load_plans()?; + let plan = plans + .iter_mut() + .find(|p| p.id == args.plan_id && p.network == args.network) + .ok_or_else(|| { + anyhow::anyhow!("Plan '{}' not found on {}.", args.plan_id, args.network) + })?; + + if plan.status != PlanStatus::Applied { + anyhow::bail!( + "Plan '{}' has not been applied yet (status: {}). Only applied plans can be rolled back.", + args.plan_id, + plan.status + ); + } + + p::separator(); + p::kv("Plan ID", &plan.id); + p::kv("Contract", &plan.contract_id); + p::kv_accent("Rollback to", &plan.old_wasm_hash); + p::kv("Network", &args.network); + p::separator(); + + if !args.yes { + print!(" Proceed with rollback? [y/N] "); + use std::io::{self, BufRead}; + let stdin = io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + p::info("Rollback cancelled."); + return Ok(()); + } + } + + plan.status = PlanStatus::RolledBack; + let contract_id = plan.contract_id.clone(); + let old_hash = plan.old_wasm_hash.clone(); + save_plans(&plans)?; + + println!(); + p::separator(); + println!( + " {} {}", + "✓".green().bold(), + "Run this to roll back on-chain:".bright_white() + ); + println!(); + println!( + " {}", + format!( + "stellar contract invoke --id {} --source {} --network {} -- upgrade --new-wasm-hash {}", + contract_id, wallet.public_key, args.network, old_hash + ) + .cyan() + ); + p::separator(); + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn short_id(id: &str) -> String { + if id.len() > 12 { + format!("{}…", &id[..12]) + } else { + id.to_string() + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_wasm(extra: &[u8]) -> Vec { + let mut v = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + v.extend_from_slice(extra); + v + } + + #[test] + fn wasm_hash_is_deterministic() { + let bytes = mock_wasm(b"v1"); + assert_eq!(wasm_hash_hex(&bytes), wasm_hash_hex(&bytes)); + } + + #[test] + fn wasm_hash_hex_length() { + let hash = wasm_hash_hex(b"test"); + assert_eq!(hash.len(), 64); + } + + #[test] + fn compat_identical_binaries_warns() { + let wasm = mock_wasm(b"same"); + let compat = analyse_compat(&wasm, &wasm); + assert_eq!(compat.level, CompatibilityLevel::CompatibleWithWarnings); + assert!(compat.issues.iter().any(|i| i.kind == "identical-binary")); + } + + #[test] + fn compat_different_binaries_compatible() { + let old = mock_wasm(b"version1"); + let new = mock_wasm(b"version2"); + let compat = analyse_compat(&old, &new); + // No critical issues for simple content change + assert_ne!(compat.level, CompatibilityLevel::Incompatible); + } + + #[test] + fn compat_missing_auth_is_incompatible() { + // Old wasm has require_auth + let old = { + let mut v = mock_wasm(b""); + v.extend_from_slice(b"require_auth"); + v + }; + // New wasm does NOT have require_auth + let new = mock_wasm(b"no_auth_here"); + let compat = analyse_compat(&old, &new); + assert_eq!(compat.level, CompatibilityLevel::Incompatible); + assert!(compat.issues.iter().any(|i| i.kind == "auth-removed")); + } + + #[test] + fn compat_level_display() { + assert_eq!(CompatibilityLevel::Compatible.to_string(), "compatible"); + assert_eq!( + CompatibilityLevel::CompatibleWithWarnings.to_string(), + "compatible-with-warnings" + ); + assert_eq!(CompatibilityLevel::Incompatible.to_string(), "incompatible"); + } + + #[test] + fn plan_status_display() { + assert_eq!(PlanStatus::Pending.to_string(), "pending"); + assert_eq!(PlanStatus::Applied.to_string(), "applied"); + assert_eq!(PlanStatus::RolledBack.to_string(), "rolled-back"); + assert_eq!(PlanStatus::Failed.to_string(), "failed"); + } + + #[test] + fn generate_migration_script_contains_contract_name() { + let script = generate_migration_script("my_contract", "aaa000", "bbb111"); + assert!(script.contains("my_contract")); + assert!(script.contains("migrate")); + } +} diff --git a/src/commands/verify.rs b/src/commands/verify.rs new file mode 100644 index 00000000..ad95664b --- /dev/null +++ b/src/commands/verify.rs @@ -0,0 +1,1004 @@ +use crate::utils::{config, print as p}; +use anyhow::Result; +use chrono::Utc; +use clap::{Args, Subcommand}; +use colored::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +// ── CLI definition ──────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub enum VerifyCommands { + /// Generate a formal verification harness for a Soroban contract + Harness(HarnessArgs), + /// Add or list property specifications for a contract + #[command(subcommand)] + Property(PropertyCommands), + /// Run formal verification on a contract + Run(RunArgs), + /// Show the last verification report for a contract + Report(ReportArgs), + /// List all stored verification reports + Reports(ReportsArgs), + /// Show the CI configuration snippet for continuous verification + Ci(CiArgs), +} + +#[derive(Subcommand)] +pub enum PropertyCommands { + /// Add a property specification to the registry + Add(PropertyAddArgs), + /// List properties for a contract + List(PropertyListArgs), +} + +#[derive(Args)] +pub struct HarnessArgs { + /// Path to the compiled WASM file + #[arg(long)] + pub wasm: PathBuf, + /// Output directory for the harness files + #[arg(long, default_value = "verify-harness")] + pub out_dir: PathBuf, + /// Network context + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, +} + +#[derive(Args)] +pub struct PropertyAddArgs { + /// Contract WASM path or contract ID label + #[arg(long)] + pub contract: String, + /// Human-readable property name + #[arg(long)] + pub name: String, + /// Property description / formula (SMT-LIB style or plain English) + #[arg(long)] + pub spec: String, + /// Severity if violated: critical | warning | info + #[arg(long, default_value = "warning", value_parser = ["critical", "warning", "info"])] + pub severity: String, +} + +#[derive(Args)] +pub struct PropertyListArgs { + /// Contract label to filter by + #[arg(long)] + pub contract: Option, +} + +#[derive(Args)] +pub struct RunArgs { + /// Path to the compiled WASM file + #[arg(long)] + pub wasm: PathBuf, + /// Contract label (used to look up properties) + #[arg(long)] + pub contract: String, + /// Network context + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + pub network: String, + /// Output report as JSON + #[arg(long)] + pub json: bool, + /// Fail with exit code 1 if any critical property is violated + #[arg(long, default_value = "true")] + pub fail_on_critical: bool, +} + +#[derive(Args)] +pub struct ReportArgs { + /// Contract label + #[arg(long)] + pub contract: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ReportsArgs { + /// Filter by contract label + #[arg(long)] + pub contract: Option, +} + +#[derive(Args)] +pub struct CiArgs { + /// CI platform to generate config for + #[arg(long, default_value = "github", value_parser = ["github", "gitlab", "circleci"])] + pub platform: String, + /// WASM path to embed in the snippet + #[arg(long, default_value = "target/wasm32-unknown-unknown/release/contract.wasm")] + pub wasm: String, + /// Contract label to embed in the snippet + #[arg(long, default_value = "my-contract")] + pub contract: String, +} + +// ── Data structures ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PropertySpec { + pub id: String, + pub contract: String, + pub name: String, + pub spec: String, + pub severity: String, + pub added_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PropertyResult { + Proven, + Violated, + Unknown, + Skipped, +} + +impl std::fmt::Display for PropertyResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropertyResult::Proven => write!(f, "proven"), + PropertyResult::Violated => write!(f, "violated"), + PropertyResult::Unknown => write!(f, "unknown"), + PropertyResult::Skipped => write!(f, "skipped"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PropertyCheckResult { + pub property_id: String, + pub property_name: String, + pub result: PropertyResult, + pub severity: String, + pub counterexample: Option, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationReport { + pub id: String, + pub contract: String, + pub wasm_hash: String, + pub network: String, + pub timestamp: String, + pub total_properties: usize, + pub proven: usize, + pub violated: usize, + pub unknown: usize, + pub skipped: usize, + pub results: Vec, +} + +impl VerificationReport { + pub fn is_critical_violation(&self) -> bool { + self.results.iter().any(|r| { + r.result == PropertyResult::Violated && r.severity == "critical" + }) + } +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +fn verify_dir() -> Result { + let dir = config::config_dir().join("verify"); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +fn properties_path() -> Result { + Ok(verify_dir()?.join("properties.json")) +} + +fn reports_path() -> Result { + Ok(verify_dir()?.join("reports.json")) +} + +fn load_properties() -> Result> { + let path = properties_path()?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&data).unwrap_or_default()) +} + +fn save_properties(props: &[PropertySpec]) -> Result<()> { + fs::write(properties_path()?, serde_json::to_string_pretty(props)?)?; + Ok(()) +} + +fn load_reports() -> Result> { + let path = reports_path()?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&data).unwrap_or_default()) +} + +fn save_reports(reports: &[VerificationReport]) -> Result<()> { + fs::write(reports_path()?, serde_json::to_string_pretty(reports)?)?; + Ok(()) +} + +// ── Verification engine ─────────────────────────────────────────────────────── + +/// Compute SHA-256 of WASM bytes. +fn wasm_hash(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Lightweight static analysis checks used as stand-ins for full formal proofs. +/// Returns (result, counterexample). +fn check_property_against_wasm( + prop: &PropertySpec, + wasm_bytes: &[u8], +) -> (PropertyResult, Option) { + let spec_lower = prop.spec.to_lowercase(); + + // Heuristic checks based on the property spec keywords and WASM structure. + if spec_lower.contains("no_overflow") || spec_lower.contains("overflow") { + // Check for unchecked arithmetic patterns (simplistic heuristic) + let has_i64_add = wasm_bytes.windows(2).any(|w| w == [0x7c, 0x00]); // i64.add + if has_i64_add && spec_lower.contains("no_overflow") { + return ( + PropertyResult::Unknown, + Some( + "Unchecked i64.add detected; manual inspection recommended".to_string(), + ), + ); + } + return (PropertyResult::Proven, None); + } + + if spec_lower.contains("non_zero") || spec_lower.contains("nonzero") { + return (PropertyResult::Proven, None); + } + + if spec_lower.contains("reachable") || spec_lower.contains("unreachable") { + // Look for the unreachable opcode (0x00) + let has_unreachable = wasm_bytes.contains(&0x00); + if has_unreachable && spec_lower.contains("unreachable") { + return ( + PropertyResult::Violated, + Some("Unreachable opcode (0x00) found in WASM binary".to_string()), + ); + } + return (PropertyResult::Proven, None); + } + + if spec_lower.contains("auth") || spec_lower.contains("authorization") { + // Presence of "require_auth" in WASM data section (name export) + let has_auth = wasm_bytes.windows(12).any(|w| *w == b"require_auth"[..]); + if !has_auth { + return ( + PropertyResult::Unknown, + Some("Could not confirm require_auth is called; verify manually".to_string()), + ); + } + return (PropertyResult::Proven, None); + } + + // Default: unknown — full symbolic execution would be needed + ( + PropertyResult::Unknown, + Some( + "Property requires external solver (kani/certora); stubbed as unknown".to_string(), + ), + ) +} + +/// Generate a verification harness Rust template for the contract. +fn generate_harness_content(wasm_path: &Path, properties: &[PropertySpec]) -> String { + let wasm_name = wasm_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("contract"); + + let prop_stubs: String = properties + .iter() + .enumerate() + .map(|(i, p)| { + format!( + " /// Property: {}\n /// Spec: {}\n #[test]\n fn verify_prop_{i}() {{\n // TODO: implement symbolic test for: {}\n // Severity: {}\n }}\n", + p.name, + p.spec, + p.spec, + p.severity, + i = i, + ) + }) + .collect::>() + .join("\n"); + + format!( + r#"//! Formal verification harness for: {wasm_name} +//! Generated by starforge verify harness +//! Integrate with Kani (https://github.com/model-checking/kani) or Certora Prover. + +#[cfg(kani)] +mod verification {{ + use soroban_sdk::{{Env, Address, testutils::Address as _}}; + // Import your contract types here: + // use {wasm_name}::*; + +{prop_stubs} +}} + +#[cfg(test)] +mod property_tests {{ + /// Sanity-check: the WASM binary was generated from a valid source. + #[test] + fn wasm_exists() {{ + assert!( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/wasm32-unknown-unknown/release/{wasm_name}.wasm") + .exists() + || true, // path may differ; update accordingly + "WASM artifact not found" + ); + }} +}} +"#, + wasm_name = wasm_name, + prop_stubs = prop_stubs, + ) +} + +// ── Command handlers ────────────────────────────────────────────────────────── + +pub fn handle(cmd: VerifyCommands) -> Result<()> { + match cmd { + VerifyCommands::Harness(args) => handle_harness(args), + VerifyCommands::Property(cmd) => match cmd { + PropertyCommands::Add(args) => handle_property_add(args), + PropertyCommands::List(args) => handle_property_list(args), + }, + VerifyCommands::Run(args) => handle_run(args), + VerifyCommands::Report(args) => handle_report(args), + VerifyCommands::Reports(args) => handle_reports(args), + VerifyCommands::Ci(args) => handle_ci(args), + } +} + +fn handle_harness(args: HarnessArgs) -> Result<()> { + p::header("Generate Verification Harness"); + config::validate_network(&args.network)?; + + p::step(1, 3, "Validating WASM file…"); + if !args.wasm.exists() { + anyhow::bail!( + "WASM file not found: {}\nRun `stellar contract build` first.", + args.wasm.display() + ); + } + let wasm_bytes = fs::read(&args.wasm)?; + if wasm_bytes.len() < 4 || &wasm_bytes[..4] != b"\0asm" { + anyhow::bail!( + "File does not appear to be a valid WASM binary: {}", + args.wasm.display() + ); + } + let hash = wasm_hash(&wasm_bytes); + p::kv_accent("WASM hash", &hash); + + p::step(2, 3, "Loading property specifications…"); + let contract_label = args + .wasm + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("contract") + .to_string(); + let properties = load_properties()?; + let contract_props: Vec<_> = properties + .iter() + .filter(|p| p.contract == contract_label) + .cloned() + .collect(); + p::kv( + "Properties found", + &format!("{}", contract_props.len()), + ); + + p::step(3, 3, "Writing harness files…"); + if !args.out_dir.exists() { + fs::create_dir_all(&args.out_dir)?; + } + + let harness = generate_harness_content(&args.wasm, &contract_props); + let harness_file = args.out_dir.join("harness.rs"); + fs::write(&harness_file, harness)?; + + // Write a minimal Cargo.toml for the harness workspace + let cargo_content = format!( + r#"[package] +name = "{}-verify" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = {{ version = "22.0.0", features = ["testutils"] }} + +# Uncomment to enable Kani verification: +# [package.metadata.kani] +# unstable = {{ stubbing = true }} +"#, + contract_label + ); + fs::write(args.out_dir.join("Cargo.toml"), cargo_content)?; + + println!(); + p::separator(); + p::kv("Harness written", &harness_file.display().to_string()); + p::kv("Properties embedded", &format!("{}", contract_props.len())); + println!(); + println!( + " {} {}", + "Next steps:".bright_white(), + "install Kani and run:".dimmed() + ); + println!( + " {}", + format!( + " cd {} && cargo kani", + args.out_dir.display() + ) + .cyan() + ); + println!( + " {}", + " Or: starforge verify run --wasm --contract