From 9c135e9b9e2ccda4389894e43eaa1671b1be4c28 Mon Sep 17 00:00:00 2001 From: Awoo Date: Thu, 20 Aug 2026 20:20:46 +0000 Subject: [PATCH 1/3] feat: add Soroban cost estimation and economic analysis engine Adds a cost command family (estimate, compare, budget, export, check-regression) built on a deterministic fee/resource model for deploy, invoke, storage, archival, event, and batch operations. A simulation adapter normalizes raw Soroban RPC simulateTransaction responses into a stable resource-usage structure, and an optional narrative layer explains cost drivers using a configured language-model provider, falling back to a deterministic summary when unavailable. Estimates persist as versioned, permission-restricted JSON snapshots per label, enabling trend export and CI-style regression threshold checks. --- src/commands/command_tree.rs | 14 + src/commands/cost/adapter.rs | 237 ++++++++ src/commands/cost/explain.rs | 222 +++++++ src/commands/cost/history.rs | 376 ++++++++++++ src/commands/cost/mod.rs | 575 ++++++++++++++++++ src/commands/cost/model.rs | 486 +++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 8 + tests/cost_estimate_cli.rs | 456 ++++++++++++++ .../simulate_cost_with_footprint.json | 35 ++ 10 files changed, 2410 insertions(+) create mode 100644 src/commands/cost/adapter.rs create mode 100644 src/commands/cost/explain.rs create mode 100644 src/commands/cost/history.rs create mode 100644 src/commands/cost/mod.rs create mode 100644 src/commands/cost/model.rs create mode 100644 tests/cost_estimate_cli.rs create mode 100644 tests/fixtures/soroban_rpc/simulate_cost_with_footprint.json 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..c82965e --- /dev/null +++ b/src/commands/cost/history.rs @@ -0,0 +1,376 @@ +//! 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/