From ada6e1cfc62f8e013eb9503646e22520951c5a2b Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Sat, 8 Nov 2025 20:34:57 +0700 Subject: [PATCH 1/7] feat(cast): confirmation for transfer/approve erc20 --- Cargo.lock | 1 + crates/cast/Cargo.toml | 1 + crates/cast/src/cmd/erc20.rs | 170 +++++++++++++++++++++++++++++++-- crates/cast/tests/cli/erc20.rs | 70 ++++++++++++++ 4 files changed, 232 insertions(+), 10 deletions(-) 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); +}); From 223bf2da1a314b57b7b9258097de7b486c501770 Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Sat, 8 Nov 2025 20:35:41 +0700 Subject: [PATCH 2/7] chore(provider): result of clippy cleanup --- crates/common/src/provider/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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(()) } From 167f435f66e71e1d143b507fbe4d57595956c75c Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Tue, 18 Nov 2025 15:10:25 +0700 Subject: [PATCH 3/7] fix(cast): replace --yes flag with yes | --- crates/cast/src/cmd/erc20.rs | 249 +++++++++++++++++---------------- crates/cast/tests/cli/erc20.rs | 20 +-- 2 files changed, 135 insertions(+), 134 deletions(-) diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index 6ae6752b0208f..94a406753629b 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -1,3 +1,4 @@ +use std::io::{self, BufRead, Write}; use std::str::FromStr; use crate::{format_uint_exp, tx::signing_provider}; @@ -15,6 +16,26 @@ 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") +} + sol! { #[sol(rpc)] interface IERC20 { @@ -56,9 +77,11 @@ pub enum Erc20Subcommand { /// Transfer ERC20 tokens. /// - /// By default, this command will prompt for confirmation before sending the transaction, + /// 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. + /// + /// For non-interactive usage (scripts, CI/CD), you can pipe 'yes' to skip the prompt: + /// yes | cast erc20 transfer [options] #[command(visible_alias = "t")] Transfer { /// The ERC20 token contract address. @@ -72,13 +95,6 @@ pub enum Erc20Subcommand { /// 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, @@ -88,9 +104,11 @@ pub enum Erc20Subcommand { /// Approve ERC20 token spending. /// - /// By default, this command will prompt for confirmation before sending the transaction, + /// 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. + /// + /// For non-interactive usage (scripts, CI/CD), you can pipe 'yes' to skip the prompt: + /// yes | cast erc20 approve [options] #[command(visible_alias = "a")] Approve { /// The ERC20 token contract address. @@ -104,13 +122,6 @@ pub enum Erc20Subcommand { /// 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, @@ -327,72 +338,67 @@ impl Erc20Subcommand { sh_println!("{}", format_uint_exp(total_supply))? } // State-changing - Self::Transfer { token, to, amount, yes, wallet, .. } => { + Self::Transfer { token, to, amount, 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() - } + // 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 - }; - format!("{trimmed} {symbol}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } } else { - sh_warn!( - "Warning: Could not fetch token decimals. Showing raw amount." - )?; - format!("{amount} {symbol} (raw amount)") - } - } - _ => { - // Could not fetch decimals, show raw amount + formatted + }; + format!("{trimmed} {symbol}") + } else { sh_warn!( - "Warning: Could not fetch token metadata (decimals/symbol). \ - The address may not be a valid ERC20 token contract." + "Warning: Could not fetch token decimals. Showing raw amount." )?; format!("{amount} {symbol} (raw amount)") } - }; - - use dialoguer::Confirm; + } + _ => { + // 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)") + } + }; - let prompt_msg = - format!("Confirm transfer of {formatted_amount} to address {to_addr}"); + 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"); - } + if !confirm_prompt(&prompt_msg)? { + eyre::bail!("Transfer cancelled by user"); } let provider = signing_provider(wallet, &provider).await?; @@ -400,72 +406,67 @@ impl Erc20Subcommand { IERC20::new(token_addr, &provider).transfer(to_addr, amount).send().await?; sh_println!("{}", tx.tx_hash())? } - Self::Approve { token, spender, amount, yes, wallet, .. } => { + Self::Approve { token, spender, amount, 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() - } + // 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 - }; - format!("{trimmed} {symbol}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } } else { - sh_warn!( - "Warning: Could not fetch token decimals. Showing raw amount." - )?; - format!("{amount} {symbol} (raw amount)") - } - } - _ => { - // Could not fetch decimals, show raw amount + formatted + }; + format!("{trimmed} {symbol}") + } else { sh_warn!( - "Warning: Could not fetch token metadata (decimals/symbol). \ - The address may not be a valid ERC20 token contract." + "Warning: Could not fetch token decimals. Showing raw amount." )?; format!("{amount} {symbol} (raw amount)") } - }; - - use dialoguer::Confirm; + } + _ => { + // 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)") + } + }; - let prompt_msg = format!( - "Confirm approval for {spender_addr} to spend {formatted_amount} from your account" - ); + 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"); - } + if !confirm_prompt(&prompt_msg)? { + eyre::bail!("Approval cancelled by user"); } let provider = signing_provider(wallet, &provider).await?; diff --git a/crates/cast/tests/cli/erc20.rs b/crates/cast/tests/cli/erc20.rs index 350821d80927c..95674bb27c5cf 100644 --- a/crates/cast/tests/cli/erc20.rs +++ b/crates/cast/tests/cli/erc20.rs @@ -102,12 +102,12 @@ forgetest_async!(erc20_transfer_approve_success, |prj, cmd| { &token, anvil_const::ADDR2, &transfer_amount.to_string(), - "--yes", "--rpc-url", &rpc, "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success(); // Verify balance changes after transfer @@ -130,12 +130,12 @@ forgetest_async!(erc20_approval_allowance, |prj, cmd| { &token, anvil_const::ADDR2, &approve_amount.to_string(), - "--yes", "--rpc-url", &rpc, "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success(); // Verify allowance was set @@ -266,13 +266,13 @@ forgetest_async!(erc20_burn_success, |prj, cmd| { 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| { +// 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 --yes flag should succeed without prompting + // Transfer with stdin "y" should succeed let output = cmd .cast_fuse() .args([ @@ -281,12 +281,12 @@ forgetest_async!(erc20_transfer_with_yes_flag, |prj, cmd| { &token, anvil_const::ADDR2, &transfer_amount.to_string(), - "--yes", "--rpc-url", &rpc, "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success() .get_output() .stdout_lossy(); @@ -300,13 +300,13 @@ forgetest_async!(erc20_transfer_with_yes_flag, |prj, cmd| { assert_eq!(addr2_balance, transfer_amount); }); -// tests that approve with --yes flag skips confirmation prompt -forgetest_async!(erc20_approve_with_yes_flag, |prj, cmd| { +// 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 --yes flag should succeed without prompting + // Approve with stdin "y" should succeed let output = cmd .cast_fuse() .args([ @@ -315,12 +315,12 @@ forgetest_async!(erc20_approve_with_yes_flag, |prj, cmd| { &token, anvil_const::ADDR2, &approve_amount.to_string(), - "--yes", "--rpc-url", &rpc, "--private-key", anvil_const::PK1, ]) + .stdin("y\n") .assert_success() .get_output() .stdout_lossy(); From f862061961169ed0d88c4c64b88b72910df1856c Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Tue, 18 Nov 2025 15:10:49 +0700 Subject: [PATCH 4/7] chore(cast): delete unused dialoguer from cast --- Cargo.lock | 1 - crates/cast/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f032bc91d79a..60d90508f0b45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,7 +2564,6 @@ dependencies = [ "clap", "clap_complete", "comfy-table", - "dialoguer", "dirs", "dunce", "evmole", diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 73ac386db1336..8f187565701b0 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -76,7 +76,6 @@ 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 } From b5b58011ec2dfe18d3121272fc04181a14c161fa Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Tue, 18 Nov 2025 15:15:42 +0700 Subject: [PATCH 5/7] chore(cast): linter changes --- crates/cast/src/cmd/erc20.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index 94a406753629b..5803d3407dac1 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -1,5 +1,7 @@ -use std::io::{self, BufRead, Write}; -use std::str::FromStr; +use std::{ + io::{self, BufRead, Write}, + str::FromStr, +}; use crate::{format_uint_exp, tx::signing_provider}; use alloy_eips::BlockId; From 5703abd81e5e49274b51cdcba681a05df7c09dfe Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Tue, 18 Nov 2025 15:37:04 +0700 Subject: [PATCH 6/7] chore(cast): fix docstrings for autodocs --- crates/cast/src/cmd/erc20.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index 5803d3407dac1..442736373944f 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -83,7 +83,10 @@ pub enum Erc20Subcommand { /// 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: - /// yes | cast erc20 transfer [options] + /// + /// ```sh + /// yes | cast erc20 transfer [OPTIONS] + /// ``` #[command(visible_alias = "t")] Transfer { /// The ERC20 token contract address. @@ -110,7 +113,10 @@ pub enum Erc20Subcommand { /// displaying the amount in human-readable format. /// /// For non-interactive usage (scripts, CI/CD), you can pipe 'yes' to skip the prompt: - /// yes | cast erc20 approve [options] + /// + /// ```sh + /// yes | cast erc20 approve [OPTIONS] + /// ``` #[command(visible_alias = "a")] Approve { /// The ERC20 token contract address. From b81932251635427fd561a1ecb5be86abd577b6f7 Mon Sep 17 00:00:00 2001 From: Vladislav <4pex1oh@gmail.com> Date: Wed, 19 Nov 2025 18:57:40 +0700 Subject: [PATCH 7/7] chore(cast): compress duplicated code --- crates/cast/src/cmd/erc20.rs | 159 ++++++++++++++--------------------- 1 file changed, 62 insertions(+), 97 deletions(-) diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index 442736373944f..99be927d43349 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -38,6 +38,64 @@ fn confirm_prompt(prompt: &str) -> eyre::Result { 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 { @@ -351,56 +409,9 @@ impl Erc20Subcommand { let to_addr = to.resolve(&provider).await?; let amount = U256::from_str(&amount)?; - // Try to fetch token metadata for better UX + // Format token amount for display 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)") - } - }; + let formatted_amount = format_token_amount(&token_contract, amount).await?; let prompt_msg = format!("Confirm transfer of {formatted_amount} to address {to_addr}"); @@ -419,55 +430,9 @@ impl Erc20Subcommand { let spender_addr = spender.resolve(&provider).await?; let amount = U256::from_str(&amount)?; - // Try to fetch token metadata for better UX + // Format token amount for display 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)") - } - }; + 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"