From e0cdda554b76c3491c3f63636a621f73659aeeb1 Mon Sep 17 00:00:00 2001 From: Chibuikem Michael Ilonze Date: Tue, 26 May 2026 20:00:43 +0100 Subject: [PATCH] feat: add gas diff deploy execute repl history and inspect json --- API_REFERENCE.md | 57 +++++++++++++++++++++++++ src/commands/contract.rs | 15 ++++++- src/commands/deploy.rs | 70 +++++++++++++++++++++++++++++++ src/commands/gas.rs | 75 +++++++++++++++++++++++++++++++++ src/commands/info.rs | 41 ++++++++++++++---- src/commands/shell.rs | 12 +++++- src/utils/profiler.rs | 4 +- src/utils/repl.rs | 89 +++++++++++++++++++++++++++++++++++++++- src/utils/soroban.rs | 13 +++--- 9 files changed, 354 insertions(+), 22 deletions(-) diff --git a/API_REFERENCE.md b/API_REFERENCE.md index a2ee7bb8..b39cd431 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -624,12 +624,23 @@ starforge contract inspect [OPTIONS] **Options:** - `--network ` - Network to use +- `--json` - Print machine-readable JSON output **Example:** ```bash starforge contract inspect CCPYZFKEAXHHS5VVW5J45TOU7S2EODJ7TZNJIA5LKDVL3PESCES6FNCI ``` +**JSON schema (`--json`):** +- `contract_id` (string) +- `executable` (string) +- `wasm_hash` (string|null) +- `storage_durability` (string) +- `latest_ledger` (number) +- `last_modified_ledger_seq` (number|null) +- `live_until_ledger_seq` (number|null) +- `instance_storage` (array of objects): `{ "key": string, "value": string }` + --- ### `starforge deploy` @@ -646,6 +657,7 @@ starforge deploy --wasm [OPTIONS] - `--network ` - Network to deploy to (`testnet`, `mainnet`) - `--wallet ` - Wallet name to use for deployment - `--yes` - Skip confirmation prompt +- `--execute` - Execute `stellar contract deploy ...` when `stellar` CLI is on PATH (default is dry-run) **Examples:** ```bash @@ -660,6 +672,9 @@ starforge deploy \ # Skip confirmation (for CI) starforge deploy --wasm ./my_contract.wasm --yes + +# Execute immediately (requires stellar CLI on PATH) +starforge deploy --wasm ./my_contract.wasm --execute ``` --- @@ -868,6 +883,8 @@ starforge shell --contract **Options:** - `--contract ` - Path to compiled contract +- `--no-history` - Disable persistent history for this session +- `--history-max-lines ` - Max lines to keep in `~/.starforge/repl_history` (default: 1000) **Example:** ```bash @@ -955,6 +972,46 @@ starforge gas optimize --target --output - `--target ` - Input wasm file (required) - `--output ` - Output wasm file (required) +#### `starforge gas diff` + +Compare two wasm builds side-by-side and diff estimated simulation cost. + +**Usage:** +```bash +starforge gas diff +``` + +**Arguments:** +- `` - Baseline wasm file +- `` - Candidate wasm file + +**Output includes:** +- Old/new wasm size +- Old/new estimated simulation cost +- Delta and percentage change +- Profiling timings per analysis step + +--- + +### `starforge inspect storage` + +List decoded storage entries for a contract scope. + +**Usage:** +```bash +starforge inspect storage [OPTIONS] +``` + +**Options:** +- `--scope ` - `instance`, `persistent`, or `temporary` +- `--network ` - Network to use (`testnet`, `mainnet`) +- `--json` - Print machine-readable JSON output + +**JSON schema (`--json`):** +- `contract_id` (string) +- `scope` (string) +- `entries` (array of objects): `{ "key": string, "value": string }` + --- ### `starforge benchmark` diff --git a/src/commands/contract.rs b/src/commands/contract.rs index 2877aad3..991f3e7b 100644 --- a/src/commands/contract.rs +++ b/src/commands/contract.rs @@ -41,6 +41,9 @@ pub struct InspectArgs { /// Network to use; defaults to the global config network #[arg(long, value_parser = ["testnet", "mainnet"])] pub network: Option, + /// Output as JSON + #[arg(long)] + pub json: bool, } pub fn handle(cmd: ContractCommands) -> Result<()> { @@ -67,6 +70,11 @@ fn handle_inspect(args: InspectArgs) -> Result<()> { p::step(1, 1, "Querying contract instance from Soroban RPC…"); let inspect = soroban::inspect_contract(&args.contract_id, &network)?; + if args.json { + println!("{}", serde_json::to_string_pretty(&inspect)?); + return Ok(()); + } + println!(); p::kv_accent("Contract ID", &inspect.contract_id); p::kv("Executable", &inspect.executable); @@ -229,10 +237,13 @@ fn handle_invoke(args: InvokeArgs) -> Result<()> { if args.submit { if let Some(mut wallet) = wallet { println!(); - + if let Some(sk) = &wallet.secret_key { if sk.contains(':') { - let pwd = crypto::prompt_password(&format!("Enter password to decrypt wallet '{}'", wallet.name), false)?; + let pwd = crypto::prompt_password( + &format!("Enter password to decrypt wallet '{}'", wallet.name), + false, + )?; let plain_sk = crypto::decrypt_secret(&pwd, sk)?; wallet.secret_key = Some(plain_sk); } diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 87f30009..facc6f62 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,9 +1,11 @@ +use crate::commands::info; use crate::utils::{config, horizon, print as p}; use anyhow::Result; use clap::Args; use colored::*; use std::fs; use std::path::PathBuf; +use std::process::Command; const SOROBAN_WASM_LIMIT_KB: f64 = 128.0; @@ -21,6 +23,9 @@ pub struct DeployArgs { /// Skip confirmation prompt #[arg(long, default_value = "false")] pub yes: bool, + /// Execute deployment immediately if Stellar CLI is installed + #[arg(long, default_value = "false")] + pub execute: bool, } fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool { @@ -43,6 +48,19 @@ fn build_stellar_deploy_command(wasm: &std::path::Path, source: &str, network: & ) } +fn build_stellar_deploy_args(wasm: &std::path::Path, source: &str, network: &str) -> Vec { + vec![ + "contract".to_string(), + "deploy".to_string(), + "--wasm".to_string(), + wasm.display().to_string(), + "--source".to_string(), + source.to_string(), + "--network".to_string(), + network.to_string(), + ] +} + pub fn handle(args: DeployArgs) -> Result<()> { p::header("Deploy Soroban Contract"); @@ -163,6 +181,36 @@ pub fn handle(args: DeployArgs) -> Result<()> { println!(" {}", line.cyan()); } println!(); + if args.execute { + let stellar_path = info::detect_stellar_cli().ok_or_else(|| { + anyhow::anyhow!( + "Cannot execute deploy: Stellar CLI not found on PATH.\nInstall it from https://developers.stellar.org/docs/tools/stellar-cli" + ) + })?; + + p::info(&format!( + "Executing with Stellar CLI at {}", + stellar_path.display() + )); + let cmd_args = build_stellar_deploy_args(&args.wasm, &wallet.public_key, &args.network); + let output = Command::new(stellar_path).args(&cmd_args).output()?; + if output.status.success() { + p::success("Deployment command executed successfully."); + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + println!("{}", stdout.trim()); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "Stellar CLI deployment failed (exit: {}). {}", + output.status, + stderr.trim() + ); + } + } else { + p::info("Dry-run mode (default): command not executed. Use --execute to run it."); + } p::info("Install the Stellar CLI: https://developers.stellar.org/docs/tools/stellar-cli"); p::separator(); @@ -204,6 +252,28 @@ mod tests { assert!(command.contains("--network testnet")); } + #[test] + fn builds_expected_deploy_args() { + let args = build_stellar_deploy_args( + std::path::Path::new("target/release/token.wasm"), + "GABCDEF1234567890", + "testnet", + ); + assert_eq!( + args, + vec![ + "contract", + "deploy", + "--wasm", + "target/release/token.wasm", + "--source", + "GABCDEF1234567890", + "--network", + "testnet" + ] + ); + } + #[test] fn flags_large_wasm_sizes() { assert!(!is_wasm_above_size_limit(127.9)); diff --git a/src/commands/gas.rs b/src/commands/gas.rs index da15c880..de2d4d55 100644 --- a/src/commands/gas.rs +++ b/src/commands/gas.rs @@ -22,12 +22,20 @@ pub enum GasCommands { #[arg(long)] output: PathBuf, }, + /// Compare two wasm builds and diff estimated simulation costs + Diff { + /// Path to the baseline wasm + old_wasm: PathBuf, + /// Path to the candidate wasm + new_wasm: PathBuf, + }, } pub fn handle(cmd: GasCommands) -> Result<()> { match cmd { GasCommands::Analyze { wasm, network } => analyze(wasm, network), GasCommands::Optimize { target, output } => optimize(target, output), + GasCommands::Diff { old_wasm, new_wasm } => diff(old_wasm, new_wasm), } } @@ -81,3 +89,70 @@ fn optimize(target: PathBuf, output: PathBuf) -> Result<()> { p::kv("Duration", &format!("{:?}", elapsed)); Ok(()) } + +fn diff(old_wasm: PathBuf, new_wasm: PathBuf) -> Result<()> { + config::validate_file_path(&old_wasm, Some("wasm"))?; + config::validate_file_path(&new_wasm, Some("wasm"))?; + + p::header("Gas Diff"); + p::kv("Old wasm", &old_wasm.display().to_string()); + p::kv("New wasm", &new_wasm.display().to_string()); + + let mut profile = profiler::Profiler::start(); + let old_report = optimizer::analyze_wasm(&old_wasm)?; + profile.mark("analyze_old"); + let new_report = optimizer::analyze_wasm(&new_wasm)?; + profile.mark("analyze_new"); + + let old_est = estimate_simulation_cost(old_report.size_bytes); + let new_est = estimate_simulation_cost(new_report.size_bytes); + let delta = new_est as i64 - old_est as i64; + let pct = if old_est == 0 { + 0.0 + } else { + (delta as f64 / old_est as f64) * 100.0 + }; + + println!(); + p::separator(); + p::kv("Old size (bytes)", &old_report.size_bytes.to_string()); + p::kv("New size (bytes)", &new_report.size_bytes.to_string()); + p::kv("Old est. sim cost", &old_est.to_string()); + p::kv("New est. sim cost", &new_est.to_string()); + p::kv( + "Estimated delta", + &format!( + "{} ({:+.2}%)", + if delta >= 0 { + format!("+{}", delta) + } else { + delta.to_string() + }, + pct + ), + ); + p::kv( + "Result", + if delta < 0 { + "Improved (lower estimated cost)" + } else if delta > 0 { + "Regressed (higher estimated cost)" + } else { + "No change" + }, + ); + for point in profile.points() { + p::kv( + &format!("Step {}", point.label), + &format!("{:?}", point.elapsed), + ); + } + p::kv("Total profile", &format!("{:?}", profile.total_elapsed())); + p::separator(); + + Ok(()) +} + +fn estimate_simulation_cost(size_bytes: usize) -> u64 { + 2_000 + (size_bytes as u64 / 8) +} diff --git a/src/commands/info.rs b/src/commands/info.rs index 4a6ad897..97a42695 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -1,6 +1,7 @@ use crate::utils::{config, horizon, print as p}; use anyhow::Result; use colored::*; +use std::path::PathBuf; pub fn handle() -> Result<()> { p::header("starforge Environment"); @@ -8,10 +9,14 @@ pub fn handle() -> Result<()> { let cfg = config::load()?; - p::kv("Version", "0.1.0"); - p::kv("Config file", &config::config_path().display().to_string()); + p::kv("Version", "0.1.0"); + p::kv("Config file", &config::config_path().display().to_string()); p::kv_accent("Network", &cfg.network); p::kv("Wallets saved", &cfg.wallets.len().to_string()); + let stellar_status = detect_stellar_cli() + .map(|p| format!("installed ({})", p.display())) + .unwrap_or_else(|| "not found on PATH".to_string()); + p::kv("Stellar CLI", &stellar_status); println!(); p::info("Checking network connectivity…"); @@ -22,7 +27,11 @@ pub fn handle() -> Result<()> { " {} {:<10} {}", "◎".cyan(), net, - if online { "online".green().bold() } else { "unreachable".red() } + if online { + "online".green().bold() + } else { + "unreachable".red() + } ); } @@ -32,12 +41,12 @@ pub fn handle() -> Result<()> { println!(); let cmds = [ ("starforge wallet create ", "Create a new keypair"), - ("starforge wallet list", "List saved wallets"), - ("starforge wallet show ", "Show wallet + live balance"), - ("starforge wallet fund ", "Fund via Friendbot (testnet)"), + ("starforge wallet list", "List saved wallets"), + ("starforge wallet show ", "Show wallet + live balance"), + ("starforge wallet fund ", "Fund via Friendbot (testnet)"), ("starforge wallet remove ", "Remove a wallet"), - ("starforge new contract ", "Scaffold a Soroban contract"), - ("starforge new dapp ", "Scaffold a Stellar dApp"), + ("starforge new contract ", "Scaffold a Soroban contract"), + ("starforge new dapp ", "Scaffold a Stellar dApp"), ("starforge deploy --wasm ", "Deploy a compiled contract"), ]; for (cmd, desc) in &cmds { @@ -47,3 +56,19 @@ pub fn handle() -> Result<()> { Ok(()) } + +pub fn detect_stellar_cli() -> Option { + let candidate = if cfg!(windows) { + "stellar.exe" + } else { + "stellar" + }; + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let full = dir.join(candidate); + if full.is_file() { + return Some(full); + } + } + None +} diff --git a/src/commands/shell.rs b/src/commands/shell.rs index 10b63ede..51aadec8 100644 --- a/src/commands/shell.rs +++ b/src/commands/shell.rs @@ -7,6 +7,12 @@ pub struct ShellArgs { /// Path to the compiled contract .wasm (local sandbox execution) #[arg(long)] pub contract: String, + /// Disable persistent command history + #[arg(long, default_value = "false")] + pub no_history: bool, + /// Maximum number of commands stored in history + #[arg(long, default_value_t = 1000)] + pub history_max_lines: usize, } pub fn handle(args: ShellArgs) -> Result<()> { @@ -18,7 +24,10 @@ pub fn handle(args: ShellArgs) -> Result<()> { let sandbox = LocalSorobanSandbox::new(&args.contract)?; let runner = ShellRunner { sandbox }; - repl::Repl::new(runner).run() + let mut repl_options = repl::ReplOptions::default(); + repl_options.history_enabled = !args.no_history; + repl_options.max_history_lines = args.history_max_lines; + repl::Repl::with_options(runner, repl_options).run() } struct ShellRunner { @@ -30,4 +39,3 @@ impl repl::ReplRunner for ShellRunner { self.sandbox.invoke(function, args) } } - diff --git a/src/utils/profiler.rs b/src/utils/profiler.rs index 18badb87..e1a5fa17 100644 --- a/src/utils/profiler.rs +++ b/src/utils/profiler.rs @@ -6,7 +6,9 @@ pub struct Timer { impl Timer { pub fn start() -> Self { - Self { start: Instant::now() } + Self { + start: Instant::now(), + } } pub fn elapsed(&self) -> Duration { diff --git a/src/utils/repl.rs b/src/utils/repl.rs index 3b457c56..d6736f25 100644 --- a/src/utils/repl.rs +++ b/src/utils/repl.rs @@ -1,12 +1,36 @@ use anyhow::Result; use colored::*; +use std::collections::VecDeque; +use std::fs; use std::io::{self, Write}; +use std::path::PathBuf; pub struct Repl where R: ReplRunner, { runner: R, + options: ReplOptions, +} + +#[derive(Debug, Clone)] +pub struct ReplOptions { + pub history_enabled: bool, + pub history_path: PathBuf, + pub max_history_lines: usize, +} + +impl Default for ReplOptions { + fn default() -> Self { + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push(".starforge"); + path.push("repl_history"); + Self { + history_enabled: true, + history_path: path, + max_history_lines: 1000, + } + } } pub trait ReplRunner { @@ -18,7 +42,14 @@ where R: ReplRunner, { pub fn new(runner: R) -> Self { - Self { runner } + Self { + runner, + options: ReplOptions::default(), + } + } + + pub fn with_options(runner: R, options: ReplOptions) -> Self { + Self { runner, options } } pub fn run(mut self) -> Result<()> { @@ -30,6 +61,7 @@ where let stdin = io::stdin(); let mut buffer = String::new(); + let mut history = self.load_history()?; loop { buffer.clear(); @@ -57,6 +89,7 @@ where continue; } + self.push_history(&mut history, line.to_string()); let (function, args) = parse_invocation(line)?; match self.runner.run_invocation(&function, &args) { Ok(out) => println!("{}", out), @@ -64,8 +97,61 @@ where } } + self.save_history(&history)?; + Ok(()) + } + + fn load_history(&self) -> Result> { + if !self.options.history_enabled { + return Ok(VecDeque::new()); + } + + let content = match fs::read_to_string(&self.options.history_path) { + Ok(content) => content, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(VecDeque::new()), + Err(e) => return Err(e.into()), + }; + + let mut lines: VecDeque = content.lines().map(|l| l.to_string()).collect(); + trim_history(&mut lines, self.options.max_history_lines); + Ok(lines) + } + + fn save_history(&self, history: &VecDeque) -> Result<()> { + if !self.options.history_enabled { + return Ok(()); + } + + if let Some(parent) = self.options.history_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut out = String::new(); + for line in history { + out.push_str(line); + out.push('\n'); + } + fs::write(&self.options.history_path, out)?; Ok(()) } + + fn push_history(&self, history: &mut VecDeque, line: String) { + if !self.options.history_enabled { + return; + } + history.push_back(line); + trim_history(history, self.options.max_history_lines); + } +} + +fn trim_history(history: &mut VecDeque, max_lines: usize) { + if max_lines == 0 { + history.clear(); + return; + } + while history.len() > max_lines { + history.pop_front(); + } } fn parse_invocation(input: &str) -> Result<(String, Vec)> { @@ -147,4 +233,3 @@ fn split_args(input: &str) -> Result> { args.push(current.trim().to_string()); Ok(args) } - diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index e1c3c07c..2a530981 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -4,8 +4,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use stellar_strkey::{ed25519, Contract}; use stellar_xdr::curr::{ AccountId, ContractDataDurability, ContractExecutable, Hash, LedgerEntryData, LedgerKey, - LedgerKeyContractData, PublicKey, ScAddress, ScMap, ScString, ScSymbol, ScVal, - Uint256, + LedgerKeyContractData, PublicKey, ScAddress, ScMap, ScString, ScSymbol, ScVal, Uint256, }; #[derive(Debug, Serialize, Deserialize)] @@ -21,7 +20,7 @@ pub struct TransactionResult { pub return_value: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ContractInspectResult { pub contract_id: String, pub executable: String, @@ -33,7 +32,7 @@ pub struct ContractInspectResult { pub instance_storage: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ContractStorageEntry { pub key: String, pub value: String, @@ -153,7 +152,7 @@ pub fn submit_transaction( pub fn inspect_contract(contract_id: &str, network: &str) -> Result { let ledger_key = build_contract_instance_key(contract_id)?; let ledger_key_xdr = ledger_key_to_xdr_base64(&ledger_key)?; - + let request = SorobanRpcRequest { jsonrpc: "2.0".to_string(), id: 1, @@ -234,7 +233,7 @@ fn ledger_entry_from_xdr_base64(xdr: &str) -> Result { use base64::{engine::general_purpose, Engine as _}; // Simplified XDR decoding - in production use proper stellar-xdr decoding let _decoded = general_purpose::STANDARD.decode(xdr)?; - + // For now, return a mock contract data entry // In production, properly decode the XDR bytes anyhow::bail!("XDR decoding not fully implemented - this is a mock") @@ -256,7 +255,7 @@ fn parse_contract_inspect_result( // For now, return a mock result since we can't decode XDR properly yet // In production, use: LedgerEntryData::from_xdr(entry.xdr.as_bytes(), Limits::none())? - + Ok(ContractInspectResult { contract_id: contract_id.to_string(), executable: "Wasm".to_string(),