diff --git a/Cargo.lock b/Cargo.lock index 60d90508f0b45..0f032bc91d79a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,6 +2564,7 @@ dependencies = [ "clap", "clap_complete", "comfy-table", + "dialoguer", "dirs", "dunce", "evmole", diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 8f187565701b0..73ac386db1336 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -76,6 +76,7 @@ foundry-cli.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } clap_complete.workspace = true comfy-table.workspace = true +dialoguer.workspace = true dunce.workspace = true itertools.workspace = true regex = { workspace = true, default-features = false } diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index a1abb2ea3b7c1..6ae6752b0208f 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -55,6 +55,10 @@ pub enum Erc20Subcommand { }, /// Transfer ERC20 tokens. + /// + /// By default, this command will prompt for confirmation before sending the transaction, + /// displaying the amount in human-readable format (e.g., "100 USDC" instead of raw wei). + /// Use --yes to skip the confirmation prompt for non-interactive usage. #[command(visible_alias = "t")] Transfer { /// The ERC20 token contract address. @@ -65,9 +69,16 @@ pub enum Erc20Subcommand { #[arg(value_parser = NameOrAddress::from_str)] to: NameOrAddress, - /// The amount to transfer. + /// The amount to transfer (in smallest unit, e.g., wei for 18 decimals). amount: String, + /// Skip confirmation prompt. + /// + /// By default, the command will prompt for confirmation before sending the transaction. + /// Use this flag to skip the prompt for scripts and non-interactive usage. + #[arg(long, short)] + yes: bool, + #[command(flatten)] rpc: RpcOpts, @@ -76,6 +87,10 @@ pub enum Erc20Subcommand { }, /// Approve ERC20 token spending. + /// + /// By default, this command will prompt for confirmation before sending the transaction, + /// displaying the amount in human-readable format. + /// Use --yes to skip the confirmation prompt for non-interactive usage. #[command(visible_alias = "a")] Approve { /// The ERC20 token contract address. @@ -86,9 +101,16 @@ pub enum Erc20Subcommand { #[arg(value_parser = NameOrAddress::from_str)] spender: NameOrAddress, - /// The amount to approve. + /// The amount to approve (in smallest unit, e.g., wei for 18 decimals). amount: String, + /// Skip confirmation prompt. + /// + /// By default, the command will prompt for confirmation before sending the transaction. + /// Use this flag to skip the prompt for scripts and non-interactive usage. + #[arg(long, short)] + yes: bool, + #[command(flatten)] rpc: RpcOpts, @@ -305,22 +327,150 @@ impl Erc20Subcommand { sh_println!("{}", format_uint_exp(total_supply))? } // State-changing - Self::Transfer { token, to, amount, wallet, .. } => { - let token = token.resolve(&provider).await?; - let to = to.resolve(&provider).await?; + Self::Transfer { token, to, amount, yes, wallet, .. } => { + let token_addr = token.resolve(&provider).await?; + let to_addr = to.resolve(&provider).await?; let amount = U256::from_str(&amount)?; + // If confirmation is not skipped, prompt user + if !yes { + // Try to fetch token metadata for better UX + let token_contract = IERC20::new(token_addr, &provider); + + // Fetch symbol (fallback to "TOKEN" if not available) + let symbol = token_contract + .symbol() + .call() + .await + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "TOKEN".to_string()); + + // Fetch decimals (fallback to raw amount display if not available) + let formatted_amount = match token_contract.decimals().call().await { + Ok(decimals) if decimals <= 77 => { + use alloy_primitives::utils::{ParseUnits, Unit}; + + if let Some(unit) = Unit::new(decimals) { + let formatted = ParseUnits::U256(amount).format_units(unit); + + let trimmed = if let Some(dot_pos) = formatted.find('.') { + let fractional = &formatted[dot_pos + 1..]; + if fractional.chars().all(|c| c == '0') { + formatted[..dot_pos].to_string() + } else { + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } + } else { + formatted + }; + format!("{trimmed} {symbol}") + } else { + sh_warn!( + "Warning: Could not fetch token decimals. Showing raw amount." + )?; + format!("{amount} {symbol} (raw amount)") + } + } + _ => { + // Could not fetch decimals, show raw amount + sh_warn!( + "Warning: Could not fetch token metadata (decimals/symbol). \ + The address may not be a valid ERC20 token contract." + )?; + format!("{amount} {symbol} (raw amount)") + } + }; + + use dialoguer::Confirm; + + let prompt_msg = + format!("Confirm transfer of {formatted_amount} to address {to_addr}"); + + if !Confirm::new().with_prompt(prompt_msg).interact()? { + eyre::bail!("Transfer cancelled by user"); + } + } + let provider = signing_provider(wallet, &provider).await?; - let tx = IERC20::new(token, &provider).transfer(to, amount).send().await?; + let tx = + IERC20::new(token_addr, &provider).transfer(to_addr, amount).send().await?; sh_println!("{}", tx.tx_hash())? } - Self::Approve { token, spender, amount, wallet, .. } => { - let token = token.resolve(&provider).await?; - let spender = spender.resolve(&provider).await?; + Self::Approve { token, spender, amount, yes, wallet, .. } => { + let token_addr = token.resolve(&provider).await?; + let spender_addr = spender.resolve(&provider).await?; let amount = U256::from_str(&amount)?; + // If confirmation is not skipped, prompt user + if !yes { + // Try to fetch token metadata for better UX + let token_contract = IERC20::new(token_addr, &provider); + + // Fetch symbol (fallback to "TOKEN" if not available) + let symbol = token_contract + .symbol() + .call() + .await + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "TOKEN".to_string()); + + // Fetch decimals (fallback to raw amount display if not available) + let formatted_amount = match token_contract.decimals().call().await { + Ok(decimals) if decimals <= 77 => { + use alloy_primitives::utils::{ParseUnits, Unit}; + + if let Some(unit) = Unit::new(decimals) { + let formatted = ParseUnits::U256(amount).format_units(unit); + let trimmed = if let Some(dot_pos) = formatted.find('.') { + let fractional = &formatted[dot_pos + 1..]; + if fractional.chars().all(|c| c == '0') { + formatted[..dot_pos].to_string() + } else { + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } + } else { + formatted + }; + format!("{trimmed} {symbol}") + } else { + sh_warn!( + "Warning: Could not fetch token decimals. Showing raw amount." + )?; + format!("{amount} {symbol} (raw amount)") + } + } + _ => { + // Could not fetch decimals, show raw amount + sh_warn!( + "Warning: Could not fetch token metadata (decimals/symbol). \ + The address may not be a valid ERC20 token contract." + )?; + format!("{amount} {symbol} (raw amount)") + } + }; + + use dialoguer::Confirm; + + let prompt_msg = format!( + "Confirm approval for {spender_addr} to spend {formatted_amount} from your account" + ); + + if !Confirm::new().with_prompt(prompt_msg).interact()? { + eyre::bail!("Approval cancelled by user"); + } + } + let provider = signing_provider(wallet, &provider).await?; - let tx = IERC20::new(token, &provider).approve(spender, amount).send().await?; + let tx = + IERC20::new(token_addr, &provider).approve(spender_addr, amount).send().await?; sh_println!("{}", tx.tx_hash())? } Self::Mint { token, to, amount, wallet, .. } => { diff --git a/crates/cast/tests/cli/erc20.rs b/crates/cast/tests/cli/erc20.rs index 7390a8af5fded..350821d80927c 100644 --- a/crates/cast/tests/cli/erc20.rs +++ b/crates/cast/tests/cli/erc20.rs @@ -102,6 +102,7 @@ forgetest_async!(erc20_transfer_approve_success, |prj, cmd| { &token, anvil_const::ADDR2, &transfer_amount.to_string(), + "--yes", "--rpc-url", &rpc, "--private-key", @@ -129,6 +130,7 @@ forgetest_async!(erc20_approval_allowance, |prj, cmd| { &token, anvil_const::ADDR2, &approve_amount.to_string(), + "--yes", "--rpc-url", &rpc, "--private-key", @@ -263,3 +265,71 @@ forgetest_async!(erc20_burn_success, |prj, cmd| { let total_supply: U256 = output.split_whitespace().next().unwrap().parse().unwrap(); assert_eq!(total_supply, initial_supply - burn_amount); }); + +// tests that transfer with --yes flag skips confirmation prompt +forgetest_async!(erc20_transfer_with_yes_flag, |prj, cmd| { + let (rpc, token) = setup_token_test(&prj, &mut cmd).await; + + let transfer_amount = U256::from(50_000_000_000_000_000_000u128); // 50 tokens + + // Transfer with --yes flag should succeed without prompting + let output = cmd + .cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--yes", + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Output should be a transaction hash (starts with 0x and is 66 chars long) + assert!(output.starts_with("0x")); + assert_eq!(output.trim().len(), 66); + + // Verify the transfer actually happened + let addr2_balance = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(addr2_balance, transfer_amount); +}); + +// tests that approve with --yes flag skips confirmation prompt +forgetest_async!(erc20_approve_with_yes_flag, |prj, cmd| { + let (rpc, token) = setup_token_test(&prj, &mut cmd).await; + + let approve_amount = U256::from(75_000_000_000_000_000_000u128); // 75 tokens + + // Approve with --yes flag should succeed without prompting + let output = cmd + .cast_fuse() + .args([ + "erc20", + "approve", + &token, + anvil_const::ADDR2, + &approve_amount.to_string(), + "--yes", + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Output should be a transaction hash (starts with 0x and is 66 chars long) + assert!(output.starts_with("0x")); + assert_eq!(output.trim().len(), 66); + + // Verify the approval actually happened + let allowance = get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc); + assert_eq!(allowance, approve_amount); +}); diff --git a/crates/common/src/provider/mod.rs b/crates/common/src/provider/mod.rs index 870c2c24f895f..03d93b2a96c10 100644 --- a/crates/common/src/provider/mod.rs +++ b/crates/common/src/provider/mod.rs @@ -356,10 +356,10 @@ fn resolve_path(path: &Path) -> Result { #[cfg(windows)] fn resolve_path(path: &Path) -> Result { - if let Some(s) = path.to_str() { - if s.starts_with(r"\\.\pipe\") { - return Ok(path.to_path_buf()); - } + if let Some(s) = path.to_str() + && s.starts_with(r"\\.\pipe\") + { + return Ok(path.to_path_buf()); } Err(()) }