From 5e6cc665b91caf1d80f8c6e0342c4bb727b9d7db Mon Sep 17 00:00:00 2001 From: johnsmccain Date: Tue, 26 May 2026 20:26:48 +0100 Subject: [PATCH] Wire deploy optimization, add configurable friendbot/faucet network support, and use dynamic Horizon URLs --- Cargo.lock | 1 + src/commands/deploy.rs | 33 +++++++++++-- src/commands/network.rs | 19 +++++++- src/commands/wallet.rs | 22 +++++---- src/utils/config.rs | 28 +++++++++-- src/utils/horizon.rs | 101 ++++++++++++++++++++++++++++------------ 6 files changed, 154 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bab27f9..f52ade5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 87f30009..ed0b695b 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, horizon, print as p}; +use crate::utils::{config, horizon, optimizer, print as p}; use anyhow::Result; use clap::Args; use colored::*; @@ -18,6 +18,9 @@ pub struct DeployArgs { /// Wallet name to use for deployment #[arg(long)] pub wallet: Option, + /// Optimize the WASM before deployment using the built-in optimizer + #[arg(long, default_value = "false")] + pub optimize: bool, /// Skip confirmation prompt #[arg(long, default_value = "false")] pub yes: bool, @@ -53,11 +56,30 @@ pub fn handle(args: DeployArgs) -> Result<()> { ); } - let wasm_bytes = fs::read(&args.wasm)?; - let wasm_size_kb = wasm_bytes.len() as f64 / 1024.0; + let mut wasm_path = args.wasm.clone(); + let mut wasm_bytes = fs::read(&wasm_path)?; + let mut wasm_size_kb = wasm_bytes.len() as f64 / 1024.0; + + if args.optimize { + let optimized_path = args + .wasm + .with_file_name(format!("{}-optimized.wasm", args.wasm.file_stem().unwrap_or_default().to_string_lossy())); + p::header("WASM Optimization"); + p::kv("Input WASM", &args.wasm.display().to_string()); + p::kv("Output WASM", &optimized_path.display().to_string()); + let result = optimizer::optimize_wasm(&args.wasm, &optimized_path)?; + wasm_path = optimized_path; + wasm_bytes = fs::read(&wasm_path)?; + wasm_size_kb = wasm_bytes.len() as f64 / 1024.0; + println!(); + p::success("Optimization pass completed"); + p::kv("Input size", &format!("{} bytes", result.input_size_bytes)); + p::kv("Output size", &format!("{} bytes", result.output_size_bytes)); + p::separator(); + } p::separator(); - p::kv("WASM file", &args.wasm.display().to_string()); + p::kv("WASM file", &wasm_path.display().to_string()); p::kv("WASM size", &format!("{:.1} KB", wasm_size_kb)); p::kv("Network", &args.network); @@ -66,6 +88,7 @@ pub fn handle(args: DeployArgs) -> Result<()> { "WASM is {:.1} KB — Soroban limit is 128 KB. Optimize with --release.", wasm_size_kb )); + p::info("If this contract is still too large, use `starforge gas optimize --target .wasm --output .wasm` or external tools such as `wasm-opt -Oz`."); } let cfg = config::load()?; @@ -158,7 +181,7 @@ pub fn handle(args: DeployArgs) -> Result<()> { "Ready! Run this to complete the deployment:".bright_white() ); println!(); - let deploy_cmd = build_stellar_deploy_command(&args.wasm, &wallet.public_key, &args.network); + let deploy_cmd = build_stellar_deploy_command(&wasm_path, &wallet.public_key, &args.network); for line in deploy_cmd.lines() { println!(" {}", line.cyan()); } diff --git a/src/commands/network.rs b/src/commands/network.rs index b8324e2d..8f4d26b6 100644 --- a/src/commands/network.rs +++ b/src/commands/network.rs @@ -21,6 +21,9 @@ pub enum NetworkCommands { /// Optional Soroban RPC URL #[arg(long)] soroban_rpc_url: Option, + /// Optional network faucet / Friendbot URL + #[arg(long)] + friendbot_url: Option, }, /// Test connectivity to a network Test { @@ -51,6 +54,9 @@ fn show() -> Result<()> { if let Some(soroban_url) = &net_cfg.soroban_rpc_url { p::kv("Soroban RPC", soroban_url); } + if let Some(friendbot_url) = &net_cfg.friendbot_url { + p::kv("Friendbot", friendbot_url); + } println!(); } @@ -91,7 +97,7 @@ fn switch(target: String) -> Result<()> { Ok(()) } -fn add_network(name: String, horizon_url: String, soroban_rpc_url: Option) -> Result<()> { +fn add_network(name: String, horizon_url: String, soroban_rpc_url: Option, friendbot_url: Option) -> Result<()> { let mut cfg = config::load()?; if !horizon_url.starts_with("http://") && !horizon_url.starts_with("https://") { @@ -104,7 +110,13 @@ fn add_network(name: String, horizon_url: String, soroban_rpc_url: Option, encrypt: b cfg.wallets.push(wallet); if fund { - if network == "mainnet" { + let net_cfg = config::get_network_config(&cfg, &network)?; + if net_cfg.friendbot_url.is_none() && network == "mainnet" { p::warn("Friendbot is not available on Mainnet. Skipping fund step."); } else { - p::step(3, steps, "Funding via Friendbot…"); - match horizon::fund_account(&public_key) { + p::step(3, steps, "Funding via network faucet…"); + match horizon::fund_account(&public_key, &network) { Ok(_) => { if let Some(w) = cfg.wallets.iter_mut().find(|w| w.name == name) { w.funded = true; } - p::success("Funded with 10,000 XLM on testnet"); + p::success("Account funded via configured faucet"); } Err(e) => p::warn(&format!("Funding failed: {}", e)), } @@ -421,7 +422,10 @@ fn fund_wallet(name: String) -> Result<()> { let mut cfg = config::load()?; if cfg.network == "mainnet" { - anyhow::bail!("Friendbot is not available on Mainnet."); + let net_cfg = config::get_network_config(&cfg, &cfg.network)?; + if net_cfg.friendbot_url.is_none() { + anyhow::bail!("Friendbot is not available on Mainnet."); + } } let public_key = cfg @@ -431,8 +435,8 @@ fn fund_wallet(name: String) -> Result<()> { .map(|w| w.public_key.clone()) .ok_or_else(|| anyhow::anyhow!("Wallet '{}' not found", name))?; - p::info(&format!("Funding '{}' via Friendbot…", name)); - horizon::fund_account(&public_key)?; + p::info(&format!("Funding '{}' via configured network faucet…", name)); + horizon::fund_account(&public_key, &cfg.network)?; if let Some(w) = cfg.wallets.iter_mut().find(|w| w.name == name) { w.funded = true; diff --git a/src/utils/config.rs b/src/utils/config.rs index b397bdc9..674d333d 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -74,10 +74,17 @@ pub fn validate_file_path(path: &std::path::Path, expected_ext: Option<&str>) -> pub fn validate_network(network: &str) -> Result<()> { match network { "testnet" | "mainnet" | "docker-testnet" => Ok(()), - _ => anyhow::bail!( - "Unsupported network '{}'. Use 'testnet', 'mainnet', or 'docker-testnet'.", - network - ), + _ => { + let cfg = load()?; + if cfg.networks.contains_key(network) { + Ok(()) + } else { + anyhow::bail!( + "Unsupported network '{}'. Use 'testnet', 'mainnet', 'docker-testnet', or a configured custom network.", + network + ) + } + } } } @@ -121,6 +128,7 @@ fn default_version() -> String { pub struct NetworkConfig { pub horizon_url: String, pub soroban_rpc_url: Option, + pub friendbot_url: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -139,16 +147,19 @@ impl Default for Config { networks.insert("testnet".to_string(), NetworkConfig { horizon_url: "https://horizon-testnet.stellar.org".to_string(), soroban_rpc_url: Some("https://soroban-testnet.stellar.org".to_string()), + friendbot_url: Some("https://friendbot.stellar.org".to_string()), }); networks.insert("mainnet".to_string(), NetworkConfig { horizon_url: "https://horizon.stellar.org".to_string(), soroban_rpc_url: Some("https://mainnet.sorobanrpc.com".to_string()), + friendbot_url: None, }); networks.insert( "docker-testnet".to_string(), NetworkConfig { horizon_url: "http://localhost:8000".to_string(), soroban_rpc_url: Some("http://localhost:8000/rpc".to_string()), + friendbot_url: None, }, ); @@ -374,13 +385,20 @@ pub fn get_network_config(cfg: &Config, network: &str) -> Result .ok_or_else(|| anyhow::anyhow!("Network '{}' not found in configuration", network)) } -pub fn add_custom_network(config: &mut Config, name: String, horizon_url: String, soroban_rpc_url: Option) -> Result<()> { +pub fn add_custom_network( + config: &mut Config, + name: String, + horizon_url: String, + soroban_rpc_url: Option, + friendbot_url: Option, +) -> Result<()> { if config.networks.contains_key(&name) { anyhow::bail!("Network '{}' already exists", name); } config.networks.insert(name, NetworkConfig { horizon_url, soroban_rpc_url, + friendbot_url, }); Ok(()) } diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index a7849e0a..b8e0614a 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -1,12 +1,18 @@ -use anyhow::{Result, Context}; +use crate::utils::config; +use anyhow::{Context, Result}; use serde::Deserialize; -pub fn horizon_url(network: &str) -> &'static str { - match network { - "mainnet" => "https://horizon.stellar.org", - "docker-testnet" => "http://localhost:8000", - _ => "https://horizon-testnet.stellar.org", - } +pub fn network_config(network: &str) -> Result { + let cfg = config::load()?; + config::get_network_config(&cfg, network) +} + +pub fn horizon_url(network: &str) -> Result { + Ok(network_config(network)?.horizon_url) +} + +pub fn friendbot_url(network: &str) -> Result> { + Ok(network_config(network)?.friendbot_url) } #[derive(Debug, Deserialize)] @@ -26,10 +32,13 @@ pub struct Balance { pub asset_code: Option, } -pub fn fund_account(public_key: &str) -> Result<()> { - let url = format!("https://friendbot.stellar.org?addr={}", public_key); +pub fn fund_account(public_key: &str, network: &str) -> Result<()> { + let friendbot = friendbot_url(network)? + .unwrap_or_else(|| "https://friendbot.stellar.org".to_string()); + let separator = if friendbot.contains('?') { '&' } else { '?' }; + let url = format!("{}{}addr={}", friendbot, separator, public_key); let res = ureq::get(&url).call() - .with_context(|| "Friendbot request failed")?; + .with_context(|| format!("Friendbot request failed for {}", network))?; if res.status() == 200 { Ok(()) } else { @@ -38,7 +47,8 @@ pub fn fund_account(public_key: &str) -> Result<()> { } pub fn fetch_account(public_key: &str, network: &str) -> Result { - let url = format!("{}/accounts/{}", horizon_url(network), public_key); + let horizon = horizon_url(network)?; + let url = format!("{}/accounts/{}", horizon, public_key); let res = ureq::get(&url).call() .with_context(|| format!("Failed to reach Horizon on {}", network))?; if res.status() == 200 { @@ -51,12 +61,48 @@ pub fn fetch_account(public_key: &str, network: &str) -> Result } pub fn check_network(network: &str) -> bool { - let url = format!("{}/", horizon_url(network)); - ureq::get(&url).call().map(|r| r.status() == 200).unwrap_or(false) + if let Ok(horizon) = horizon_url(network) { + let url = format!("{}/", horizon); + ureq::get(&url).call().map(|r| r.status() == 200).unwrap_or(false) + } else { + false + } +} + +pub fn build_transaction_query_url(public_key: &str, network: &str, filter: &TxFilter) -> Result { + let horizon = horizon_url(network)?; + let mut url = format!( + "{}/accounts/{}/transactions?order={}&limit={}", + horizon, + public_key, + filter.order.as_deref().unwrap_or("desc"), + filter.limit.min(200) + ); + + if let Some(ref cursor) = filter.cursor { + url.push_str(&format!("&cursor={}", cursor)); + } + if let Some(ref type_filter) = filter.type_filter { + url.push_str(&format!("&type={}", type_filter)); + } + + Ok(url) } #[derive(Debug, Deserialize, Clone)] pub struct TransactionRecord { + pub hash: String, + pub successful: bool, + pub operation_count: u32, + pub fee_charged: String, + pub created_at: String, + pub memo_type: Option, + pub memo: Option, + pub source_account: Option, + #[serde(rename = "type")] + pub transaction_type: Option, + pub paging_token: Option, +} pub hash: String, pub successful: bool, pub operation_count: u32, @@ -82,6 +128,8 @@ struct TransactionsEmbedded { pub struct TxFilter { pub limit: u8, pub cursor: Option, + pub order: Option, + pub type_filter: Option, pub after: Option, pub before: Option, pub successful_only: Option, @@ -107,18 +155,7 @@ pub fn fetch_transactions_filtered( network: &str, filter: TxFilter, ) -> Result> { - let limit = filter.limit.min(200); - let mut url = format!( - "{}/accounts/{}/transactions?order=desc&limit={}", - horizon_url(network), - public_key, - limit - ); - - if let Some(ref cursor) = filter.cursor { - url.push_str(&format!("&cursor={}", cursor)); - } - + let url = build_transaction_query_url(public_key, network, &filter)?; let res = ureq::get(&url).call().with_context(|| { format!( "Account '{}' not found on {}. Has it been funded?", @@ -132,7 +169,10 @@ pub fn fetch_transactions_filtered( let mut records = parsed.embedded.records; - // Client-side date filtering (Horizon doesn't support date range natively) + // Client-side filtering for Horizon features not universally supported + if let Some(ref type_filter) = filter.type_filter { + records.retain(|tx| tx.transaction_type.as_deref() == Some(type_filter.as_str())); + } if let Some(ref after) = filter.after { records.retain(|tx| tx.created_at.as_str() >= after.as_str()); } @@ -182,7 +222,8 @@ pub fn build_and_simulate_payment( )?; // Simulate the transaction - let _url = format!("{}/transactions", horizon_url(network)); + let horizon = horizon_url(network)?; + let _url = format!("{}/transactions", horizon); let _form_data = format!("tx={}", urlencoding::encode(&tx_xdr)); // For simulation, we'll estimate the fee @@ -203,7 +244,8 @@ pub fn submit_payment_transaction( let signed_xdr = sign_transaction_xdr(transaction_xdr, secret_key, network)?; // Submit to Horizon - let url = format!("{}/transactions", horizon_url(network)); + let horizon = horizon_url(network)?; + let url = format!("{}/transactions", horizon); let form_data = format!("tx={}", urlencoding::encode(&signed_xdr)); let res = ureq::post(&url) @@ -244,7 +286,8 @@ pub fn submit_multisig_transaction( network: &str, ) -> Result { // Submit a pre-signed transaction (e.g. multisig envelope) to Horizon. - let url = format!("{}/transactions", horizon_url(network)); + let horizon = horizon_url(network)?; + let url = format!("{}/transactions", horizon); let form_data = format!("tx={}", urlencoding::encode(signed_transaction_xdr)); let res = ureq::post(&url)