diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index a1abb2ea3b7c1..99be927d43349 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -1,4 +1,7 @@ -use std::str::FromStr; +use std::{ + io::{self, BufRead, Write}, + str::FromStr, +}; use crate::{format_uint_exp, tx::signing_provider}; use alloy_eips::BlockId; @@ -15,6 +18,84 @@ use foundry_wallets::WalletOpts; #[doc(hidden)] pub use foundry_config::utils::*; +/// Simple confirmation prompt that works in both interactive and non-interactive environments. +/// +/// In interactive mode (TTY), prompts the user for y/n confirmation. +/// In non-interactive mode (pipe, tests, CI/CD), reads from stdin. +/// +/// The prompt is written to stderr to avoid mixing with command output. +fn confirm_prompt(prompt: &str) -> eyre::Result { + // Print the prompt to stderr (so it doesn't mix with stdout output) + sh_eprint!("{prompt} [y/n] ")?; + io::stderr().flush()?; + + // Read a line from stdin + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + + // Check if user confirmed + let answer = input.trim().to_lowercase(); + Ok(answer == "y" || answer == "yes") +} + +/// Formats a token amount in human-readable form. +/// +/// Fetches token metadata (symbol and decimals) and formats the amount accordingly. +/// Falls back to raw amount display if metadata cannot be fetched. +async fn format_token_amount( + token_contract: &IERC20::IERC20Instance, + amount: U256, +) -> eyre::Result +where + P: alloy_provider::Provider, + N: alloy_network::Network, +{ + // 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)") + } + }; + + Ok(formatted_amount) +} + sol! { #[sol(rpc)] interface IERC20 { @@ -55,6 +136,15 @@ pub enum Erc20Subcommand { }, /// Transfer ERC20 tokens. + /// + /// 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). + /// + /// For non-interactive usage (scripts, CI/CD), you can pipe 'yes' to skip the prompt: + /// + /// ```sh + /// yes | cast erc20 transfer [OPTIONS] + /// ``` #[command(visible_alias = "t")] Transfer { /// The ERC20 token contract address. @@ -65,7 +155,7 @@ 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, #[command(flatten)] @@ -76,6 +166,15 @@ pub enum Erc20Subcommand { }, /// Approve ERC20 token spending. + /// + /// This command will prompt for confirmation before sending the transaction, + /// displaying the amount in human-readable format. + /// + /// For non-interactive usage (scripts, CI/CD), you can pipe 'yes' to skip the prompt: + /// + /// ```sh + /// yes | cast erc20 approve [OPTIONS] + /// ``` #[command(visible_alias = "a")] Approve { /// The ERC20 token contract address. @@ -86,7 +185,7 @@ 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, #[command(flatten)] @@ -306,21 +405,46 @@ impl Erc20Subcommand { } // State-changing Self::Transfer { token, to, amount, wallet, .. } => { - let token = token.resolve(&provider).await?; - let to = to.resolve(&provider).await?; + let token_addr = token.resolve(&provider).await?; + let to_addr = to.resolve(&provider).await?; let amount = U256::from_str(&amount)?; + // Format token amount for display + let token_contract = IERC20::new(token_addr, &provider); + let formatted_amount = format_token_amount(&token_contract, amount).await?; + + let prompt_msg = + format!("Confirm transfer of {formatted_amount} to address {to_addr}"); + + if !confirm_prompt(&prompt_msg)? { + 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?; + let token_addr = token.resolve(&provider).await?; + let spender_addr = spender.resolve(&provider).await?; let amount = U256::from_str(&amount)?; + // Format token amount for display + let token_contract = IERC20::new(token_addr, &provider); + let formatted_amount = format_token_amount(&token_contract, amount).await?; + + let prompt_msg = format!( + "Confirm approval for {spender_addr} to spend {formatted_amount} from your account" + ); + + if !confirm_prompt(&prompt_msg)? { + 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..95674bb27c5cf 100644 --- a/crates/cast/tests/cli/erc20.rs +++ b/crates/cast/tests/cli/erc20.rs @@ -107,6 +107,7 @@ forgetest_async!(erc20_transfer_approve_success, |prj, cmd| { "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success(); // Verify balance changes after transfer @@ -134,6 +135,7 @@ forgetest_async!(erc20_approval_allowance, |prj, cmd| { "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success(); // Verify allowance was set @@ -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 stdin confirmation works +forgetest_async!(erc20_transfer_with_confirmation, |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 stdin "y" should succeed + let output = cmd + .cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .stdin("y\n") + .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 stdin confirmation works +forgetest_async!(erc20_approve_with_confirmation, |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 stdin "y" should succeed + let output = cmd + .cast_fuse() + .args([ + "erc20", + "approve", + &token, + anvil_const::ADDR2, + &approve_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .stdin("y\n") + .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(()) }