From 24c538da4d1bc6f89455d4b6921ec73cd7f6c1ad Mon Sep 17 00:00:00 2001 From: Kayce10 Date: Thu, 23 Jul 2026 15:55:59 +0000 Subject: [PATCH] feat(#504): add AI contract debugging assistant Implements the AI contract debugging assistant as described in issue #504. Changes: - src/utils/ai_debugger.rs: Rule-based AI engine with 10 Soroban error patterns (auth, arithmetic, storage, token, panic, WASM, network, TTL, test failures, type/ABI mismatches), stack trace parser, variable state inspector, and 8 unit tests covering all major code paths. - src/commands/ai_debug.rs: CLI command with four subcommands: analyse - analyse error + optional stack trace + variable state explain - explain a named error category in depth inspect - inspect name=value variable pairs for suspicious values test - analyse cargo test failure output and suggest fixes - src/commands/mod.rs: registered pub mod ai_debug - src/utils/mod.rs: registered pub mod ai_debugger - src/main.rs: added AiDebug variant to Commands enum, command_name match arm, result dispatch arm, and recovery hints - src/utils/horizon.rs: fixed pre-existing merge artifact (duplicated fetch_account body using removed ureq crate causing unclosed delimiter) Acceptance criteria met: Error explanations clear (DebugFinding.explanation field) Bug identification accurate (10 pattern matchers with deduplication) Fix suggestions working (DebugFinding.fix_suggestion field) Root cause analysis helpful (DebugFinding.root_cause field) Integration with tests (ai-debug test subcommand) --- src/commands/ai_debug.rs | 365 +++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 11 + src/utils/ai_debugger.rs | 746 +++++++++++++++++++++++++++++++++++++++ src/utils/horizon.rs | 12 - src/utils/mod.rs | 1 + 6 files changed, 1124 insertions(+), 12 deletions(-) create mode 100644 src/commands/ai_debug.rs create mode 100644 src/utils/ai_debugger.rs diff --git a/src/commands/ai_debug.rs b/src/commands/ai_debug.rs new file mode 100644 index 00000000..db15dd17 --- /dev/null +++ b/src/commands/ai_debug.rs @@ -0,0 +1,365 @@ +//! `starforge ai-debug` — AI Contract Debugging Assistant. +//! +//! Analyses Soroban contract errors, stack traces, and variable state to +//! provide clear explanations, root-cause analysis, bug identification, +//! fix suggestions, and guided reproduction steps. +//! +//! ## Sub-commands +//! - `analyse` — Analyse an error message and/or stack trace +//! - `explain` — Explain a specific error code or category +//! - `inspect` — Inspect variable state for suspicious values +//! - `test` — Analyse test failure output and suggest fixes + +use crate::utils::ai_debugger::{self, Severity}; +use crate::utils::print as p; +use anyhow::Result; +use clap::{Args, Subcommand}; +use colored::*; +use std::fs; +use std::path::PathBuf; + +// ── Sub-command enum ────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub enum AiDebugCommands { + /// Analyse a contract error message, with optional stack trace and variables + Analyse(AnalyseArgs), + /// Explain a known error category (auth, arithmetic, storage, token, wasm, ttl, type) + Explain(ExplainArgs), + /// Inspect variable state for potential bugs (pass name=value pairs) + Inspect(InspectArgs), + /// Analyse test failure output and suggest fixes + Test(TestArgs), +} + +// ── Analyse sub-command ─────────────────────────────────────────────────────── + +#[derive(Args)] +pub struct AnalyseArgs { + /// The error message to analyse (quote the full message) + pub error: String, + + /// Raw stack trace string (optional; use quotes or --stack-trace-file) + #[arg(long)] + pub stack_trace: Option, + + /// Path to a file containing the stack trace + #[arg(long)] + pub stack_trace_file: Option, + + /// Variable name=value pairs for state inspection (e.g. amount=0 caller=None) + #[arg(long = "var", value_name = "NAME=VALUE", num_args = 1..)] + pub variables: Vec, + + /// Output format: text | json + #[arg(long, default_value = "text", value_parser = ["text", "json"])] + pub format: String, +} + +// ── Explain sub-command ─────────────────────────────────────────────────────── + +#[derive(Args)] +pub struct ExplainArgs { + /// Category to explain: auth | arithmetic | storage | token | panic | wasm | network | ttl | test | type + pub category: String, +} + +// ── Inspect sub-command ─────────────────────────────────────────────────────── + +#[derive(Args)] +pub struct InspectArgs { + /// Variable name=value pairs to inspect (e.g. amount=0 balance=9999) + #[arg(required = true, value_name = "NAME=VALUE", num_args = 1..)] + pub variables: Vec, +} + +// ── Test sub-command ────────────────────────────────────────────────────────── + +#[derive(Args)] +pub struct TestArgs { + /// The test failure output to analyse (quote the full output) + pub output: Option, + + /// Path to a file containing test output (alternative to inline output) + #[arg(long)] + pub file: Option, + + /// Also provide the originating error message for combined analysis + #[arg(long)] + pub error: Option, +} + +// ── Top-level handler ───────────────────────────────────────────────────────── + +pub async fn handle(cmd: AiDebugCommands) -> Result<()> { + match cmd { + AiDebugCommands::Analyse(args) => handle_analyse(args).await, + AiDebugCommands::Explain(args) => handle_explain(args).await, + AiDebugCommands::Inspect(args) => handle_inspect(args).await, + AiDebugCommands::Test(args) => handle_test(args).await, + } +} + +// ── analyse handler ─────────────────────────────────────────────────────────── + +async fn handle_analyse(args: AnalyseArgs) -> Result<()> { + // Resolve stack trace from inline string or file + let stack_trace_owned: Option = if let Some(file) = args.stack_trace_file { + Some(fs::read_to_string(&file).map_err(|e| { + anyhow::anyhow!("Could not read stack trace file {}: {}", file.display(), e) + })?) + } else { + args.stack_trace + }; + + // Parse name=value variable pairs + let variables = parse_variables(&args.variables)?; + let vars_ref: Vec<(String, String)> = variables; + + let report = ai_debugger::analyse( + &args.error, + stack_trace_owned.as_deref(), + if vars_ref.is_empty() { None } else { Some(&vars_ref) }, + None, + ); + + if args.format == "json" { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + print_report(&report); + Ok(()) +} + +// ── explain handler ─────────────────────────────────────────────────────────── + +async fn handle_explain(args: ExplainArgs) -> Result<()> { + // Map category to a synthetic error message that will trigger the right pattern + let synthetic_error = match args.category.to_lowercase().as_str() { + "auth" | "authorization" => "require_auth failed", + "arithmetic" | "overflow" | "underflow" => "attempt to add with overflow", + "storage" | "store" => "storage key not found", + "token" | "balance" => "insufficient balance for transfer", + "panic" => "called `option::unwrap` on a `none` value", + "wasm" | "binary" => "invalid wasm binary", + "network" | "contract" => "contract not found on network", + "ttl" | "archival" => "entry expired ttl elapsed", + "test" | "assert" => "assertion failed left right", + "type" | "abi" | "xdr" => "xdr type conversion mismatch", + other => anyhow::bail!( + "Unknown category '{}'. Valid categories: auth, arithmetic, storage, token, panic, wasm, network, ttl, test, type", + other + ), + }; + + let report = ai_debugger::analyse(synthetic_error, None, None, None); + + p::header("AI Debugger — Category Explanation"); + p::kv("Category", &args.category); + p::separator(); + + if report.findings.is_empty() { + p::warn("No detailed explanation available for this category."); + return Ok(()); + } + + for finding in &report.findings { + print_finding(finding, true); + } + Ok(()) +} + +// ── inspect handler ─────────────────────────────────────────────────────────── + +async fn handle_inspect(args: InspectArgs) -> Result<()> { + let variables = parse_variables(&args.variables)?; + + p::header("AI Debugger — Variable State Inspection"); + p::separator(); + + let insights = ai_debugger::inspect_variable_state(&variables); + for insight in &insights { + println!(" {}", insight.bright_white()); + } + println!(); + Ok(()) +} + +// ── test handler ───────────────────────────────────────────────────────────── + +async fn handle_test(args: TestArgs) -> Result<()> { + // Resolve test output from inline or file + let output: String = if let Some(file) = args.file { + fs::read_to_string(&file) + .map_err(|e| anyhow::anyhow!("Could not read file {}: {}", file.display(), e))? + } else if let Some(out) = args.output { + out + } else { + anyhow::bail!("Provide test output inline or via --file "); + }; + + let error_msg = args.error.as_deref().unwrap_or("test failure"); + + let report = ai_debugger::analyse(error_msg, None, None, Some(&output)); + + p::header("AI Debugger — Test Failure Analysis"); + p::separator(); + + if let Some(ref analysis) = report.test_failure_analysis { + println!("\n {}", "Test Analysis:".yellow().bold()); + println!(" {}\n", analysis.bright_white()); + } + + if !report.findings.is_empty() { + println!(" {}", "Related Findings:".yellow().bold()); + for finding in &report.findings { + print_finding(finding, false); + } + } + + println!(" {}", "Guidance:".yellow().bold()); + println!(" {}\n", report.overall_guidance.bright_white()); + Ok(()) +} + +// ── Display helpers ─────────────────────────────────────────────────────────── + +fn print_report(report: &ai_debugger::DebugReport) { + p::header("AI Contract Debugging Assistant"); + p::kv("Input", &report.input_summary); + p::separator(); + + if report.findings.is_empty() { + p::warn("No specific issue pattern matched. See general guidance below."); + } else { + println!( + "\n {} {}\n", + "Findings:".yellow().bold(), + format!("({})", report.findings.len()).dimmed() + ); + for finding in &report.findings { + print_finding(finding, true); + } + } + + // Variable insights + if !report.variable_insights.is_empty() { + println!(" {}", "Variable State Insights:".yellow().bold()); + for insight in &report.variable_insights { + println!(" {}", insight.bright_white()); + } + println!(); + } + + // Suggested breakpoints + if !report.suggested_breakpoints.is_empty() { + println!(" {}", "Suggested Breakpoints:".cyan().bold()); + for bp in &report.suggested_breakpoints { + println!(" {} {}", "→".cyan(), bp); + } + println!(); + } + + // Overall guidance + println!(" {}", "Guidance:".yellow().bold()); + println!(" {}\n", report.overall_guidance.bright_white()); +} + +fn print_finding(finding: &ai_debugger::DebugFinding, verbose: bool) { + let sev_color = match finding.severity { + Severity::Critical => finding.severity.label().red().bold(), + Severity::High => finding.severity.label().yellow().bold(), + Severity::Medium => finding.severity.label().bright_yellow().bold(), + Severity::Low => finding.severity.label().cyan().bold(), + Severity::Info => finding.severity.label().white().bold(), + }; + + println!( + " [{}] {} — {}", + sev_color, + finding.id.bright_white().bold(), + finding.title.bright_white() + ); + println!(" {} {}", "Category:".dimmed(), finding.category); + println!(); + + println!(" {}", "Explanation:".bright_white().underline()); + wrap_print(&finding.explanation, 80, " "); + println!(); + + println!(" {}", "Root Cause:".bright_white().underline()); + wrap_print(&finding.root_cause, 80, " "); + println!(); + + println!(" {}", "Fix Suggestion:".green().underline()); + wrap_print(&finding.fix_suggestion, 80, " "); + println!(); + + if verbose { + if !finding.reproduction_steps.is_empty() { + println!(" {}", "Reproduction Steps:".bright_white().underline()); + for (i, step) in finding.reproduction_steps.iter().enumerate() { + println!(" {}. {}", i + 1, step); + } + println!(); + } + + if !finding.breakpoint_hints.is_empty() { + println!(" {}", "Breakpoint Hints:".cyan().underline()); + for hint in &finding.breakpoint_hints { + println!(" {} {}", "→".cyan(), hint); + } + println!(); + } + + if !finding.references.is_empty() { + println!(" {}", "References:".dimmed().underline()); + for r in &finding.references { + println!(" {}", r.dimmed()); + } + println!(); + } + } + + p::separator(); +} + +/// Naive word-wrap for long description strings. +fn wrap_print(text: &str, max_width: usize, indent: &str) { + let mut line_len = 0; + let mut current = String::new(); + for word in text.split_whitespace() { + if line_len + word.len() + 1 > max_width && !current.is_empty() { + println!("{}{}", indent, current); + current = word.to_string(); + line_len = word.len(); + } else { + if !current.is_empty() { + current.push(' '); + line_len += 1; + } + current.push_str(word); + line_len += word.len(); + } + } + if !current.is_empty() { + println!("{}{}", indent, current); + } +} + +/// Parse a list of "name=value" strings into tuples. +fn parse_variables(raw: &[String]) -> Result> { + raw.iter() + .map(|s| { + let mut parts = s.splitn(2, '='); + let name = parts + .next() + .filter(|n| !n.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Invalid variable format '{}': expected NAME=VALUE", s))? + .to_string(); + let value = parts.next().unwrap_or("").to_string(); + Ok((name, value)) + }) + .collect() +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4fb6c1e5..44309748 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod ai_debug; pub mod analytics; pub mod audit; pub mod backup; diff --git a/src/main.rs b/src/main.rs index e264fdb7..3bf34296 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,10 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// AI-powered contract debugging assistant (error analysis, bug identification, fix suggestions) + #[command(subcommand)] + AiDebug(commands::ai_debug::AiDebugCommands), + /// Manage test wallets (create, list, fund, show, remove) #[command(subcommand)] Wallet(commands::wallet::WalletCommands), @@ -217,6 +221,7 @@ async fn main() { } let command_name = match &cli.command { + Commands::AiDebug(_) => "ai-debug", Commands::Wallet(_) => "wallet", Commands::New(_) => "new", Commands::Contract(_) => "contract", @@ -264,6 +269,7 @@ async fn main() { let start = std::time::Instant::now(); let result = match cli.command { + Commands::AiDebug(cmd) => commands::ai_debug::handle(cmd).await, Commands::Wallet(cmd) => commands::wallet::handle(cmd).await, Commands::New(cmd) => commands::new::handle(cmd).await, Commands::Contract(cmd) => commands::contract::handle(cmd).await, @@ -415,6 +421,11 @@ fn recovery_hints(command: &str, err: &anyhow::Error) -> Vec { hints.push("Check your internet connection and retry.".into()); } } + "ai-debug" => { + hints.push("Provide the full error message in quotes: starforge ai-debug analyse \"\"".into()); + hints.push("Explain a specific category: starforge ai-debug explain auth".into()); + hints.push("Available categories: auth, arithmetic, storage, token, panic, wasm, network, ttl, test, type".into()); + } "benchmark" | "test" => { if msg.contains("wasm") || msg.contains("not found") { hints.push("Build your contract first: stellar contract build".into()); diff --git a/src/utils/ai_debugger.rs b/src/utils/ai_debugger.rs new file mode 100644 index 00000000..2a83f697 --- /dev/null +++ b/src/utils/ai_debugger.rs @@ -0,0 +1,746 @@ +//! AI Contract Debugging Assistant utility. +//! +//! Rule-based analysis engine that interprets Soroban contract errors, +//! identifies common bugs, suggests fixes, and provides root-cause explanations. +//! No external AI API required — all analysis is performed locally. + +use serde::{Deserialize, Serialize}; + +// ── Severity ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Critical, + High, + Medium, + Low, + Info, +} + +impl Severity { + pub fn label(&self) -> &'static str { + match self { + Severity::Critical => "CRITICAL", + Severity::High => "HIGH", + Severity::Medium => "MEDIUM", + Severity::Low => "LOW", + Severity::Info => "INFO", + } + } +} + +// ── Core result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugFinding { + pub id: String, + pub severity: Severity, + pub category: String, + pub title: String, + pub explanation: String, + pub root_cause: String, + pub fix_suggestion: String, + pub reproduction_steps: Vec, + pub breakpoint_hints: Vec, + pub references: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackTraceFrame { + pub frame_index: usize, + pub function: String, + pub location: Option, + pub context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugReport { + pub input_summary: String, + pub findings: Vec, + pub variable_insights: Vec, + pub test_failure_analysis: Option, + pub overall_guidance: String, + pub suggested_breakpoints: Vec, +} + +// ── Error pattern definitions ──────────────────────────────────────────────── + +struct ErrorPattern { + keywords: &'static [&'static str], + finding: fn() -> DebugFinding, +} + +fn pattern_auth_required() -> DebugFinding { + DebugFinding { + id: "AUTH001".into(), + severity: Severity::High, + category: "Authorization".into(), + title: "Missing or failed authorization check".into(), + explanation: "The contract called require_auth() or require_auth_for_args() and the \ + invoker did not satisfy the authorization requirements. This means the transaction \ + signer lacks the necessary privileges for this operation." + .into(), + root_cause: "The caller's address did not match the expected authorized address, \ + or the required signature was absent from the transaction envelope." + .into(), + fix_suggestion: "Ensure the correct account signs the transaction. If writing the \ + contract, verify that env.require_auth(&caller) is placed before any state \ + mutation, and that the caller argument matches the transaction's source account." + .into(), + reproduction_steps: vec![ + "Invoke the contract function with an unauthorized account.".into(), + "Observe the 'require_auth' error in the simulation result.".into(), + "Re-invoke using the correct authorized account.".into(), + ], + breakpoint_hints: vec![ + "Set a breakpoint at the require_auth() call site.".into(), + "Inspect the `caller` variable before the auth check.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/learn/smart-contract-internals/authorization".into(), + ], + } +} + +fn pattern_overflow() -> DebugFinding { + DebugFinding { + id: "ARITH001".into(), + severity: Severity::Critical, + category: "Arithmetic".into(), + title: "Integer overflow or underflow".into(), + explanation: "An arithmetic operation produced a value outside the valid range for \ + its integer type. In Soroban contracts, overflow panics by default in debug \ + builds and wraps silently in release builds unless checked arithmetic is used." + .into(), + root_cause: "A calculation exceeded i128/u128/i64/u64 bounds. Common causes: \ + token amounts added without bounds checking, loop counters, or fee calculations." + .into(), + fix_suggestion: "Replace raw arithmetic with checked_add / checked_sub / \ + checked_mul and return a contract error on None. Example: \ + `amount.checked_add(fee).ok_or(Error::Overflow)?`" + .into(), + reproduction_steps: vec![ + "Call the function with boundary values (e.g. i128::MAX for an amount).".into(), + "Observe the panic or wrap-around in the return value.".into(), + "Add checked arithmetic and verify the error is returned cleanly.".into(), + ], + breakpoint_hints: vec![ + "Set a breakpoint just before the arithmetic expression.".into(), + "Inspect operand values to confirm they are near type limits.".into(), + ], + references: vec![ + "https://doc.rust-lang.org/std/primitive.i128.html#method.checked_add".into(), + ], + } +} + +fn pattern_storage_missing() -> DebugFinding { + DebugFinding { + id: "STORE001".into(), + severity: Severity::Medium, + category: "Storage".into(), + title: "Contract storage key not found".into(), + explanation: "The contract attempted to read a storage entry that has not been \ + written yet, or the entry has expired (TTL elapsed). Soroban storage is \ + persistent, temporary, or instance-scoped, and each has different TTL rules." + .into(), + root_cause: "Either the contract was not initialized before calling this function, \ + or a persistent entry's ledger TTL expired and was archived." + .into(), + fix_suggestion: "Guard all storage reads with `env.storage().persistent().has(&key)` \ + before reading. Call an `initialize()` function to seed required state. \ + For persistent storage, extend TTL with \ + `env.storage().persistent().extend_ttl(&key, min, max)`." + .into(), + reproduction_steps: vec![ + "Deploy the contract fresh (no prior state).".into(), + "Call the function that reads storage without calling initialize() first.".into(), + "Observe the missing-key panic.".into(), + ], + breakpoint_hints: vec![ + "Break before the storage get() call.".into(), + "Use `starforge inspect` to view live storage state.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/learn/smart-contract-internals/persisting-data".into(), + ], + } +} + +fn pattern_insufficient_balance() -> DebugFinding { + DebugFinding { + id: "TOKEN001".into(), + severity: Severity::High, + category: "Token / Balance".into(), + title: "Insufficient token balance".into(), + explanation: "The account or contract does not hold enough tokens to complete \ + the requested transfer or operation. The Soroban token interface enforces \ + balance constraints at the host level." + .into(), + root_cause: "The sender's balance is less than the amount being transferred, \ + or fees were not accounted for when computing available balance." + .into(), + fix_suggestion: "Check the balance with `token_client.balance(&sender)` before \ + initiating a transfer. Add a guard: \ + `if balance < amount { return Err(Error::InsufficientFunds); }`" + .into(), + reproduction_steps: vec![ + "Attempt to transfer more tokens than the sender holds.".into(), + "Observe the error from the SEP-41 token contract.".into(), + "Fund the account and retry.".into(), + ], + breakpoint_hints: vec![ + "Inspect the `balance` variable before the transfer call.".into(), + "Log sender address and amount to confirm they are correct.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/tokens/token-interface".into(), + ], + } +} + +fn pattern_panic() -> DebugFinding { + DebugFinding { + id: "PANIC001".into(), + severity: Severity::High, + category: "Runtime Panic".into(), + title: "Contract execution panicked".into(), + explanation: "The Soroban host caught a Rust panic inside the contract. This \ + surfaces as a generic host error in simulation output. Panics abort the \ + entire invocation and roll back all state changes." + .into(), + root_cause: "Common sources: unwrap() on None/Err, index out of bounds, \ + failed assertion (assert! / panic!), or integer overflow in release mode." + .into(), + fix_suggestion: "Replace all `.unwrap()` calls with proper error handling using \ + `?` or `.ok_or(ContractError::...)`. Replace `assert!` with explicit \ + `if` guards that return a typed error. Enable `overflow-checks = true` \ + in Cargo.toml `[profile.release]`." + .into(), + reproduction_steps: vec![ + "Run the contract invocation via `starforge debug start`.".into(), + "Look for 'panicked at' in the error output or host logs.".into(), + "Identify the unwrap/assert site and replace with error propagation.".into(), + ], + breakpoint_hints: vec![ + "Set breakpoints before each unwrap() call.".into(), + "Inspect the Option/Result value before unwrapping.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/learn/smart-contract-internals/errors".into(), + ], + } +} + +fn pattern_wasm_invalid() -> DebugFinding { + DebugFinding { + id: "WASM001".into(), + severity: Severity::Critical, + category: "WASM / Build".into(), + title: "Invalid or corrupt WASM binary".into(), + explanation: "The WASM file could not be parsed or validated by the Soroban host. \ + This can happen if the file is truncated, built with incompatible settings, \ + or is not a Soroban contract at all." + .into(), + root_cause: "The binary was not compiled with `--target wasm32-unknown-unknown`, \ + or build artifacts are stale after a `cargo clean`." + .into(), + fix_suggestion: "Rebuild from source: `stellar contract build`. Verify the output \ + path ends in `.wasm`. Run `wasm-validate ` to confirm the binary is \ + valid WebAssembly. Use `--optimize` for production deployments." + .into(), + reproduction_steps: vec![ + "Attempt to deploy or simulate the suspect WASM file.".into(), + "Note the 'invalid wasm' error from the host.".into(), + "Run `stellar contract build` and retry with the fresh binary.".into(), + ], + breakpoint_hints: vec![ + "Check file size — a valid Soroban WASM is rarely under 1 KB.".into(), + "Run `xxd | head` to verify the \\0asm magic header.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/build/smart-contracts/getting-started/setup".into(), + ], + } +} + +fn pattern_contract_not_found() -> DebugFinding { + DebugFinding { + id: "NET001".into(), + severity: Severity::High, + category: "Network / Contract".into(), + title: "Contract not found on network".into(), + explanation: "The contract ID supplied does not exist on the target network, or \ + the contract has been deleted / archived. This produces a 'not found' error \ + during simulation or invocation." + .into(), + root_cause: "Wrong network selected (e.g. using a testnet ID on mainnet), \ + the contract was never deployed, or the deployment used a different account \ + than expected." + .into(), + fix_suggestion: "Verify the active network with `starforge network show`. \ + Confirm the contract ID by inspecting recent deployments: \ + `starforge deployments list`. Redeploy if necessary." + .into(), + reproduction_steps: vec![ + "Try to invoke the contract using its ID.".into(), + "Observe the 'contract not found' error.".into(), + "Switch to the correct network and retry.".into(), + ], + breakpoint_hints: vec![ + "Print the contract ID and network before invocation.".into(), + "Use `starforge contract inspect ` to verify existence.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/data/rpc/api-reference/methods/getLedgerEntries".into(), + ], + } +} + +fn pattern_ttl_expired() -> DebugFinding { + DebugFinding { + id: "TTL001".into(), + severity: Severity::Medium, + category: "Storage / TTL".into(), + title: "Storage entry TTL has expired".into(), + explanation: "A persistent or temporary storage entry was not accessed or extended \ + within its time-to-live window and has been archived by the Soroban host. \ + Archived entries cannot be read until restored." + .into(), + root_cause: "The contract did not call `extend_ttl` frequently enough. \ + High-throughput contracts that go quiet for many ledgers are at risk." + .into(), + fix_suggestion: "In read-heavy functions, proactively extend TTL: \ + `env.storage().persistent().extend_ttl(&key, MIN_TTL, MAX_TTL)`. \ + Consider a background keeper job that touches keys regularly." + .into(), + reproduction_steps: vec![ + "Deploy a contract and write a persistent entry.".into(), + "Advance the ledger past the TTL (use sandbox time controls).".into(), + "Read the entry and observe the 'entry archived' error.".into(), + ], + breakpoint_hints: vec![ + "Inspect `env.ledger().sequence()` relative to the entry's TTL.".into(), + "Log TTL values after each extend_ttl call.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/learn/smart-contract-internals/state-archival".into(), + ], + } +} + +fn pattern_test_failure() -> DebugFinding { + DebugFinding { + id: "TEST001".into(), + severity: Severity::Medium, + category: "Test Failure".into(), + title: "Contract test assertion failed".into(), + explanation: "A Rust test for this contract failed. The test expected a specific \ + return value or state but the contract produced different output. This may \ + indicate a logic error, incorrect mock setup, or a changed function signature." + .into(), + root_cause: "The most common causes are: wrong argument ordering, state not reset \ + between test cases, or a business logic condition that was not accounted for \ + in the test setup." + .into(), + fix_suggestion: "Run `cargo test -- --nocapture` to see full output. Add \ + `println!` or `eprintln!` inside the contract (debug builds only) to trace \ + values. Verify the `TestClient` is constructed from a fresh `Env::default()` \ + for each test." + .into(), + reproduction_steps: vec![ + "Run `cargo test ` in the contract crate.".into(), + "Inspect the 'left' and 'right' values in the assertion output.".into(), + "Add intermediate assertions to isolate the diverging value.".into(), + ], + breakpoint_hints: vec![ + "Break at the function entry inside the contract under test.".into(), + "Inspect all arguments passed by the test client.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/build/smart-contracts/example-contracts/hello-world#writing-tests".into(), + ], + } +} + +fn pattern_type_mismatch() -> DebugFinding { + DebugFinding { + id: "TYPE001".into(), + severity: Severity::Medium, + category: "Type / ABI".into(), + title: "Argument type mismatch or ABI incompatibility".into(), + explanation: "The supplied argument could not be converted to the type expected by \ + the contract function. Soroban performs strict XDR type-checking at the host \ + boundary." + .into(), + root_cause: "A caller passed a string where an address was expected, used the \ + wrong numeric type (i64 vs u128), or the contract was upgraded with a \ + different function signature than the client was compiled against." + .into(), + fix_suggestion: "Check the contract ABI with `starforge contract inspect `. \ + Regenerate language bindings with `starforge contract generate-bindings`. \ + Ensure the types in your invocation match the contract's `#[contracttype]` \ + definitions." + .into(), + reproduction_steps: vec![ + "Invoke the contract with a mismatched argument type.".into(), + "Observe the XDR conversion error.".into(), + "Correct the argument type and reinvoke.".into(), + ], + breakpoint_hints: vec![ + "Log the raw XDR of each argument before invocation.".into(), + "Compare argument types against the contract source or ABI spec.".into(), + ], + references: vec![ + "https://developers.stellar.org/docs/learn/smart-contract-internals/contract-interactions/cross-contract".into(), + ], + } +} + +// ── Pattern registry ───────────────────────────────────────────────────────── + +fn all_patterns() -> Vec { + vec![ + ErrorPattern { + keywords: &["require_auth", "auth", "unauthorized", "not authorized", "auth failed"], + finding: pattern_auth_required, + }, + ErrorPattern { + keywords: &["overflow", "underflow", "attempt to add with overflow", "attempt to subtract with overflow", "attempt to multiply with overflow"], + finding: pattern_overflow, + }, + ErrorPattern { + keywords: &["not found", "missing key", "storage", "no entry", "key not found", "missing storage"], + finding: pattern_storage_missing, + }, + ErrorPattern { + keywords: &["insufficient", "balance", "not enough", "insufficient funds", "insufficient balance"], + finding: pattern_insufficient_balance, + }, + ErrorPattern { + keywords: &["panic", "panicked", "unwrap", "called `option::unwrap` on a `none`", "called `result::unwrap` on an `err`"], + finding: pattern_panic, + }, + ErrorPattern { + keywords: &["invalid wasm", "wasm", "webassembly", "binary", "magic", "malformed"], + finding: pattern_wasm_invalid, + }, + ErrorPattern { + keywords: &["contract not found", "no contract", "does not exist", "ledger entry not found"], + finding: pattern_contract_not_found, + }, + ErrorPattern { + keywords: &["ttl", "expired", "archived", "state archival", "entry expired"], + finding: pattern_ttl_expired, + }, + ErrorPattern { + keywords: &["test", "assert", "assertion", "expected", "left =", "right =", "#[test]", "failed"], + finding: pattern_test_failure, + }, + ErrorPattern { + keywords: &["type", "abi", "xdr", "conversion", "mismatch", "invalid argument", "wrong type"], + finding: pattern_type_mismatch, + }, + ] +} + +// ── Stack trace parser ──────────────────────────────────────────────────────── + +/// Parse a raw stack trace string into structured frames. +pub fn parse_stack_trace(trace: &str) -> Vec { + let mut frames = Vec::new(); + for (i, line) in trace.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Try to parse lines like " 0: function_name at src/lib.rs:42" + let (function, location) = if let Some(at_pos) = line.find(" at ") { + let func_part = line[..at_pos].trim_start_matches(|c: char| c.is_ascii_digit() || c == ':' || c == ' '); + let loc_part = &line[at_pos + 4..]; + (func_part.to_string(), Some(loc_part.to_string())) + } else { + let func_part = line.trim_start_matches(|c: char| c.is_ascii_digit() || c == ':' || c == ' '); + (func_part.to_string(), None) + }; + + if !function.is_empty() { + frames.push(StackTraceFrame { + frame_index: i, + function, + location, + context: None, + }); + } + } + frames +} + +// ── Variable state inspector ────────────────────────────────────────────────── + +/// Produce human-readable insights about variable values passed as name=value pairs. +pub fn inspect_variable_state(variables: &[(String, String)]) -> Vec { + let mut insights = Vec::new(); + for (name, value) in variables { + let name_lower = name.to_lowercase(); + let value_lower = value.to_lowercase(); + + // Detect potential zero-value bugs + if value == "0" || value == "0i128" || value == "0u128" { + if name_lower.contains("amount") || name_lower.contains("balance") || name_lower.contains("fee") { + insights.push(format!( + "⚠ '{}' is zero — confirm this is intentional for a value-carrying field.", + name + )); + } + } + + // Detect max-value boundary conditions + if value.contains("170141183460469231731687303715884105727") + || value.contains("i128::MAX") + || value.contains("9223372036854775807") + { + insights.push(format!( + "⚠ '{}' is at or near integer maximum — arithmetic on this value will overflow.", + name + )); + } + + // Detect empty / null-like address + if name_lower.contains("address") || name_lower.contains("account") { + if value_lower.contains("none") || value == "\"\"" || value.is_empty() { + insights.push(format!( + "✗ '{}' is empty or None — the contract will likely fail auth checks.", + name + )); + } + } + + // Detect very large collections + if name_lower.contains("vec") || name_lower.contains("map") || name_lower.contains("list") { + if let Ok(n) = value.trim_matches(|c: char| !c.is_ascii_digit()).parse::() { + if n > 1000 { + insights.push(format!( + "ℹ '{}' has {} elements — consider gas cost implications for large collections.", + name, n + )); + } + } + } + } + + if insights.is_empty() { + insights.push("No suspicious variable states detected.".into()); + } + insights +} + +// ── Core analysis engine ────────────────────────────────────────────────────── + +/// Analyse an error message against all known patterns and return matching findings. +fn analyse_error_message(error_msg: &str) -> Vec { + let lower = error_msg.to_lowercase(); + let patterns = all_patterns(); + let mut findings = Vec::new(); + + for pattern in &patterns { + if pattern.keywords.iter().any(|kw| lower.contains(kw)) { + findings.push((pattern.finding)()); + } + } + + // Deduplicate by id + findings.dedup_by_key(|f| f.id.clone()); + findings +} + +/// Build the overall guidance string from findings. +fn build_overall_guidance(findings: &[DebugFinding], has_stack_trace: bool) -> String { + if findings.is_empty() { + let mut msg = "No specific pattern matched the provided input. \ + Try the following general debugging steps:\n\ + 1. Run `cargo test -- --nocapture` for detailed output.\n\ + 2. Use `starforge debug start --wasm ` to step through execution.\n\ + 3. Check `starforge audit ` for static analysis findings." + .to_string(); + if !has_stack_trace { + msg.push_str( + "\n4. Provide a stack trace with --stack-trace for deeper analysis.", + ); + } + return msg; + } + + let critical = findings.iter().filter(|f| f.severity == Severity::Critical).count(); + let high = findings.iter().filter(|f| f.severity == Severity::High).count(); + + let priority = if critical > 0 { + format!("{} critical issue(s) require immediate attention.", critical) + } else if high > 0 { + format!("{} high-severity issue(s) detected.", high) + } else { + "Issues detected are medium or low severity.".into() + }; + + format!( + "{} Address findings in order of severity (CRITICAL → HIGH → MEDIUM). \ + Each finding includes a fix suggestion and reproduction steps to verify the fix.", + priority + ) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Analyse a contract error, optional stack trace, and optional variable state. +/// +/// Returns a full [`DebugReport`] with findings, guidance, and breakpoint hints. +pub fn analyse( + error_message: &str, + stack_trace: Option<&str>, + variables: Option<&[(String, String)]>, + test_output: Option<&str>, +) -> DebugReport { + let input_summary = format!( + "Error: {}{}{}", + &error_message[..error_message.len().min(120)], + if stack_trace.is_some() { " | Stack trace provided" } else { "" }, + if variables.map(|v| !v.is_empty()).unwrap_or(false) { " | Variables provided" } else { "" }, + ); + + // 1. Error pattern matching + let mut findings = analyse_error_message(error_message); + + // 2. Stack trace analysis — augment findings with source locations + let stack_frames = stack_trace.map(parse_stack_trace).unwrap_or_default(); + if !stack_frames.is_empty() && findings.is_empty() { + // No error message match — scan frames for known trouble spots + let frame_text: String = stack_frames + .iter() + .map(|f| format!("{} {}", f.function, f.location.as_deref().unwrap_or(""))) + .collect::>() + .join("\n"); + findings = analyse_error_message(&frame_text); + } + + // 3. Test output analysis + let test_failure_analysis = test_output.map(|output| { + let lower = output.to_lowercase(); + let extra_findings = analyse_error_message(&lower); + if extra_findings.is_empty() { + // Try to extract assertion details + let left = extract_between(output, "left: `", "`"); + let right = extract_between(output, "right: `", "`"); + match (left, right) { + (Some(l), Some(r)) => format!( + "Test assertion failed: expected `{}` but got `{}`. \ + Check the logic path that produces the actual value.", + r, l + ), + _ => "Test failed. Run with --nocapture for full output.".into(), + } + } else { + format!( + "Test output matched {} known issue pattern(s). See findings for details.", + extra_findings.len() + ) + } + }); + + // 4. Variable state inspection + let variable_insights = variables + .map(inspect_variable_state) + .unwrap_or_default(); + + // 5. Collect all suggested breakpoints + let suggested_breakpoints: Vec = findings + .iter() + .flat_map(|f| f.breakpoint_hints.iter().cloned()) + .collect(); + + let overall_guidance = build_overall_guidance(&findings, stack_trace.is_some()); + + DebugReport { + input_summary, + findings, + variable_insights, + test_failure_analysis, + overall_guidance, + suggested_breakpoints, + } +} + +/// Helper to extract a substring between two delimiters. +fn extract_between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> { + let start_pos = s.find(start)? + start.len(); + let end_pos = s[start_pos..].find(end)? + start_pos; + Some(&s[start_pos..end_pos]) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_auth_error() { + let report = analyse("Error: require_auth failed for address", None, None, None); + assert!(!report.findings.is_empty(), "should detect auth finding"); + assert_eq!(report.findings[0].id, "AUTH001"); + } + + #[test] + fn detects_overflow_error() { + let report = analyse("attempt to add with overflow in balance calculation", None, None, None); + assert!(report.findings.iter().any(|f| f.id == "ARITH001")); + } + + #[test] + fn detects_storage_missing() { + let report = analyse("storage key not found: DataKey::Balance", None, None, None); + assert!(report.findings.iter().any(|f| f.id == "STORE001")); + } + + #[test] + fn no_match_returns_general_guidance() { + let report = analyse("some completely unknown error xyz123", None, None, None); + assert!(report.findings.is_empty()); + assert!(report.overall_guidance.contains("general debugging")); + } + + #[test] + fn parses_stack_trace_frames() { + let trace = " 0: contract::transfer at src/lib.rs:42\n 1: host::invoke at host.rs:10"; + let frames = parse_stack_trace(trace); + assert_eq!(frames.len(), 2); + assert!(frames[0].function.contains("contract::transfer")); + assert!(frames[0].location.as_deref().unwrap_or("").contains("src/lib.rs:42")); + } + + #[test] + fn variable_inspection_flags_zero_amount() { + let vars = vec![("amount".to_string(), "0".to_string())]; + let insights = inspect_variable_state(&vars); + assert!(insights.iter().any(|i| i.contains("zero"))); + } + + #[test] + fn variable_inspection_ok_for_normal_values() { + let vars = vec![("amount".to_string(), "1000".to_string())]; + let insights = inspect_variable_state(&vars); + assert_eq!(insights[0], "No suspicious variable states detected."); + } + + #[test] + fn test_failure_analysis_extracts_assertion_values() { + let output = "thread 'main' panicked: assertion `left == right` failed\n left: `42`\n right: `100`"; + let report = analyse("assertion failed", None, None, Some(output)); + assert!(report.test_failure_analysis.is_some()); + } + + #[test] + fn report_lists_breakpoint_hints_for_findings() { + let report = analyse("require_auth failed", None, None, None); + assert!(!report.suggested_breakpoints.is_empty()); + } +} diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index b8974808..fd878066 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -101,18 +101,6 @@ pub async fn fund_account(public_key: &str, network: &str) -> Result<()> { pub async fn fetch_account(public_key: &str, network: &str) -> Result { let horizon = horizon_url(network)?; let url = format!("{}/accounts/{}", horizon, public_key); - let res = ureq::get(&url) - .call() - .with_context(|| { - format!( - "Could not reach Horizon on '{}'. Check your internet connection or run: starforge network test", - network - ) - })?; - if res.status() == 200 { - let account: AccountResponse = res - .into_json() - .with_context(|| "Failed to parse account response from Horizon")?; let res = HTTP_CLIENT .get(&url) .send() diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 84c97255..36e0a750 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod ai_debugger; pub mod approval_engine; pub mod audit; pub mod security_scanner;