diff --git a/Cargo.lock b/Cargo.lock index 575e21e..425dcd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,9 +119,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -640,9 +640,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1074,7 +1074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1351,9 +1351,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1568,7 +1568,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.18", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -1819,7 +1819,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2086,7 +2086,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2658,7 +2658,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3259,7 +3259,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4049,7 +4049,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/deny.toml b/deny.toml index ef12360..acbf3ee 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,18 @@ ignore = [ "RUSTSEC-2026-0104", # number_prefix unmaintained; pulled in via indicatif. No safe upgrade. "RUSTSEC-2025-0119", + # instant unmaintained; pulled in via backoff (async-openai's retry logic). No safe upgrade. + "RUSTSEC-2024-0384", + # paste unmaintained; pulled in via wasmi_core. No safe upgrade. + "RUSTSEC-2024-0436", + # rustls-pemfile unmaintained; pulled in via reqwest/async-openai. No safe upgrade. + "RUSTSEC-2025-0134", + # h2 0.3.27 (hyper 0.14, via reqwest 0.11/async-openai) has no patched 0.3.x + # release; the fix only landed in 0.4.16+, which requires reqwest's hyper-1.x + # line. The separate h2 0.4.x instance in this tree is already updated. + "RUSTSEC-2026-0258", + # backoff unmaintained; pulled in via async-openai's retry logic. No safe upgrade. + "RUSTSEC-2025-0012", ] [licenses] diff --git a/src/commands/command_tree.rs b/src/commands/command_tree.rs index 167ea05..bc439a1 100644 --- a/src/commands/command_tree.rs +++ b/src/commands/command_tree.rs @@ -259,6 +259,20 @@ const COMMANDS: &[CmdEntry] = &[ ("report export", "Run a check and export the resulting report to a file"), ], }, + CmdEntry { + name: "cost", + about: "AI-assisted cost estimation and economic analysis for Soroban operations", + subs: &[ + ("estimate", "Estimate the fee/resource cost of a single operation"), + ("compare", "Compare two cost estimates"), + ("budget", "Check the latest estimate for a label against a fee budget"), + ("export", "Export historical estimates for a label as JSON or CSV"), + ( + "check-regression", + "Fail if the latest estimate regressed beyond a threshold", + ), + ], + }, CmdEntry { name: "commands", about: "Display the full command tree (this command)", diff --git a/src/commands/cost/adapter.rs b/src/commands/cost/adapter.rs new file mode 100644 index 0000000..4d9b2e4 --- /dev/null +++ b/src/commands/cost/adapter.rs @@ -0,0 +1,237 @@ +//! Simulation adapters: normalize Soroban RPC `simulateTransaction` responses +//! (and already-parsed [`crate::utils::soroban::SimulationResult`] values) +//! into the stable [`ResourceUsage`] structure the cost model consumes. +//! +//! Consuming raw RPC JSON directly (rather than only the fields +//! `crate::utils::soroban` already surfaces) lets the estimator use signals — +//! `cost.cpuInsns`, `cost.memBytes`, per-key size hints — that the existing +//! invoke/deploy flows don't need but cost estimation does. + +use crate::commands::cost::model::ResourceUsage; +use crate::utils::soroban::SimulationResult; +use anyhow::{Context, Result}; +use serde_json::Value; + +/// Parses a full Soroban RPC JSON-RPC envelope (`{"jsonrpc", "id", "result"|"error"}`) +/// — the same shape used by `tests/fixtures/soroban_rpc/*.json` — and normalizes +/// its `result` payload into [`ResourceUsage`]. +pub fn normalize_from_rpc_envelope(envelope: &Value) -> Result { + if let Some(error) = envelope.get("error") { + anyhow::bail!("Soroban RPC response contains an error: {}", error); + } + let result = envelope + .get("result") + .context("Soroban RPC envelope has neither a 'result' nor an 'error' field")?; + Ok(normalize_from_result_value(result)) +} + +/// Normalizes an already-unwrapped `result` value (i.e. the object a real RPC +/// client would receive after stripping the `jsonrpc`/`id`/`error` envelope). +pub fn normalize_from_result_value(result: &Value) -> ResourceUsage { + let cpu_insns = result + .pointer("/cost/cpuInsns") + .and_then(Value::as_u64) + .unwrap_or(0); + let mem_bytes = result + .pointer("/cost/memBytes") + .and_then(Value::as_u64) + .unwrap_or(0); + + let footprint = result + .pointer("/transactionData/resources/footprint") + .or_else(|| result.pointer("/transactionData/footprint")) + .or_else(|| result.pointer("/footprint")); + + let (read_entries, read_bytes) = footprint + .and_then(|f| f.get("readOnly").or_else(|| f.get("readOnlyKeys"))) + .map(count_and_size) + .unwrap_or((0, 0)); + + let (write_entries, write_bytes) = footprint + .and_then(|f| f.get("readWrite").or_else(|| f.get("readWriteKeys"))) + .map(count_and_size) + .unwrap_or((0, 0)); + + let events = result.get("events").and_then(Value::as_array); + let event_count = events.map(|e| e.len() as u32).unwrap_or(0); + let event_bytes = events + .map(|e| { + e.iter() + .map(|ev| ev.as_str().map(str::len).unwrap_or(0) as u64) + .sum() + }) + .unwrap_or(0); + + ResourceUsage { + cpu_insns, + mem_bytes, + read_entries, + write_entries, + read_bytes, + write_bytes, + event_count, + event_bytes, + } +} + +fn count_and_size(keys: &Value) -> (u32, u64) { + let arr = match keys.as_array() { + Some(a) => a, + None => return (0, 0), + }; + let count = arr.len() as u32; + let bytes = arr + .iter() + .map(|k| { + k.get("sizeHintBytes") + .or_else(|| k.get("size_hint_bytes")) + .and_then(Value::as_u64) + .unwrap_or(0) + }) + .sum(); + (count, bytes) +} + +/// Normalizes an already-constructed [`SimulationResult`] (as produced by +/// `crate::utils::soroban::simulate_transaction`/`simulate_deploy_transaction`) +/// into [`ResourceUsage`]. CPU/memory counters are not preserved on +/// `SimulationResult` itself, so this reads storage footprint and event +/// counts only; callers with access to the raw RPC JSON should prefer +/// [`normalize_from_rpc_envelope`] for the fuller picture. +pub fn normalize_from_simulation(sim: &SimulationResult) -> ResourceUsage { + let (read_entries, read_bytes, write_entries, write_bytes) = match &sim.footprint { + Some(fp) => { + let read_bytes: u64 = fp.read_only.iter().map(|k| k.size_hint_bytes as u64).sum(); + let write_bytes: u64 = fp.read_write.iter().map(|k| k.size_hint_bytes as u64).sum(); + ( + fp.read_only.len() as u32, + read_bytes, + fp.read_write.len() as u32, + write_bytes, + ) + } + None => (0, 0, 0, 0), + }; + + let event_count = sim.events.len() as u32; + let event_bytes: u64 = sim.events.iter().map(|e| e.len() as u64).sum(); + + ResourceUsage { + cpu_insns: 0, + mem_bytes: 0, + read_entries, + write_entries, + read_bytes, + write_bytes, + event_count, + event_bytes, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalizes_full_envelope_with_cost_and_footprint() { + let envelope = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "cost": { "cpuInsns": 150000, "memBytes": 2048 }, + "transactionData": { + "resources": { + "footprint": { + "readOnly": [{ "sizeHintBytes": 64 }], + "readWrite": [{ "sizeHintBytes": 128 }, { "sizeHintBytes": 32 }], + } + } + }, + "events": ["AAAA", "BBBBBB"], + } + }); + + let usage = normalize_from_rpc_envelope(&envelope).unwrap(); + assert_eq!(usage.cpu_insns, 150_000); + assert_eq!(usage.mem_bytes, 2048); + assert_eq!(usage.read_entries, 1); + assert_eq!(usage.read_bytes, 64); + assert_eq!(usage.write_entries, 2); + assert_eq!(usage.write_bytes, 160); + assert_eq!(usage.event_count, 2); + assert_eq!(usage.event_bytes, 4 + 6); + } + + #[test] + fn error_envelope_is_rejected() { + let envelope = json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { "code": -32600, "message": "boom" } + }); + let err = normalize_from_rpc_envelope(&envelope).unwrap_err(); + assert!(err.to_string().contains("error")); + } + + #[test] + fn missing_result_and_error_is_rejected() { + let envelope = json!({ "jsonrpc": "2.0", "id": 1 }); + assert!(normalize_from_rpc_envelope(&envelope).is_err()); + } + + #[test] + fn missing_optional_fields_default_to_zero() { + let envelope = json!({ "jsonrpc": "2.0", "id": 1, "result": {} }); + let usage = normalize_from_rpc_envelope(&envelope).unwrap(); + assert_eq!(usage, ResourceUsage::default()); + } + + #[test] + fn normalize_from_simulation_sums_footprint_and_events() { + use crate::utils::soroban::{ + FootprintAccess, StorageFootprintKey, StorageFootprintSummary, + }; + + let sim = SimulationResult { + return_value: "ok".to_string(), + fee: 12345, + events: vec!["abcd".to_string(), "ef".to_string()], + errors: vec![], + footprint: Some(StorageFootprintSummary { + read_only: vec![StorageFootprintKey { + access: FootprintAccess::ReadOnly, + key: "k1".to_string(), + size_hint_bytes: 40, + }], + read_write: vec![StorageFootprintKey { + access: FootprintAccess::ReadWrite, + key: "k2".to_string(), + size_hint_bytes: 80, + }], + }), + }; + + let usage = normalize_from_simulation(&sim); + assert_eq!(usage.read_entries, 1); + assert_eq!(usage.read_bytes, 40); + assert_eq!(usage.write_entries, 1); + assert_eq!(usage.write_bytes, 80); + assert_eq!(usage.event_count, 2); + assert_eq!(usage.event_bytes, 6); + assert_eq!(usage.cpu_insns, 0); + } + + #[test] + fn normalize_from_simulation_handles_missing_footprint() { + let sim = SimulationResult { + return_value: "ok".to_string(), + fee: 100, + events: vec![], + errors: vec![], + footprint: None, + }; + let usage = normalize_from_simulation(&sim); + assert_eq!(usage, ResourceUsage::default()); + } +} diff --git a/src/commands/cost/explain.rs b/src/commands/cost/explain.rs new file mode 100644 index 0000000..2f287d9 --- /dev/null +++ b/src/commands/cost/explain.rs @@ -0,0 +1,222 @@ +//! AI-assisted cost explanations with a deterministic fallback. +//! +//! Mirrors the `commands::ai::impact` shape: the deterministic engine always +//! computes a complete, usable explanation first; an AI narrative (when +//! enabled and reachable) only augments it. A failed or disabled AI call +//! never blocks the estimate from being useful. + +use crate::commands::ai::impact::redactor::redact_text; +use crate::commands::cost::model::CostEstimate; +use anyhow::{Context, Result}; +use async_openai::{ + config::OpenAIConfig, + types::{ChatCompletionRequestMessage, CreateChatCompletionRequest, Role}, + Client, +}; +use std::env; + +/// Builds the deterministic, rule-based explanation of cost drivers, +/// optimization opportunities, and budget risk. This always succeeds and +/// requires no network access, so it is safe to run in CI and as the +/// fallback when AI assistance is disabled or unavailable. +pub fn deterministic_explanation(estimate: &CostEstimate) -> String { + let mut lines = Vec::new(); + + lines.push(format!( + "Cost drivers for {} on {} ({} stroops / {:.7} XLM total):", + estimate.operation.as_str(), + estimate.network, + estimate.total_fee_stroops, + estimate.total_fee_xlm + )); + + for (name, amount) in estimate.breakdown.ranked_components() { + let pct = (amount as f64 / estimate.total_fee_stroops.max(1) as f64) * 100.0; + lines.push(format!(" - {}: {} stroops ({:.1}%)", name, amount, pct)); + } + + lines.push(String::new()); + lines.push("Optimization opportunities:".to_string()); + let mut suggestions = Vec::new(); + if estimate.breakdown.write_fee_stroops > estimate.breakdown.read_fee_stroops * 2 { + suggestions.push( + "Ledger writes dominate; consider batching writes or reducing persisted entry size." + .to_string(), + ); + } + if estimate.resource_usage.event_bytes > 0 && estimate.breakdown.event_fee_stroops > 0 { + suggestions.push( + "Contract emits sizeable event payloads; trimming event data reduces fee linearly." + .to_string(), + ); + } + if estimate.batch_size > 1 { + suggestions.push(format!( + "Already batching {} items — verify this is the optimal batch size for your \ + throughput/fee tradeoff.", + estimate.batch_size + )); + } + if suggestions.is_empty() { + suggestions.push( + "No obvious optimization opportunities detected from resource usage alone.".to_string(), + ); + } + for s in suggestions { + lines.push(format!(" - {}", s)); + } + + lines.push(String::new()); + lines.push("Budget risk:".to_string()); + match estimate.archival_ledgers_until_expiry { + Some(n) if n <= 0 => lines.push( + " - HIGH: target entry is archived; every access now carries a restore penalty." + .to_string(), + ), + Some(n) => lines.push(format!( + " - Entry has {} ledgers until expiry; monitor for archival-driven cost increases.", + n + )), + None => { + lines.push(" - No archival/TTL data supplied; unable to assess rent risk.".to_string()) + } + } + + lines.join("\n") +} + +/// Attempts to generate an AI narrative that augments the deterministic +/// explanation with free-form commentary. Returns `Ok(None)` (never an error +/// visible to the caller as a hard failure) when no API key is configured, so +/// callers can decide how to present "AI unavailable" vs. an actual API +/// error. Prompts and responses are redacted before leaving/entering this +/// function. +pub async fn maybe_generate_ai_narrative( + estimate: &CostEstimate, + model: &str, +) -> Result> { + let api_key = match env::var("OPENAI_API_KEY").or_else(|_| env::var("STARFORGE_AI_API_KEY")) { + Ok(key) => key, + Err(_) => return Ok(None), + }; + + let client = Client::with_config(OpenAIConfig::new().with_api_key(api_key)); + let narrative = generate_ai_narrative(&client, estimate, model).await?; + Ok(Some(narrative)) +} + +async fn generate_ai_narrative( + client: &Client, + estimate: &CostEstimate, + model: &str, +) -> Result { + let breakdown_str = estimate + .breakdown + .ranked_components() + .iter() + .map(|(name, amount)| format!("- {}: {} stroops", name, amount)) + .collect::>() + .join("\n"); + + let notes_str = estimate.notes.join("\n- "); + + let prompt = format!( + "Explain the economics of the following Soroban {} operation on {}:\n\n\ + Total fee: {} stroops ({:.7} XLM)\n\ + Batch size: {}\n\ + Cost breakdown:\n{}\n\n\ + Deterministic engine notes:\n- {}\n\n\ + Write a concise explanation (3-5 short paragraphs) covering: (1) what is driving this \ + cost, (2) concrete optimization opportunities specific to this resource profile, and \ + (3) any budget or archival risk a developer should plan for.", + estimate.operation.as_str(), + estimate.network, + estimate.total_fee_stroops, + estimate.total_fee_xlm, + estimate.batch_size, + breakdown_str, + notes_str, + ); + let redacted_prompt = redact_text(&prompt); + + let system_prompt = "You are a Soroban smart-contract cost and economics advisor. Be concrete \ + and quantitative; do not restate the raw numbers without interpreting them."; + + let messages = vec![ + ChatCompletionRequestMessage { + role: Role::System, + content: Some(system_prompt.to_string()), + name: None, + function_call: None, + }, + ChatCompletionRequestMessage { + role: Role::User, + content: Some(redacted_prompt), + name: None, + function_call: None, + }, + ]; + + let request = CreateChatCompletionRequest { + model: model.to_string(), + messages, + ..Default::default() + }; + + let response = crate::commands::ai::execute_chat(client, "cost_estimation", model, request) + .await + .context("Failed to generate AI cost narrative")?; + + let text = response + .choices + .first() + .and_then(|c| c.message.content.as_deref()) + .unwrap_or("No narrative generated by the AI provider.") + .trim(); + + Ok(redact_text(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::cost::model::{estimate_cost, OperationKind, ResourceUsage}; + + #[test] + fn deterministic_explanation_mentions_dominant_driver() { + let usage = ResourceUsage { + write_entries: 5, + write_bytes: 1000, + ..Default::default() + }; + let estimate = estimate_cost(&usage, OperationKind::Invoke, "testnet", 1, None); + let explanation = deterministic_explanation(&estimate); + assert!(explanation.contains("Cost drivers")); + assert!(explanation.contains("Optimization opportunities")); + assert!(explanation.contains("Budget risk")); + } + + #[test] + fn deterministic_explanation_flags_archived_entry_as_high_risk() { + let usage = ResourceUsage::default(); + let estimate = estimate_cost(&usage, OperationKind::Archival, "testnet", 1, Some(-10)); + let explanation = deterministic_explanation(&estimate); + assert!(explanation.contains("HIGH")); + } + + #[tokio::test] + async fn missing_api_key_yields_none_not_error() { + // SAFETY: test-only env var mutation, scoped to this process; no + // other test in this crate reads/writes these two variables. + unsafe { + env::remove_var("OPENAI_API_KEY"); + env::remove_var("STARFORGE_AI_API_KEY"); + } + let usage = ResourceUsage::default(); + let estimate = estimate_cost(&usage, OperationKind::Invoke, "testnet", 1, None); + let result = maybe_generate_ai_narrative(&estimate, "gpt-4") + .await + .unwrap(); + assert!(result.is_none()); + } +} diff --git a/src/commands/cost/history.rs b/src/commands/cost/history.rs new file mode 100644 index 0000000..cf53079 --- /dev/null +++ b/src/commands/cost/history.rs @@ -0,0 +1,452 @@ +//! Versioned persistence for cost estimates, enabling trend export and +//! regression-threshold checks across runs. +//! +//! Snapshots are stored as one pretty-printed JSON file per estimate under +//! `/cost_history/-/-.json`, +//! mirroring the versioned-report convention used by `utils::upgrade_analyzer` +//! rather than the append-only jsonl log convention used by +//! `utils::telemetry` — each estimate is a standalone, independently +//! loadable artifact rather than a log line. The label fingerprint keeps +//! labels that collide after sanitization (e.g. `"svc:v1"` and `"svc/v1"`) +//! from merging into the same directory. File names are RFC3339-ish sortable +//! timestamps with a zero-padded sequence suffix, so directory listing order +//! is chronological without parsing file contents even when two saves for +//! the same label land in the same millisecond. + +use crate::commands::cost::model::CostEstimate; +use crate::utils::config; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const HISTORY_SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistorySnapshot { + pub schema_version: u8, + pub label: String, + pub timestamp: DateTime, + pub estimate: CostEstimate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegressionCheckResult { + pub label: String, + pub threshold_percent: f64, + pub candidate_timestamp: DateTime, + pub candidate_fee_stroops: u64, + pub baseline_timestamp: Option>, + pub baseline_fee_stroops: Option, + pub delta_stroops: i64, + pub delta_percent: f64, + pub regressed: bool, +} + +/// Replaces any character outside `[A-Za-z0-9_-]` with `_` so a label is +/// always safe to use as a single path segment (no traversal, no separators). +fn sanitize_label(label: &str) -> String { + let sanitized: String = label + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "default".to_string() + } else { + sanitized + } +} + +/// First 8 hex characters of the label's SHA-256, used to disambiguate labels +/// that collide after sanitization (e.g. `"svc:v1"` and `"svc/v1"` both +/// sanitize to `"svc_v1"`). Deterministic, so the same raw label always maps +/// to the same directory. +fn label_fingerprint(label: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(label.as_bytes()); + let digest = hasher.finalize(); + hex::encode(&digest[..4]) +} + +fn history_dir(base: &Path, label: &str) -> PathBuf { + let dir_name = format!("{}-{}", sanitize_label(label), label_fingerprint(label)); + base.join("cost_history").join(dir_name) +} + +#[cfg(unix)] +fn restrict_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for {}", path.display()))? + .permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms) + .with_context(|| format!("Failed to restrict permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn restrict_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +/// Snapshot filenames always carry a zero-padded sequence suffix (`-000`, +/// `-001`, ...) rather than only a millisecond timestamp, so two saves for +/// the same label landing in the same millisecond get distinct, still +/// chronologically-sortable filenames instead of one silently overwriting +/// the other. Fixed-width padding keeps lexicographic order equal to +/// numeric/timestamp order in both dimensions. +fn snapshot_filename(timestamp: DateTime, seq: u32) -> String { + format!("{}-{:03}.json", timestamp.format("%Y%m%dT%H%M%S%3fZ"), seq) +} + +fn unique_snapshot_path(dir: &Path, timestamp: DateTime) -> PathBuf { + for seq in 0..1000u32 { + let candidate = dir.join(snapshot_filename(timestamp, seq)); + if !candidate.exists() { + return candidate; + } + } + // Effectively unreachable (1000 saves for one label within one + // millisecond), but fall back to a PID-qualified name rather than ever + // silently overwriting a snapshot. + dir.join(format!( + "{}-pid{}.json", + timestamp.format("%Y%m%dT%H%M%S%3fZ"), + std::process::id() + )) +} + +fn save_snapshot_in(base: &Path, label: &str, estimate: &CostEstimate) -> Result { + let dir = history_dir(base, label); + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create cost history directory {}", dir.display()))?; + + let timestamp = Utc::now(); + let mut estimate = estimate.clone(); + estimate.label = Some(label.to_string()); + let snapshot = HistorySnapshot { + schema_version: HISTORY_SCHEMA_VERSION, + label: label.to_string(), + timestamp, + estimate, + }; + + let path = unique_snapshot_path(&dir, timestamp); + let json = serde_json::to_string_pretty(&snapshot).context("Failed to serialize snapshot")?; + fs::write(&path, json) + .with_context(|| format!("Failed to write snapshot to {}", path.display()))?; + restrict_permissions(&path)?; + Ok(path) +} + +fn list_snapshot_paths_in(base: &Path, label: &str) -> Result> { + let dir = history_dir(base, label); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut paths: Vec = fs::read_dir(&dir) + .with_context(|| format!("Failed to read cost history directory {}", dir.display()))? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .collect(); + paths.sort(); + Ok(paths) +} + +fn load_snapshot_from(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read snapshot {}", path.display()))?; + serde_json::from_str(&contents).with_context(|| { + format!( + "Failed to parse snapshot {} (schema mismatch?)", + path.display() + ) + }) +} + +fn load_all_snapshots_in(base: &Path, label: &str) -> Result> { + list_snapshot_paths_in(base, label)? + .iter() + .map(|p| load_snapshot_from(p)) + .collect() +} + +fn check_regression_in( + base: &Path, + label: &str, + threshold_percent: f64, +) -> Result { + let snapshots = load_all_snapshots_in(base, label)?; + let candidate = snapshots + .last() + .with_context(|| format!("No cost history found for label '{}'", label))?; + + let baseline = if snapshots.len() >= 2 { + snapshots.get(snapshots.len() - 2) + } else { + None + }; + + let candidate_fee = candidate.estimate.total_fee_stroops; + let (baseline_timestamp, baseline_fee, delta_stroops, delta_percent) = match baseline { + Some(b) => { + let delta = candidate_fee as i64 - b.estimate.total_fee_stroops as i64; + let pct = if b.estimate.total_fee_stroops == 0 { + 0.0 + } else { + (delta as f64 / b.estimate.total_fee_stroops as f64) * 100.0 + }; + ( + Some(b.timestamp), + Some(b.estimate.total_fee_stroops), + delta, + pct, + ) + } + None => (None, None, 0, 0.0), + }; + + let regressed = baseline.is_some() && delta_percent > threshold_percent; + + Ok(RegressionCheckResult { + label: label.to_string(), + threshold_percent, + candidate_timestamp: candidate.timestamp, + candidate_fee_stroops: candidate_fee, + baseline_timestamp, + baseline_fee_stroops: baseline_fee, + delta_stroops, + delta_percent, + regressed, + }) +} + +fn export_history_in(base: &Path, label: &str, format: &str) -> Result { + let snapshots = load_all_snapshots_in(base, label)?; + match format { + "json" => { + serde_json::to_string_pretty(&snapshots).context("Failed to serialize history export") + } + "csv" => { + let mut out = String::from( + "timestamp,operation,network,batch_size,total_fee_stroops,total_fee_xlm\n", + ); + for s in &snapshots { + out.push_str(&format!( + "{},{},{},{},{},{:.7}\n", + s.timestamp.to_rfc3339(), + s.estimate.operation.as_str(), + s.estimate.network, + s.estimate.batch_size, + s.estimate.total_fee_stroops, + s.estimate.total_fee_xlm, + )); + } + Ok(out) + } + other => anyhow::bail!( + "Unsupported export format '{}'. Use 'json' or 'csv'.", + other + ), + } +} + +// ── Public API (defaults to the real starforge data directory) ────────────── + +pub fn save_snapshot(label: &str, estimate: &CostEstimate) -> Result { + save_snapshot_in(&config::get_data_dir()?, label, estimate) +} + +pub fn load_all_snapshots(label: &str) -> Result> { + load_all_snapshots_in(&config::get_data_dir()?, label) +} + +pub fn load_latest(label: &str) -> Result> { + Ok(load_all_snapshots(label)?.into_iter().next_back()) +} + +pub fn check_regression(label: &str, threshold_percent: f64) -> Result { + check_regression_in(&config::get_data_dir()?, label, threshold_percent) +} + +pub fn export_history(label: &str, format: &str) -> Result { + export_history_in(&config::get_data_dir()?, label, format) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::cost::model::{estimate_cost, OperationKind, ResourceUsage}; + use tempfile::tempdir; + + fn sample_estimate(cpu: u64) -> CostEstimate { + let usage = ResourceUsage { + cpu_insns: cpu, + ..Default::default() + }; + estimate_cost(&usage, OperationKind::Invoke, "testnet", 1, None) + } + + #[test] + fn sanitize_label_strips_unsafe_characters() { + assert_eq!(sanitize_label("my/contract:v1"), "my_contract_v1"); + assert_eq!(sanitize_label(""), "default"); + assert_eq!(sanitize_label("../../etc"), "______etc"); + } + + #[test] + fn save_and_load_round_trips() { + let dir = tempdir().unwrap(); + let est = sample_estimate(10_000); + let path = save_snapshot_in(dir.path(), "my-contract", &est).unwrap(); + assert!(path.exists()); + + let loaded = load_snapshot_from(&path).unwrap(); + assert_eq!(loaded.schema_version, HISTORY_SCHEMA_VERSION); + assert_eq!(loaded.estimate.total_fee_stroops, est.total_fee_stroops); + } + + #[cfg(unix)] + #[test] + fn saved_snapshot_has_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let est = sample_estimate(1_000); + let path = save_snapshot_in(dir.path(), "perm-check", &est).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn labels_colliding_after_sanitization_stay_isolated() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "svc:v1", &sample_estimate(10_000)).unwrap(); + save_snapshot_in(dir.path(), "svc/v1", &sample_estimate(999_000)).unwrap(); + + let a = load_all_snapshots_in(dir.path(), "svc:v1").unwrap(); + let b = load_all_snapshots_in(dir.path(), "svc/v1").unwrap(); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + assert_eq!( + a[0].estimate.total_fee_stroops, + sample_estimate(10_000).total_fee_stroops + ); + assert_eq!( + b[0].estimate.total_fee_stroops, + sample_estimate(999_000).total_fee_stroops + ); + } + + #[test] + fn same_millisecond_saves_do_not_overwrite_each_other() { + let dir = tempdir().unwrap(); + // No sleep between these: forces both saves into (very likely) the + // same millisecond so the sequence-suffix fallback is exercised. + for cpu in 1..=5u64 { + save_snapshot_in(dir.path(), "rapid", &sample_estimate(cpu)).unwrap(); + } + let snapshots = load_all_snapshots_in(dir.path(), "rapid").unwrap(); + assert_eq!( + snapshots.len(), + 5, + "no save should silently overwrite another" + ); + } + + #[test] + fn list_is_empty_when_no_history_exists() { + let dir = tempdir().unwrap(); + let snapshots = load_all_snapshots_in(dir.path(), "never-seen").unwrap(); + assert!(snapshots.is_empty()); + } + + #[test] + fn regression_check_flags_increase_beyond_threshold() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "regress-me", &sample_estimate(10_000)).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(2)); + save_snapshot_in(dir.path(), "regress-me", &sample_estimate(100_000)).unwrap(); + + let result = check_regression_in(dir.path(), "regress-me", 5.0).unwrap(); + assert!(result.regressed); + assert!(result.delta_percent > 5.0); + assert!(result.baseline_fee_stroops.is_some()); + } + + #[test] + fn regression_check_passes_within_threshold() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "stable", &sample_estimate(10_000)).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(2)); + save_snapshot_in(dir.path(), "stable", &sample_estimate(10_050)).unwrap(); + + let result = check_regression_in(dir.path(), "stable", 50.0).unwrap(); + assert!(!result.regressed); + } + + #[test] + fn regression_check_without_baseline_never_regresses() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "first-run", &sample_estimate(10_000)).unwrap(); + let result = check_regression_in(dir.path(), "first-run", 5.0).unwrap(); + assert!(!result.regressed); + assert!(result.baseline_fee_stroops.is_none()); + } + + #[test] + fn regression_check_errors_on_unknown_label() { + let dir = tempdir().unwrap(); + assert!(check_regression_in(dir.path(), "nonexistent", 5.0).is_err()); + } + + #[test] + fn export_json_round_trips_snapshots() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "export-me", &sample_estimate(5_000)).unwrap(); + let json = export_history_in(dir.path(), "export-me", "json").unwrap(); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.len(), 1); + } + + #[test] + fn export_csv_includes_header_and_row() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "export-csv", &sample_estimate(5_000)).unwrap(); + let csv = export_history_in(dir.path(), "export-csv", "csv").unwrap(); + assert!(csv.starts_with("timestamp,operation,network")); + assert_eq!(csv.lines().count(), 2); + } + + #[test] + fn export_rejects_unsupported_format() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "export-bad", &sample_estimate(5_000)).unwrap(); + assert!(export_history_in(dir.path(), "export-bad", "xml").is_err()); + } + + #[test] + fn snapshots_are_returned_in_chronological_order() { + let dir = tempdir().unwrap(); + save_snapshot_in(dir.path(), "ordered", &sample_estimate(1)).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(2)); + save_snapshot_in(dir.path(), "ordered", &sample_estimate(2)).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(2)); + save_snapshot_in(dir.path(), "ordered", &sample_estimate(3)).unwrap(); + + let snapshots = load_all_snapshots_in(dir.path(), "ordered").unwrap(); + let cpu_order: Vec = snapshots + .iter() + .map(|s| s.estimate.resource_usage.cpu_insns) + .collect(); + assert_eq!(cpu_order, vec![1, 2, 3]); + } +} diff --git a/src/commands/cost/mod.rs b/src/commands/cost/mod.rs new file mode 100644 index 0000000..c64752d --- /dev/null +++ b/src/commands/cost/mod.rs @@ -0,0 +1,578 @@ +//! AI-assisted cost estimation and economic analysis for Soroban operations. +//! +//! Provides a deterministic cost model (see [`model`]) fed either by manual +//! parameters or by normalizing real Soroban RPC simulation responses (see +//! [`adapter`]), an optional AI narrative layer with a deterministic fallback +//! (see [`explain`]), and versioned historical persistence enabling +//! regression-threshold checks in CI (see [`history`]). + +pub mod adapter; +pub mod explain; +pub mod history; +pub mod model; + +use crate::commands::ai::impact::redactor::redact_text; +use crate::utils::{config, print as p}; +use anyhow::{Context, Result}; +use clap::Subcommand; +use colored::*; +use model::{estimate_cost, OperationKind, ResourceUsage}; +use std::fs; +use std::path::PathBuf; + +const DEFAULT_HISTORY_LABEL: &str = "default"; + +#[derive(Subcommand)] +pub enum CostCommands { + /// Estimate the fee/resource cost of a single Soroban operation + Estimate { + /// Operation kind: deploy, invoke, storage-write, storage-read, archival, event, batch + operation: String, + + /// Network context used for base-fee heuristics (default: config network) + #[arg(long)] + network: Option, + + /// Path to a raw Soroban RPC simulateTransaction JSON response/fixture + /// to normalize into resource usage (see tests/fixtures/soroban_rpc/) + #[arg(long)] + simulation_file: Option, + + /// Manual CPU instruction count (overrides simulation file if both given) + #[arg(long)] + cpu_insns: Option, + /// Manual peak memory usage in bytes + #[arg(long)] + mem_bytes: Option, + /// Manual ledger entries read + #[arg(long)] + read_entries: Option, + /// Manual ledger entries written + #[arg(long)] + write_entries: Option, + /// Manual bytes read from the ledger + #[arg(long)] + read_bytes: Option, + /// Manual bytes written to the ledger + #[arg(long)] + write_bytes: Option, + /// Manual number of contract events emitted + #[arg(long)] + event_count: Option, + /// Manual total bytes of emitted event data + #[arg(long)] + event_bytes: Option, + + /// Number of items for a batch operation (amortizes the base fee) + #[arg(long, default_value_t = 1)] + batch_size: u32, + + /// Ledgers remaining until the targeted entry's TTL expires (negative/zero if already archived) + #[arg(long)] + ledgers_until_expiry: Option, + + /// History label to persist this estimate under when --save is passed + #[arg(long)] + label: Option, + + /// Persist this estimate to versioned history for later comparison/regression checks + #[arg(long)] + save: bool, + + /// Skip AI narrative generation and use only the deterministic explanation + #[arg(long)] + deterministic: bool, + + /// Model to use for the AI narrative (default: gpt-4) + #[arg(long, default_value = "gpt-4")] + model: String, + + /// Output format: markdown or json + #[arg(long, default_value = "markdown", value_parser = ["markdown", "json"])] + format: String, + + /// Optional path to write the report instead of stdout + #[arg(long)] + output: Option, + }, + + /// Compare two cost estimates (explicit snapshot files, or the two most + /// recent entries in a label's history) + Compare { + /// History label to compare the two most recent snapshots for + #[arg(long, conflicts_with_all = ["baseline", "candidate"])] + label: Option, + + /// Path to the baseline snapshot JSON (as produced by `estimate --save`) + #[arg(long, requires = "candidate")] + baseline: Option, + + /// Path to the candidate snapshot JSON + #[arg(long, requires = "baseline")] + candidate: Option, + + /// Output format: markdown or json + #[arg(long, default_value = "markdown", value_parser = ["markdown", "json"])] + format: String, + }, + + /// Check the latest estimate for a label against a fee budget + Budget { + /// History label to check + #[arg(long, default_value = "default")] + label: String, + + /// Maximum acceptable fee, in stroops + #[arg(long)] + max_fee_stroops: u64, + + /// Output format: markdown or json + #[arg(long, default_value = "markdown", value_parser = ["markdown", "json"])] + format: String, + }, + + /// Export historical estimates for a label as JSON or CSV + Export { + /// History label to export + #[arg(long, default_value = "default")] + label: String, + + /// Export format: json or csv + #[arg(long, default_value = "json", value_parser = ["json", "csv"])] + format: String, + + /// Optional path to write the export instead of stdout + #[arg(long)] + output: Option, + }, + + /// Fail if the latest estimate for a label regressed beyond a threshold + /// versus the previous one — intended for CI cost-regression gates + CheckRegression { + /// History label to check + #[arg(long, default_value = "default")] + label: String, + + /// Maximum acceptable percentage fee increase versus the previous estimate + #[arg(long, default_value_t = 10.0)] + threshold_percent: f64, + + /// Output format: markdown or json + #[arg(long, default_value = "markdown", value_parser = ["markdown", "json"])] + format: String, + }, +} + +pub async fn handle(cmd: CostCommands) -> Result<()> { + match cmd { + CostCommands::Estimate { + operation, + network, + simulation_file, + cpu_insns, + mem_bytes, + read_entries, + write_entries, + read_bytes, + write_bytes, + event_count, + event_bytes, + batch_size, + ledgers_until_expiry, + label, + save, + deterministic, + model, + format, + output, + } => { + estimate( + &operation, + network, + simulation_file, + ManualOverrides { + cpu_insns, + mem_bytes, + read_entries, + write_entries, + read_bytes, + write_bytes, + event_count, + event_bytes, + }, + batch_size, + ledgers_until_expiry, + label, + save, + deterministic, + &model, + &format, + output, + ) + .await + } + CostCommands::Compare { + label, + baseline, + candidate, + format, + } => compare(label, baseline, candidate, &format), + CostCommands::Budget { + label, + max_fee_stroops, + format, + } => budget(&label, max_fee_stroops, &format), + CostCommands::Export { + label, + format, + output, + } => export(&label, &format, output), + CostCommands::CheckRegression { + label, + threshold_percent, + format, + } => check_regression(&label, threshold_percent, &format), + } +} + +#[derive(Default)] +struct ManualOverrides { + cpu_insns: Option, + mem_bytes: Option, + read_entries: Option, + write_entries: Option, + read_bytes: Option, + write_bytes: Option, + event_count: Option, + event_bytes: Option, +} + +impl ManualOverrides { + fn apply(self, mut usage: ResourceUsage) -> ResourceUsage { + if let Some(v) = self.cpu_insns { + usage.cpu_insns = v; + } + if let Some(v) = self.mem_bytes { + usage.mem_bytes = v; + } + if let Some(v) = self.read_entries { + usage.read_entries = v; + } + if let Some(v) = self.write_entries { + usage.write_entries = v; + } + if let Some(v) = self.read_bytes { + usage.read_bytes = v; + } + if let Some(v) = self.write_bytes { + usage.write_bytes = v; + } + if let Some(v) = self.event_count { + usage.event_count = v; + } + if let Some(v) = self.event_bytes { + usage.event_bytes = v; + } + usage + } +} + +#[allow(clippy::too_many_arguments)] +async fn estimate( + operation: &str, + network: Option, + simulation_file: Option, + overrides: ManualOverrides, + batch_size: u32, + ledgers_until_expiry: Option, + label: Option, + save: bool, + deterministic: bool, + model: &str, + format: &str, + output: Option, +) -> Result<()> { + let op = OperationKind::parse(operation)?; + if batch_size == 0 { + anyhow::bail!("--batch-size must be at least 1 (got 0)"); + } + let cfg = config::load().unwrap_or_default(); + let network = network.unwrap_or(cfg.network); + config::validate_network(&network)?; + + let base_usage = match &simulation_file { + Some(path) => { + config::validate_file_path(path, Some("json"))?; + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read simulation file {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&contents).with_context(|| { + format!("Failed to parse simulation file {} as JSON", path.display()) + })?; + adapter::normalize_from_rpc_envelope(&value).with_context(|| { + format!("Failed to normalize simulation file {}", path.display()) + })? + } + None => ResourceUsage::default(), + }; + let usage = overrides.apply(base_usage); + + let mut cost_estimate = estimate_cost(&usage, op, &network, batch_size, ledgers_until_expiry); + + p::header("Soroban Cost Estimate"); + p::kv("Operation", op.as_str()); + p::kv("Network", &network); + + let explanation = if deterministic { + println!( + "{} Using deterministic cost engine (AI assistance disabled).", + "📊".cyan() + ); + explain::deterministic_explanation(&cost_estimate) + } else { + match explain::maybe_generate_ai_narrative(&cost_estimate, model).await { + Ok(Some(narrative)) => format!( + "{}\n\n---\n\nAI narrative:\n\n{}", + explain::deterministic_explanation(&cost_estimate), + narrative + ), + Ok(None) => { + println!( + "{} Using deterministic cost engine (AI assistance unavailable/unconfigured).", + "📊".cyan() + ); + explain::deterministic_explanation(&cost_estimate) + } + Err(e) => { + eprintln!( + "{} Warning: AI narrative generation failed: {}. Falling back to deterministic explanation.", + "⚠".yellow().bold(), + e + ); + explain::deterministic_explanation(&cost_estimate) + } + } + }; + + if save { + let label = label.unwrap_or_else(|| DEFAULT_HISTORY_LABEL.to_string()); + let path = history::save_snapshot(&label, &cost_estimate)?; + p::success(&format!("Saved snapshot to {}", path.display())); + cost_estimate.label = Some(label); + } + + let rendered = if format == "json" { + serde_json::to_string_pretty(&cost_estimate)? + } else { + format_estimate_markdown(&cost_estimate, Some(&explanation)) + }; + let rendered = redact_text(&rendered); + + write_or_print(&rendered, output) +} + +fn compare( + label: Option, + baseline_path: Option, + candidate_path: Option, + format: &str, +) -> Result<()> { + let (baseline, candidate) = if let Some(label) = label { + let snapshots = history::load_all_snapshots(&label)?; + if snapshots.len() < 2 { + anyhow::bail!( + "Need at least 2 saved estimates for label '{}' to compare; found {}", + label, + snapshots.len() + ); + } + let candidate = snapshots[snapshots.len() - 1].estimate.clone(); + let baseline = snapshots[snapshots.len() - 2].estimate.clone(); + (baseline, candidate) + } else { + let baseline_path = + baseline_path.context("--baseline is required when --label is not given")?; + let candidate_path = + candidate_path.context("--candidate is required when --label is not given")?; + ( + load_estimate_file(&baseline_path)?, + load_estimate_file(&candidate_path)?, + ) + }; + + let delta = candidate.total_fee_stroops as i64 - baseline.total_fee_stroops as i64; + let pct = if baseline.total_fee_stroops == 0 { + 0.0 + } else { + (delta as f64 / baseline.total_fee_stroops as f64) * 100.0 + }; + + let rendered = if format == "json" { + serde_json::to_string_pretty(&serde_json::json!({ + "baseline": baseline, + "candidate": candidate, + "delta_stroops": delta, + "delta_percent": pct, + }))? + } else { + let mut md = String::new(); + md.push_str("# Cost Comparison\n\n"); + md.push_str("| Metric | Baseline | Candidate | Delta |\n|---|---|---|---|\n"); + md.push_str(&format!( + "| Total fee (stroops) | {} | {} | {} ({:+.2}%) |\n", + baseline.total_fee_stroops, + candidate.total_fee_stroops, + if delta >= 0 { + format!("+{}", delta) + } else { + delta.to_string() + }, + pct + )); + md + }; + + write_or_print(&redact_text(&rendered), None) +} + +fn budget(label: &str, max_fee_stroops: u64, format: &str) -> Result<()> { + let latest = history::load_latest(label)? + .with_context(|| format!("No cost history found for label '{}'", label))?; + let over_budget = latest.estimate.total_fee_stroops > max_fee_stroops; + + let rendered = if format == "json" { + serde_json::to_string_pretty(&serde_json::json!({ + "label": label, + "max_fee_stroops": max_fee_stroops, + "actual_fee_stroops": latest.estimate.total_fee_stroops, + "over_budget": over_budget, + }))? + } else { + format!( + "Budget check for '{}': {} stroops actual vs {} stroops budget — {}", + label, + latest.estimate.total_fee_stroops, + max_fee_stroops, + if over_budget { + "OVER BUDGET".red().bold().to_string() + } else { + "within budget".green().to_string() + } + ) + }; + + println!("{}", redact_text(&rendered)); + if over_budget { + anyhow::bail!( + "Estimate for '{}' ({} stroops) exceeds budget of {} stroops", + label, + latest.estimate.total_fee_stroops, + max_fee_stroops + ); + } + Ok(()) +} + +fn export(label: &str, format: &str, output: Option) -> Result<()> { + let rendered = history::export_history(label, format)?; + write_or_print(&redact_text(&rendered), output) +} + +fn check_regression(label: &str, threshold_percent: f64, format: &str) -> Result<()> { + let result = history::check_regression(label, threshold_percent)?; + + let rendered = if format == "json" { + serde_json::to_string_pretty(&result)? + } else { + format!( + "Regression check for '{}': candidate {} stroops, baseline {}, delta {:+.2}% (threshold {:.2}%) — {}", + label, + result.candidate_fee_stroops, + result + .baseline_fee_stroops + .map(|v| v.to_string()) + .unwrap_or_else(|| "n/a (first run)".to_string()), + result.delta_percent, + threshold_percent, + if result.regressed { + "REGRESSED".red().bold().to_string() + } else { + "OK".green().to_string() + } + ) + }; + + println!("{}", redact_text(&rendered)); + if result.regressed { + anyhow::bail!( + "Cost regression detected for '{}': +{:.2}% exceeds threshold of {:.2}%", + label, + result.delta_percent, + threshold_percent + ); + } + Ok(()) +} + +fn load_estimate_file(path: &PathBuf) -> Result { + config::validate_file_path(path, Some("json"))?; + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read estimate file {}", path.display()))?; + if let Ok(snapshot) = serde_json::from_str::(&contents) { + return Ok(snapshot.estimate); + } + serde_json::from_str(&contents).with_context(|| { + format!( + "Failed to parse {} as a cost estimate or snapshot", + path.display() + ) + }) +} + +fn format_estimate_markdown(estimate: &model::CostEstimate, explanation: Option<&str>) -> String { + let mut md = String::new(); + md.push_str("# Soroban Cost Estimate\n\n"); + md.push_str(&format!( + "**Operation:** `{}`\n", + estimate.operation.as_str() + )); + md.push_str(&format!("**Network:** `{}`\n", estimate.network)); + md.push_str(&format!("**Batch size:** {}\n\n", estimate.batch_size)); + + md.push_str("## Breakdown\n\n"); + md.push_str("| Component | Stroops |\n|---|---|\n"); + for (name, amount) in estimate.breakdown.ranked_components() { + md.push_str(&format!("| {} | {} |\n", name, amount)); + } + md.push_str(&format!( + "| **Total** | **{} ({:.7} XLM)** |\n\n", + estimate.total_fee_stroops, estimate.total_fee_xlm + )); + + if !estimate.notes.is_empty() { + md.push_str("## Notes\n\n"); + for note in &estimate.notes { + md.push_str(&format!("- {}\n", note)); + } + md.push('\n'); + } + + if let Some(explanation) = explanation { + md.push_str("## Explanation\n\n"); + md.push_str(explanation); + md.push('\n'); + } + + md +} + +fn write_or_print(rendered: &str, output: Option) -> Result<()> { + match output { + Some(path) => { + fs::write(&path, rendered) + .with_context(|| format!("Failed to write output to {}", path.display()))?; + p::success(&format!("Report written to {}", path.display())); + } + None => println!("\n{}", rendered), + } + Ok(()) +} diff --git a/src/commands/cost/model.rs b/src/commands/cost/model.rs new file mode 100644 index 0000000..546af45 --- /dev/null +++ b/src/commands/cost/model.rs @@ -0,0 +1,536 @@ +//! Deterministic cost model for Soroban operations. +//! +//! Resource fee rates below are heuristic approximations of the Soroban fee +//! model (CPU instructions, memory, ledger read/write entries and bytes, +//! events, and archival/rent pressure), in the same spirit as the existing +//! `commands::gas` size-based heuristic. They are not a byte-for-byte replica +//! of validator fee computation, and are intended for relative comparison, +//! budgeting, and regression tracking rather than exact on-chain prediction. + +use crate::utils::soroban::DEFAULT_ARCHIVAL_WARNING_LEDGERS; +use serde::{Deserialize, Serialize}; + +pub const COST_MODEL_SCHEMA_VERSION: u8 = 1; + +/// Stroops per 10,000 CPU instructions. +const CPU_INSN_RATE_STROOPS_PER_10K: u64 = 25; +/// Stroops per KiB of high-water-mark memory used during simulation. +const MEM_BYTE_RATE_STROOPS_PER_KB: u64 = 4; +/// Flat per-entry fee for a ledger entry read. +const LEDGER_READ_ENTRY_FEE_STROOPS: u64 = 1_000; +/// Flat per-entry fee for a ledger entry write (writes are costlier than reads). +const LEDGER_WRITE_ENTRY_FEE_STROOPS: u64 = 5_000; +/// Stroops per byte written to the ledger (rent-bearing storage). +const LEDGER_WRITE_BYTE_RATE_STROOPS: u64 = 40; +/// Stroops per byte read from the ledger. +const LEDGER_READ_BYTE_RATE_STROOPS: u64 = 6; +/// Stroops per byte of emitted contract event data. +const EVENT_BYTE_RATE_STROOPS: u64 = 20; +/// Classic transaction base fee, in stroops. +const BASE_TRANSACTION_FEE_STROOPS: u64 = 100; +/// Flat penalty applied when an archived ledger entry must be restored before use. +const ARCHIVAL_RESTORE_PENALTY_STROOPS: u64 = 50_000; +/// One XLM in stroops. +const STROOPS_PER_XLM: f64 = 10_000_000.0; + +/// Divides with rounding to the nearest integer instead of truncating, so a +/// one-time fee split across a batch reconciles with the total as closely as +/// integer stroops allow (truncation would systematically undercount). +fn round_div(numerator: u64, denominator: u64) -> u64 { + if denominator == 0 { + return numerator; + } + (numerator + denominator / 2) / denominator +} + +/// Network congestion multiplier applied to the base fee component. This is a +/// coarse heuristic distinguishing quiet testnets from mainnet, not a live +/// surge-pricing feed. +fn network_base_fee_multiplier(network: &str) -> f64 { + match network { + "mainnet" => 1.5, + "testnet" | "docker-testnet" => 1.0, + _ => 1.0, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OperationKind { + Deploy, + Invoke, + StorageWrite, + StorageRead, + Archival, + Event, + Batch, +} + +impl OperationKind { + pub fn as_str(&self) -> &'static str { + match self { + OperationKind::Deploy => "deploy", + OperationKind::Invoke => "invoke", + OperationKind::StorageWrite => "storage-write", + OperationKind::StorageRead => "storage-read", + OperationKind::Archival => "archival", + OperationKind::Event => "event", + OperationKind::Batch => "batch", + } + } + + pub fn parse(value: &str) -> anyhow::Result { + match value { + "deploy" => Ok(OperationKind::Deploy), + "invoke" => Ok(OperationKind::Invoke), + "storage-write" => Ok(OperationKind::StorageWrite), + "storage-read" => Ok(OperationKind::StorageRead), + "archival" => Ok(OperationKind::Archival), + "event" => Ok(OperationKind::Event), + "batch" => Ok(OperationKind::Batch), + other => anyhow::bail!( + "Unknown operation kind '{}'. Expected one of: deploy, invoke, storage-write, \ + storage-read, archival, event, batch", + other + ), + } + } +} + +/// Normalized resource usage for a single operation, independent of how it +/// was obtained (live RPC simulation, a fixture file, or manual parameters). +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct ResourceUsage { + pub cpu_insns: u64, + pub mem_bytes: u64, + pub read_entries: u32, + pub write_entries: u32, + pub read_bytes: u64, + pub write_bytes: u64, + pub event_count: u32, + pub event_bytes: u64, +} + +impl ResourceUsage { + pub fn is_empty(&self) -> bool { + *self == ResourceUsage::default() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct CostBreakdown { + pub cpu_fee_stroops: u64, + pub mem_fee_stroops: u64, + pub read_fee_stroops: u64, + pub write_fee_stroops: u64, + pub event_fee_stroops: u64, + pub archival_fee_stroops: u64, + pub base_fee_stroops: u64, +} + +impl CostBreakdown { + pub fn total_stroops(&self) -> u64 { + self.cpu_fee_stroops + + self.mem_fee_stroops + + self.read_fee_stroops + + self.write_fee_stroops + + self.event_fee_stroops + + self.archival_fee_stroops + + self.base_fee_stroops + } + + /// Returns each named component's share of the total, sorted descending, + /// skipping zero-valued components. Used to identify the dominant cost driver. + pub fn ranked_components(&self) -> Vec<(&'static str, u64)> { + let mut parts = vec![ + ("cpu", self.cpu_fee_stroops), + ("memory", self.mem_fee_stroops), + ("ledger reads", self.read_fee_stroops), + ("ledger writes", self.write_fee_stroops), + ("events", self.event_fee_stroops), + ("archival restore", self.archival_fee_stroops), + ("base fee", self.base_fee_stroops), + ]; + parts.retain(|(_, v)| *v > 0); + parts.sort_by(|a, b| b.1.cmp(&a.1)); + parts + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CostEstimate { + pub schema_version: u8, + pub operation: OperationKind, + pub network: String, + pub label: Option, + pub batch_size: u32, + pub resource_usage: ResourceUsage, + pub breakdown: CostBreakdown, + pub total_fee_stroops: u64, + pub total_fee_xlm: f64, + pub per_item_fee_stroops: u64, + pub archival_ledgers_until_expiry: Option, + pub notes: Vec, +} + +impl CostEstimate { + pub fn stroops_to_xlm(stroops: u64) -> f64 { + stroops as f64 / STROOPS_PER_XLM + } +} + +/// Computes a full cost estimate from normalized resource usage. +/// +/// `batch_size` scales per-item resource fees (cpu/mem/read/write/event) by +/// the item count while charging the base transaction fee only once, modeling +/// how batch operations amortize fixed overhead across many items. +/// `ledgers_until_expiry` (when known) drives archival-risk notes and, once +/// the entry is already archived (negative or zero), applies a one-time +/// restore penalty — a batch shares a single archived entry's restore cost, +/// so it is charged once per estimate rather than once per item, exactly +/// like the base transaction fee. +pub fn estimate_cost( + usage: &ResourceUsage, + operation: OperationKind, + network: &str, + batch_size: u32, + ledgers_until_expiry: Option, +) -> CostEstimate { + // Callers are expected to reject batch_size == 0 before reaching this + // point (the CLI does, in `commands::cost::estimate`); this clamp is only + // a defensive fallback for other callers, not the primary validation. + let batch_size = batch_size.max(1); + let multiplier = network_base_fee_multiplier(network); + + let cpu_fee = (usage.cpu_insns / 10_000) * CPU_INSN_RATE_STROOPS_PER_10K; + let mem_fee = (usage.mem_bytes / 1024) * MEM_BYTE_RATE_STROOPS_PER_KB; + let read_fee = (usage.read_entries as u64 * LEDGER_READ_ENTRY_FEE_STROOPS) + + (usage.read_bytes * LEDGER_READ_BYTE_RATE_STROOPS); + let write_fee = (usage.write_entries as u64 * LEDGER_WRITE_ENTRY_FEE_STROOPS) + + (usage.write_bytes * LEDGER_WRITE_BYTE_RATE_STROOPS); + let event_fee = usage.event_bytes * EVENT_BYTE_RATE_STROOPS; + + let is_archived = matches!(ledgers_until_expiry, Some(n) if n <= 0); + let archival_fee = if is_archived { + ARCHIVAL_RESTORE_PENALTY_STROOPS + } else { + 0 + }; + + let base_fee = (BASE_TRANSACTION_FEE_STROOPS as f64 * multiplier).round() as u64; + + // Per-item resource fees scale with batch_size; archival_fee and base_fee + // are one-time charges shared across the whole batch (a batch of N items + // still only touches one archived entry and submits one transaction). + let per_item = CostBreakdown { + cpu_fee_stroops: cpu_fee, + mem_fee_stroops: mem_fee, + read_fee_stroops: read_fee, + write_fee_stroops: write_fee, + event_fee_stroops: event_fee, + archival_fee_stroops: 0, + base_fee_stroops: 0, + }; + + let breakdown = CostBreakdown { + cpu_fee_stroops: per_item.cpu_fee_stroops * batch_size as u64, + mem_fee_stroops: per_item.mem_fee_stroops * batch_size as u64, + read_fee_stroops: per_item.read_fee_stroops * batch_size as u64, + write_fee_stroops: per_item.write_fee_stroops * batch_size as u64, + event_fee_stroops: per_item.event_fee_stroops * batch_size as u64, + archival_fee_stroops: archival_fee, + base_fee_stroops: base_fee, + }; + + let total = breakdown.total_stroops(); + let one_time_fee = base_fee + archival_fee; + let per_item_fee = per_item.total_stroops() + round_div(one_time_fee, batch_size as u64); + + let notes = build_notes( + &breakdown, + usage, + operation, + batch_size, + ledgers_until_expiry, + ); + + CostEstimate { + schema_version: COST_MODEL_SCHEMA_VERSION, + operation, + network: network.to_string(), + label: None, + batch_size, + resource_usage: *usage, + breakdown, + total_fee_stroops: total, + total_fee_xlm: CostEstimate::stroops_to_xlm(total), + per_item_fee_stroops: per_item_fee, + archival_ledgers_until_expiry: ledgers_until_expiry, + notes, + } +} + +fn build_notes( + breakdown: &CostBreakdown, + usage: &ResourceUsage, + operation: OperationKind, + batch_size: u32, + ledgers_until_expiry: Option, +) -> Vec { + let mut notes = Vec::new(); + + if let Some((driver, amount)) = breakdown.ranked_components().first() { + let total = breakdown.total_stroops().max(1); + let pct = (*amount as f64 / total as f64) * 100.0; + notes.push(format!( + "Dominant cost driver: {} ({:.1}% of total fee)", + driver, pct + )); + } + + if usage.write_entries > 0 || usage.write_bytes > 0 { + notes.push(format!( + "Storage growth: {} write entr{} ({} bytes) will occupy ledger space until archived or removed", + usage.write_entries, + if usage.write_entries == 1 { "y" } else { "ies" }, + usage.write_bytes + )); + } + + if let Some(remaining) = ledgers_until_expiry { + if remaining <= 0 { + notes.push( + "Archival risk: target entry is already archived; a restore fee was applied" + .to_string(), + ); + } else if remaining <= DEFAULT_ARCHIVAL_WARNING_LEDGERS as i64 { + notes.push(format!( + "Archival risk: entry expires in {} ledgers, within the {}-ledger warning window \ + — consider a bump/extend before it lapses", + remaining, DEFAULT_ARCHIVAL_WARNING_LEDGERS + )); + } + } + + if operation == OperationKind::Batch && batch_size > 1 { + notes.push(format!( + "Batch of {} items amortizes the base transaction fee across all items \ + ({} stroops/item vs {} stroops standalone)", + batch_size, + breakdown + .base_fee_stroops + .checked_div(batch_size as u64) + .unwrap_or(0), + breakdown.base_fee_stroops + )); + } + + if usage.is_empty() { + notes.push( + "No resource usage was supplied or detected; this estimate reflects only the base \ + transaction fee" + .to_string(), + ); + } + + notes +} + +/// Projects storage rent/archival pressure across a set of future checkpoints +/// (in ledger-count offsets from now), given a fixed decay rate expressed as +/// ledgers-until-expiry shrinking linearly with each checkpoint. Used by the +/// `budget` command to warn about entries that will cross the archival +/// threshold before the next expected estimate run. +pub fn project_archival_horizon( + current_ledgers_until_expiry: i64, + checkpoints: &[u32], +) -> Vec<(u32, i64, bool)> { + checkpoints + .iter() + .map(|&offset| { + let remaining = current_ledgers_until_expiry - offset as i64; + let at_risk = remaining <= DEFAULT_ARCHIVAL_WARNING_LEDGERS as i64; + (offset, remaining, at_risk) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usage( + cpu: u64, + mem: u64, + reads: u32, + writes: u32, + rb: u64, + wb: u64, + ev: u32, + evb: u64, + ) -> ResourceUsage { + ResourceUsage { + cpu_insns: cpu, + mem_bytes: mem, + read_entries: reads, + write_entries: writes, + read_bytes: rb, + write_bytes: wb, + event_count: ev, + event_bytes: evb, + } + } + + #[test] + fn operation_kind_round_trips_through_str() { + for op in [ + OperationKind::Deploy, + OperationKind::Invoke, + OperationKind::StorageWrite, + OperationKind::StorageRead, + OperationKind::Archival, + OperationKind::Event, + OperationKind::Batch, + ] { + assert_eq!(OperationKind::parse(op.as_str()).unwrap(), op); + } + } + + #[test] + fn unknown_operation_kind_is_rejected() { + assert!(OperationKind::parse("teleport").is_err()); + } + + #[test] + fn empty_usage_yields_only_base_fee() { + let est = estimate_cost( + &ResourceUsage::default(), + OperationKind::Invoke, + "testnet", + 1, + None, + ); + assert_eq!(est.breakdown.cpu_fee_stroops, 0); + assert!(est.breakdown.base_fee_stroops > 0); + assert_eq!(est.total_fee_stroops, est.breakdown.base_fee_stroops); + assert!(est.notes.iter().any(|n| n.contains("No resource usage"))); + } + + #[test] + fn heavier_usage_costs_more() { + let light = usage(10_000, 1024, 1, 1, 100, 100, 1, 50); + let heavy = usage(100_000, 10_240, 5, 5, 1_000, 1_000, 5, 500); + let light_est = estimate_cost(&light, OperationKind::Invoke, "testnet", 1, None); + let heavy_est = estimate_cost(&heavy, OperationKind::Invoke, "testnet", 1, None); + assert!(heavy_est.total_fee_stroops > light_est.total_fee_stroops); + } + + #[test] + fn mainnet_base_fee_exceeds_testnet() { + let u = usage(1_000, 100, 0, 0, 0, 0, 0, 0); + let testnet = estimate_cost(&u, OperationKind::Invoke, "testnet", 1, None); + let mainnet = estimate_cost(&u, OperationKind::Invoke, "mainnet", 1, None); + assert!(mainnet.breakdown.base_fee_stroops > testnet.breakdown.base_fee_stroops); + } + + #[test] + fn batch_amortizes_base_fee_but_scales_resource_fees() { + let u = usage(50_000, 2048, 2, 2, 200, 200, 2, 100); + let single = estimate_cost(&u, OperationKind::Batch, "testnet", 1, None); + let batched = estimate_cost(&u, OperationKind::Batch, "testnet", 10, None); + + assert_eq!( + batched.breakdown.base_fee_stroops, + single.breakdown.base_fee_stroops + ); + assert_eq!( + batched.breakdown.cpu_fee_stroops, + single.breakdown.cpu_fee_stroops * 10 + ); + assert!(batched + .notes + .iter() + .any(|n| n.contains("amortizes the base transaction fee"))); + } + + #[test] + fn archival_penalty_is_charged_once_not_per_batch_item() { + let u = usage(1_000, 100, 1, 0, 50, 0, 0, 0); + let single = estimate_cost(&u, OperationKind::Batch, "testnet", 1, Some(-1)); + let batched = estimate_cost(&u, OperationKind::Batch, "testnet", 100, Some(-1)); + + assert_eq!( + single.breakdown.archival_fee_stroops, + ARCHIVAL_RESTORE_PENALTY_STROOPS + ); + assert_eq!( + batched.breakdown.archival_fee_stroops, ARCHIVAL_RESTORE_PENALTY_STROOPS, + "a batch shares one archived entry's restore cost; it must not scale with batch_size" + ); + } + + #[test] + fn per_item_fee_reconciles_closely_with_total_for_uneven_batch_sizes() { + let u = usage(20_000, 512, 1, 1, 64, 64, 1, 32); + let est = estimate_cost(&u, OperationKind::Batch, "mainnet", 7, None); + let reconstructed = est.per_item_fee_stroops * 7; + let diff = (reconstructed as i64 - est.total_fee_stroops as i64).abs(); + // Rounding (not truncating) the one-time-fee split keeps the + // reconstructed total within half a stroop-per-item of the true total. + assert!( + diff <= 3, + "per-item fee drifted too far from total: {}", + diff + ); + } + + #[test] + fn archived_entry_applies_restore_penalty_and_note() { + let u = usage(1_000, 100, 1, 0, 50, 0, 0, 0); + let est = estimate_cost(&u, OperationKind::Archival, "testnet", 1, Some(-5)); + assert_eq!( + est.breakdown.archival_fee_stroops, + ARCHIVAL_RESTORE_PENALTY_STROOPS + ); + assert!(est.notes.iter().any(|n| n.contains("already archived"))); + } + + #[test] + fn expiring_soon_entry_warns_without_penalty() { + let u = usage(1_000, 100, 1, 0, 50, 0, 0, 0); + let est = estimate_cost(&u, OperationKind::Invoke, "testnet", 1, Some(500)); + assert_eq!(est.breakdown.archival_fee_stroops, 0); + assert!(est.notes.iter().any(|n| n.contains("warning window"))); + } + + #[test] + fn stroops_to_xlm_conversion_is_correct() { + assert!((CostEstimate::stroops_to_xlm(10_000_000) - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn project_archival_horizon_flags_future_risk() { + let checkpoints = [0, 500, 1_500]; + let projection = project_archival_horizon(1_200, &checkpoints); + assert_eq!(projection.len(), 3); + assert!(!projection[0].2); // 1200 remaining, not at risk + assert!(projection[1].2); // 700 remaining, at risk + assert!(projection[2].2); // negative, at risk + } + + #[test] + fn ranked_components_sorted_descending_and_skip_zero() { + let breakdown = CostBreakdown { + cpu_fee_stroops: 10, + mem_fee_stroops: 0, + read_fee_stroops: 50, + write_fee_stroops: 5, + event_fee_stroops: 0, + archival_fee_stroops: 0, + base_fee_stroops: 100, + }; + let ranked = breakdown.ranked_components(); + assert_eq!(ranked.first().unwrap().0, "base fee"); + assert!(ranked.iter().all(|(_, v)| *v > 0)); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8a18d17..ee52792 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod completions; pub mod compliance; pub mod config; pub mod contract; +pub mod cost; pub mod deploy; pub mod diagnostics; pub mod gas; diff --git a/src/main.rs b/src/main.rs index 4577e24..c8c5529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,6 +127,10 @@ enum Commands { #[command(subcommand)] Compliance(commands::compliance::ComplianceCommands), + /// AI-assisted cost estimation and economic analysis for Soroban operations + #[command(subcommand)] + Cost(commands::cost::CostCommands), + /// Execute an installed plugin command (e.g. `starforge defi ...`) #[command(external_subcommand)] External(Vec), @@ -213,6 +217,7 @@ fn main() { Commands::Diagnostics(_) => "diagnostics", Commands::Ai(_) => "ai", Commands::Compliance(_) => "compliance", + Commands::Cost(_) => "cost", Commands::External(_) => "external", } .to_string(); @@ -248,6 +253,9 @@ fn main() { .context("Failed to create async runtime") .and_then(|rt| rt.block_on(commands::ai::handle(args))), Commands::Compliance(cmd) => commands::compliance::handle(cmd), + Commands::Cost(cmd) => tokio::runtime::Runtime::new() + .context("Failed to create async runtime") + .and_then(|rt| rt.block_on(commands::cost::handle(cmd))), Commands::External(args) => handle_external_plugin(args), }; let duration = start.elapsed(); diff --git a/tests/cost_estimate_cli.rs b/tests/cost_estimate_cli.rs new file mode 100644 index 0000000..05d7024 --- /dev/null +++ b/tests/cost_estimate_cli.rs @@ -0,0 +1,474 @@ +//! End-to-end CLI coverage for `starforge cost` (issue #50 / AI-015). +//! +//! Follows the isolated-`HOME` pattern from `tests/cli_smoke.rs` / +//! `tests/compliance_cli.rs`: no network access, no shared state between +//! tests. AI narrative generation is always skipped here (either via +//! `--deterministic` or by removing the API key env vars), so nothing in +//! this file depends on a live OpenAI endpoint. + +use std::path::Path; +use std::process::{Command, Output}; + +fn isolated_home() -> tempfile::TempDir { + tempfile::tempdir().expect("create isolated home") +} + +fn starforge(home: &Path) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_starforge")); + cmd.arg("-q"); + cmd.env("HOME", home); + cmd.env("USERPROFILE", home); + cmd.env_remove("OPENAI_API_KEY"); + cmd.env_remove("STARFORGE_AI_API_KEY"); + cmd +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn assert_success(output: &Output, cmd: &str) { + assert!( + output.status.success(), + "{} failed: {}", + cmd, + stderr(output) + ); +} + +fn fixture_path(name: &str) -> String { + format!( + "{}/tests/fixtures/soroban_rpc/{}", + env!("CARGO_MANIFEST_DIR"), + name + ) +} + +#[test] +fn estimate_deterministic_markdown_reports_breakdown_and_total() { + let home = isolated_home(); + let output = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--write-entries", + "2", + "--write-bytes", + "500", + "--deterministic", + ]) + .output() + .expect("spawn estimate"); + assert_success(&output, "cost estimate invoke"); + let out = stdout(&output); + assert!(out.contains("Soroban Cost Estimate")); + assert!(out.contains("Breakdown")); + assert!(out.contains("Total")); + assert!(out.contains("Cost drivers")); +} + +#[test] +fn estimate_json_is_valid_and_reflects_operation() { + let home = isolated_home(); + let output = starforge(home.path()) + .args([ + "cost", + "estimate", + "deploy", + "--format", + "json", + "--deterministic", + ]) + .output() + .expect("spawn estimate"); + assert_success(&output, "cost estimate deploy --format json"); + + let out = stdout(&output); + let json_start = out.find('{').expect("json object in output"); + let parsed: serde_json::Value = + serde_json::from_str(out[json_start..].trim()).expect("estimate output is valid JSON"); + assert_eq!(parsed["operation"], "deploy"); + assert!(parsed["total_fee_stroops"].as_u64().unwrap() > 0); +} + +#[test] +fn estimate_rejects_unknown_operation() { + let home = isolated_home(); + let output = starforge(home.path()) + .args(["cost", "estimate", "teleport", "--deterministic"]) + .output() + .expect("spawn estimate"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("Unknown operation kind")); +} + +#[test] +fn estimate_from_simulation_file_normalizes_cost_and_footprint() { + let home = isolated_home(); + let output = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--simulation-file", + &fixture_path("simulate_cost_with_footprint.json"), + "--format", + "json", + "--deterministic", + ]) + .output() + .expect("spawn estimate"); + assert_success(&output, "cost estimate --simulation-file"); + + let out = stdout(&output); + let json_start = out.find('{').expect("json object in output"); + let parsed: serde_json::Value = serde_json::from_str(out[json_start..].trim()).unwrap(); + assert_eq!(parsed["resource_usage"]["cpu_insns"], 480000); + assert_eq!(parsed["resource_usage"]["mem_bytes"], 8192); + assert_eq!(parsed["resource_usage"]["read_entries"], 1); + assert_eq!(parsed["resource_usage"]["write_entries"], 2); + assert_eq!(parsed["resource_usage"]["write_bytes"], 384); +} + +#[test] +fn estimate_with_bad_simulation_file_json_fails_clearly() { + let home = isolated_home(); + let bad_path = home.path().join("bad.json"); + std::fs::write(&bad_path, "{ not valid json").unwrap(); + + let output = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--simulation-file", + bad_path.to_str().unwrap(), + "--deterministic", + ]) + .output() + .expect("spawn estimate"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("Failed to parse simulation file")); +} + +#[test] +fn estimate_rejects_zero_batch_size_instead_of_silently_clamping() { + let home = isolated_home(); + let output = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--batch-size", + "0", + "--deterministic", + ]) + .output() + .expect("spawn estimate"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("--batch-size must be at least 1")); +} + +#[test] +fn save_then_export_round_trips_history_as_json() { + let home = isolated_home(); + let save = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "my-contract", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn estimate --save"); + assert_success(&save, "cost estimate --save"); + assert!(stdout(&save).contains("Saved snapshot")); + + let export = starforge(home.path()) + .args([ + "cost", + "export", + "--label", + "my-contract", + "--format", + "json", + ]) + .output() + .expect("spawn export"); + assert_success(&export, "cost export"); + + let parsed: serde_json::Value = + serde_json::from_str(stdout(&export).trim()).expect("export output is valid JSON"); + assert_eq!(parsed.as_array().unwrap().len(), 1); +} + +#[test] +fn export_csv_has_header_and_one_row_per_snapshot() { + let home = isolated_home(); + for _ in 0..2 { + let save = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "csv-label", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn estimate --save"); + assert_success(&save, "cost estimate --save"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let export = starforge(home.path()) + .args(["cost", "export", "--label", "csv-label", "--format", "csv"]) + .output() + .expect("spawn export csv"); + assert_success(&export, "cost export --format csv"); + let out = stdout(&export); + assert!(out.contains("timestamp,operation,network")); + assert_eq!(out.trim().lines().count(), 3); +} + +#[test] +fn export_unknown_label_yields_empty_history_not_an_error() { + let home = isolated_home(); + let export = starforge(home.path()) + .args([ + "cost", + "export", + "--label", + "never-estimated", + "--format", + "json", + ]) + .output() + .expect("spawn export"); + assert_success(&export, "cost export unknown label"); + let parsed: serde_json::Value = serde_json::from_str(stdout(&export).trim()).unwrap(); + assert_eq!(parsed.as_array().unwrap().len(), 0); +} + +#[test] +fn budget_passes_when_under_and_fails_when_over() { + let home = isolated_home(); + let save = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "budget-label", + "--write-entries", + "3", + "--write-bytes", + "900", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn estimate --save"); + assert_success(&save, "cost estimate --save"); + + let under = starforge(home.path()) + .args([ + "cost", + "budget", + "--label", + "budget-label", + "--max-fee-stroops", + "1000000000", + ]) + .output() + .expect("spawn budget (under)"); + assert_success(&under, "cost budget (under budget)"); + assert!(stdout(&under).contains("within budget")); + + let over = starforge(home.path()) + .args([ + "cost", + "budget", + "--label", + "budget-label", + "--max-fee-stroops", + "1", + ]) + .output() + .expect("spawn budget (over)"); + assert!( + !over.status.success(), + "expected budget check to fail when over budget" + ); + assert!(stdout(&over).contains("OVER BUDGET") || stderr(&over).contains("exceeds budget")); +} + +#[test] +fn budget_on_unknown_label_fails_with_clear_message() { + let home = isolated_home(); + let output = starforge(home.path()) + .args([ + "cost", + "budget", + "--label", + "nonexistent", + "--max-fee-stroops", + "1000", + ]) + .output() + .expect("spawn budget"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("No cost history found")); +} + +#[test] +fn check_regression_passes_on_first_run_and_fails_on_large_increase() { + let home = isolated_home(); + + let first = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "regress-label", + "--write-entries", + "1", + "--write-bytes", + "100", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn first estimate"); + assert_success(&first, "cost estimate (baseline)"); + + let first_check = starforge(home.path()) + .args([ + "cost", + "check-regression", + "--label", + "regress-label", + "--threshold-percent", + "10", + ]) + .output() + .expect("spawn check-regression"); + assert_success(&first_check, "cost check-regression (first run)"); + assert!(stdout(&first_check).contains("OK")); + + std::thread::sleep(std::time::Duration::from_millis(5)); + let second = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "regress-label", + "--write-entries", + "50", + "--write-bytes", + "50000", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn second estimate"); + assert_success(&second, "cost estimate (regressed)"); + + let second_check = starforge(home.path()) + .args([ + "cost", + "check-regression", + "--label", + "regress-label", + "--threshold-percent", + "10", + ]) + .output() + .expect("spawn check-regression"); + assert!( + !second_check.status.success(), + "expected regression to be detected" + ); + assert!(stdout(&second_check).contains("REGRESSED")); +} + +#[test] +fn compare_by_label_reports_delta_between_last_two_snapshots() { + let home = isolated_home(); + for writes in [1u32, 10u32] { + let save = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "compare-label", + "--write-entries", + &writes.to_string(), + "--write-bytes", + "1000", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn estimate --save"); + assert_success(&save, "cost estimate --save"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let compare = starforge(home.path()) + .args(["cost", "compare", "--label", "compare-label"]) + .output() + .expect("spawn compare"); + assert_success(&compare, "cost compare --label"); + assert!(stdout(&compare).contains("Cost Comparison")); +} + +#[test] +fn compare_with_fewer_than_two_snapshots_fails_clearly() { + let home = isolated_home(); + let save = starforge(home.path()) + .args([ + "cost", + "estimate", + "invoke", + "--label", + "solo-label", + "--save", + "--deterministic", + ]) + .output() + .expect("spawn estimate --save"); + assert_success(&save, "cost estimate --save"); + + let compare = starforge(home.path()) + .args(["cost", "compare", "--label", "solo-label"]) + .output() + .expect("spawn compare"); + assert!(!compare.status.success()); + assert!(stderr(&compare).contains("Need at least 2")); +} + +#[test] +fn estimate_help_documents_operation_and_save_flags() { + let home = isolated_home(); + let output = starforge(home.path()) + .args(["cost", "estimate", "--help"]) + .output() + .expect("spawn estimate --help"); + assert_success(&output, "cost estimate --help"); + let out = stdout(&output); + assert!(out.contains("--save")); + assert!(out.contains("--deterministic")); +} diff --git a/tests/fixtures/soroban_rpc/simulate_cost_with_footprint.json b/tests/fixtures/soroban_rpc/simulate_cost_with_footprint.json new file mode 100644 index 0000000..61f6f1a --- /dev/null +++ b/tests/fixtures/soroban_rpc/simulate_cost_with_footprint.json @@ -0,0 +1,35 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "latestLedger": 987654, + "cost": { + "cpuInsns": 480000, + "memBytes": 8192 + }, + "transactionData": { + "resources": { + "footprint": { + "readOnly": [ + { "key": "AAAAAQ==", "sizeHintBytes": 64 } + ], + "readWrite": [ + { "key": "AAAAAg==", "sizeHintBytes": 256 }, + { "key": "AAAAAw==", "sizeHintBytes": 128 } + ] + } + } + }, + "results": [ + { + "xdr": "AAAABQ==", + "auth": [] + } + ], + "events": [ + "AAAAEAAAAAEAAAABAAAADgAAAAh0ZXN0X2tleQAAAA4AAAAJZ29vZF92YWw=", + "AAAAEAAAAAEAAAABAAAADgAAAAh0ZXN0X2tleTIAAAA4AAAACWJ1ZF92YWwy" + ], + "returnValue": "success_value" + } +}