diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 27a79262..7ec049c0 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, horizon, optimizer, print as p, soroban}; +use crate::utils::{config, confirmation, horizon, optimizer, print as p, soroban}; use anyhow::Result; use clap::Args; use colored::*; @@ -183,23 +183,37 @@ pub fn handle(args: DeployArgs) -> Result<()> { p::separator(); } - if args.network == "mainnet" { - p::warn("You are deploying to MAINNET. This costs real XLM."); - } + // Build operation summary for confirmation + let risk_level = if args.network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; - if !args.yes { - println!(); - print!(" Proceed? [y/N] "); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - p::info("Deployment cancelled."); - return Ok(()); - } + let summary = confirmation::OperationSummary::new( + "Deploy Soroban Contract".to_string(), + args.network.clone(), + risk_level, + ) + .add("WASM file", &wasm_path.display().to_string()) + .add("WASM size", &format!("{:.1} KB", wasm_size_kb)) + .add("WASM hash", &wasm_hash) + .add("Wallet", &wallet.name) + .add("Public Key", &wallet.public_key) + .add("Optimized", if args.optimize { "Yes" } else { "No" }) + .add("Execute", if args.execute { "Yes" } else { "No (dry-run)" }); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: args.network.clone(), + skip_confirm: args.yes, + dry_run: !args.execute, + prompt: Some("Proceed with deployment?".to_string()), + require_type_confirmation: args.network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } println!(); diff --git a/src/commands/invoke.rs b/src/commands/invoke.rs index 2e330f29..344f2a98 100644 --- a/src/commands/invoke.rs +++ b/src/commands/invoke.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, print as p, soroban}; +use crate::utils::{config, confirmation, print as p, soroban}; use anyhow::Result; use clap::Args; @@ -31,6 +31,10 @@ pub struct InvokeArgs { /// Simulate only (don't submit transaction) #[arg(long)] simulate: bool, + + /// Skip confirmation prompt + #[arg(long, default_value = "false")] + yes: bool, } #[allow(dead_code)] @@ -114,6 +118,47 @@ pub fn handle(args: InvokeArgs) -> Result<()> { } } + // Add confirmation before actual submission + if !args.simulate { + let risk_level = if *network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; + + let mut summary = confirmation::OperationSummary::new( + "Invoke Contract Function".to_string(), + network.clone(), + risk_level, + ) + .add("Contract ID", &args.contract_id) + .add("Function", &args.function) + .add("Wallet", &args.wallet) + .add("Estimated Fee", &format!("{} stroops", outcome.simulation.fee)) + .add("Return Value", &outcome.simulation.return_value); + + // Add arguments to summary if present + if !arg_list.is_empty() { + for (i, (arg, arg_type)) in arg_list.iter().zip(arg_type_list.iter()).enumerate() { + summary = summary.add(&format!("Arg [{}] {}", i, arg_type), arg); + } + } + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Submit this transaction?".to_string()), + require_type_confirmation: *network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + p::info("Transaction submission cancelled."); + return Ok(()); + } + } + if let Some(tx) = outcome.transaction { p::step(2, 2, "Submitting to network..."); println!(); diff --git a/src/commands/tx.rs b/src/commands/tx.rs index a15affa8..6b9f8538 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use colored::*; +use crate::utils::confirmation; use crate::utils::horizon::FeeStats; use crate::utils::{config, crypto, horizon, print as p, tx_batch}; // Import FeeStats @@ -189,19 +190,48 @@ fn handle_batch(args: BatchArgs) -> Result<()> { ), ); - if !args.yes { - println!(); - print!(" Proceed with batch transaction? [y/N] "); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - p::info("Batch transaction cancelled."); - return Ok(()); - } + // Build operation summary for confirmation + let risk_level = if args.network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; + + let mut summary = confirmation::OperationSummary::new( + "Batch Stellar Transaction".to_string(), + args.network.clone(), + risk_level, + ) + .add("From Wallet", &wallet.name) + .add("From Address", &wallet.public_key) + .add("Operations", &doc.operations.len().to_string()) + .add("Batch File", &args.file.display().to_string()) + .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)); + + // Add operation details to summary + for (i, op) in payment_ops.iter().enumerate() { + let asset_label = match (&op.asset_code, &op.asset_issuer) { + (None, None) => "XLM".to_string(), + (Some(code), Some(issuer)) => format!("{}:{}", code, issuer), + _ => "unknown".to_string(), + }; + summary = summary.add( + &format!("Op {}", i + 1), + &format!("payment → {} {} {}", op.destination, op.amount, asset_label), + ); + } + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: args.network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Proceed with batch transaction?".to_string()), + require_type_confirmation: args.network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } println!(); @@ -380,20 +410,35 @@ fn handle_send(args: SendArgs) -> Result<()> { &format!("{}...", &tx_result.transaction_xdr[..20]), ); - // Confirmation prompt - if !args.yes { - println!(); - print!(" Proceed with payment? [y/N] "); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - p::info("Payment cancelled."); - return Ok(()); - } + // Build operation summary for confirmation + let risk_level = if args.network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; + + let summary = confirmation::OperationSummary::new( + "Send Stellar Payment".to_string(), + args.network.clone(), + risk_level, + ) + .add("From Wallet", &wallet.name) + .add("From Address", &wallet.public_key) + .add("To Address", &args.to) + .add("Amount", &format!("{} {}", args.amount, args.asset)) + .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: args.network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Proceed with payment?".to_string()), + require_type_confirmation: args.network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } // Submit transaction diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index d4224586..380a5b83 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, horizon, print as p}; +use crate::utils::{config, confirmation, horizon, print as p}; use anyhow::Result; use chrono::Utc; use clap::{Args, Subcommand}; @@ -554,23 +554,36 @@ fn handle_execute(args: ExecuteArgs) -> Result<()> { p::kv("Network", &proposal.network); p::kv("Executor", &wallet.public_key); - if args.network == "mainnet" { - p::warn("You are upgrading on MAINNET. This is irreversible without a rollback proposal."); - } + // Build operation summary for confirmation + let risk_level = if args.network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; - if !args.yes { - println!(); - print!(" Execute upgrade? [y/N] "); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - p::info("Upgrade cancelled."); - return Ok(()); - } + let summary = confirmation::OperationSummary::new( + "Execute Contract Upgrade".to_string(), + args.network.clone(), + risk_level, + ) + .add("Proposal ID", &proposal.id) + .add("Contract ID", &proposal.contract_id) + .add("New WASM hash", &proposal.new_wasm_hash) + .add("Network", &proposal.network) + .add("Executor", &wallet.public_key) + .add("Approvals", &format!("{}/{}", proposal.approvals.len(), proposal.threshold)); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: args.network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Execute this upgrade?".to_string()), + require_type_confirmation: args.network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } println!(); @@ -651,23 +664,35 @@ fn handle_rollback(args: RollbackArgs) -> Result<()> { p::kv("Originally from", &target.proposal_id); p::kv("Network", &args.network); - if args.network == "mainnet" { - p::warn("Rolling back on MAINNET. Ensure backward compatibility before proceeding."); - } + // Build operation summary for confirmation + let risk_level = if args.network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; - if !args.yes { - println!(); - print!(" Proceed with rollback? [y/N] "); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { - p::info("Rollback cancelled."); - return Ok(()); - } + let summary = confirmation::OperationSummary::new( + "Contract Rollback".to_string(), + args.network.clone(), + risk_level, + ) + .add("Contract ID", &args.contract_id) + .add("Rollback to", &args.to_hash) + .add("Originally from", &target.proposal_id) + .add("Network", &args.network) + .add("Executor", &wallet.public_key); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: args.network.clone(), + skip_confirm: args.yes, + dry_run: false, + prompt: Some("Proceed with rollback?".to_string()), + require_type_confirmation: args.network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } println!(); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index e7288af2..501f7bd2 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p}; +use crate::utils::{config, confirmation, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p}; use anyhow::{Context, Result}; use chrono::Utc; use clap::Subcommand; @@ -877,26 +877,39 @@ fn merge_wallet( &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0), ); - if !skip_confirm { - println!(); - p::warn(&format!( - "Account '{}' ({}) will be closed and remaining XLM sent to {}.", - wallet.name, wallet.public_key, destination - )); - print!( - " Type the source wallet name ({}) to confirm merge: ", - wallet.name - ); - use std::io::BufRead; - let line = std::io::stdin() - .lock() - .lines() - .next() - .unwrap_or(Ok(String::new()))?; - if line.trim() != wallet.name { - p::info("Account merge cancelled."); - return Ok(()); - } + // Build operation summary for confirmation + let risk_level = if network == "mainnet" { + confirmation::RiskLevel::High + } else { + confirmation::RiskLevel::Medium + }; + + let summary = confirmation::OperationSummary::new( + "Account Merge".to_string(), + network.clone(), + risk_level, + ) + .add("Source Wallet", &wallet.name) + .add("Source Address", &wallet.public_key) + .add("Destination", &destination) + .add("XLM to Transfer", &format!("{:.7} XLM (minus fee)", xlm_balance)) + .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)) + .add("Remove Local", if remove_local { "Yes" } else { "No" }); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level, + network: network.clone(), + skip_confirm: skip_confirm, + dry_run: false, + prompt: Some(&format!( + "Type '{}' to confirm merge of account {}:", + wallet.name, wallet.name + )), + require_type_confirmation: true, // Always require type confirmation for merge + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + return Ok(()); } println!(); diff --git a/src/utils/confirmation.rs b/src/utils/confirmation.rs new file mode 100644 index 00000000..631ccb05 --- /dev/null +++ b/src/utils/confirmation.rs @@ -0,0 +1,249 @@ +use crate::utils::print as p; +use anyhow::Result; +use colored::*; +use std::io::{BufRead, Write}; + +/// Risk level for operations +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RiskLevel { + Low, + Medium, + High, +} + +impl RiskLevel { + pub fn display(&self) -> colored::ColoredString { + match self { + RiskLevel::Low => "LOW".green(), + RiskLevel::Medium => "MEDIUM".yellow(), + RiskLevel::High => "HIGH".red(), + } + } +} + +/// Configuration for confirmation prompts +pub struct ConfirmationConfig { + /// Risk level of the operation + pub risk_level: RiskLevel, + /// Network being used (for mainnet warnings) + pub network: String, + /// Whether to skip confirmation (from --yes flag) + pub skip_confirm: bool, + /// Whether this is a dry-run/preview + pub dry_run: bool, + /// Custom confirmation message + pub prompt: Option, + /// Whether to require typing "yes" for high-risk operations + pub require_type_confirmation: bool, +} + +impl Default for ConfirmationConfig { + fn default() -> Self { + Self { + risk_level: RiskLevel::Medium, + network: "testnet".to_string(), + skip_confirm: false, + dry_run: false, + prompt: None, + require_type_confirmation: false, + } + } +} + +/// Display a prominent mainnet warning +pub fn display_mainnet_warning(network: &str) { + if network == "mainnet" { + println!(); + p::separator(); + println!( + "{} {}", + "⚠ WARNING:".red().bold(), + "You are operating on MAINNET".bright_red().bold() + ); + println!( + "{}", + " This will use REAL funds and cannot be undone.".bright_red() + ); + println!( + "{}", + " Double-check all addresses, amounts, and parameters.".bright_red() + ); + p::separator(); + println!(); + } +} + +/// Display an operation summary before confirmation +pub struct OperationSummary { + pub title: String, + pub items: Vec<(String, String)>, + pub network: String, + pub risk_level: RiskLevel, +} + +impl OperationSummary { + pub fn new(title: String, network: String, risk_level: RiskLevel) -> Self { + Self { + title, + items: Vec::new(), + network, + risk_level, + } + } + + pub fn add(mut self, key: impl Into, value: impl Into) -> Self { + self.items.push((key.into(), value.into())); + self + } + + pub fn display(&self) { + p::header(&self.title); + p::separator(); + + // Display risk level + p::kv("Risk Level", &self.risk_level.display().to_string()); + p::kv("Network", &self.network); + + println!(); + + // Display all items + for (key, value) in &self.items { + p::kv(key, value); + } + + p::separator(); + } +} + +/// Request user confirmation for an operation +pub fn confirm_operation(summary: &OperationSummary, config: &ConfirmationConfig) -> Result { + // Display mainnet warning if applicable + display_mainnet_warning(&config.network); + + // Display operation summary + summary.display(); + + // If dry-run, show preview message and return true + if config.dry_run { + println!(); + p::info("Dry-run mode: This is a preview only. No changes will be made."); + println!(); + return Ok(true); + } + + // Skip confirmation if requested + if config.skip_confirm { + println!(); + p::info("Skipping confirmation (--yes flag provided)"); + println!(); + return Ok(true); + } + + // Request confirmation + println!(); + + let prompt = config.prompt.as_deref().unwrap_or("Proceed with this operation?"); + + if config.require_type_confirmation || summary.risk_level == RiskLevel::High { + // Require typing "yes" for high-risk operations + print!(" {} [type 'yes' to confirm]: ", prompt.bright_white()); + std::io::stdout().flush()?; + + let line = std::io::stdin() + .lock() + .lines() + .next() + .unwrap_or(Ok(String::new()))?; + + if line.trim().to_lowercase() != "yes" { + println!(); + p::info("Operation cancelled."); + return Ok(false); + } + } else { + // Simple y/N confirmation + print!(" {} [y/N]: ", prompt.bright_white()); + std::io::stdout().flush()?; + + let line = std::io::stdin() + .lock() + .lines() + .next() + .unwrap_or(Ok(String::new()))?; + + if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { + println!(); + p::info("Operation cancelled."); + return Ok(false); + } + } + + println!(); + Ok(true) +} + +/// Display a preview of what will happen without executing +pub fn display_preview(summary: &OperationSummary) { + p::header("Preview Mode"); + p::separator(); + p::kv("Risk Level", &summary.risk_level.display().to_string()); + p::kv("Network", &summary.network); + println!(); + + for (key, value) in &summary.items { + p::kv(key, value); + } + + p::separator(); + println!(); + p::info("This is a preview. Use --execute to perform this operation."); + println!(); +} + +/// Validate that user has confirmed the action +pub fn validate_confirmation( + network: &str, + skip_confirm: bool, + dry_run: bool, + risk_level: RiskLevel, +) -> ConfirmationConfig { + ConfirmationConfig { + risk_level, + network: network.to_string(), + skip_confirm, + dry_run, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_risk_level_display() { + assert!(RiskLevel::Low.display().to_string().contains("LOW")); + assert!(RiskLevel::Medium.display().to_string().contains("MEDIUM")); + assert!(RiskLevel::High.display().to_string().contains("HIGH")); + } + + #[test] + fn test_operation_summary_builder() { + let summary = OperationSummary::new("Test".to_string(), "testnet".to_string(), RiskLevel::Low) + .add("Key1", "Value1") + .add("Key2", "Value2"); + + assert_eq!(summary.items.len(), 2); + assert_eq!(summary.items[0].0, "Key1"); + assert_eq!(summary.items[1].0, "Key2"); + } + + #[test] + fn test_confirmation_config_default() { + let config = ConfirmationConfig::default(); + assert_eq!(config.risk_level, RiskLevel::Medium); + assert_eq!(config.network, "testnet"); + assert!(!config.skip_confirm); + assert!(!config.dry_run); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a6717bb1..960fa1fd 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod bindings; pub mod config; +pub mod confirmation; pub mod crypto; pub mod hardware_wallet; pub mod horizon;