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..dd927a7d51fe9 100644
--- a/crates/cast/src/cmd/erc20.rs
+++ b/crates/cast/src/cmd/erc20.rs
@@ -15,6 +15,64 @@ use foundry_wallets::WalletOpts;
#[doc(hidden)]
pub use foundry_config::utils::*;
+/// 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 +113,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 +127,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 +145,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 +159,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 +385,55 @@ 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 {
+ let token_contract = IERC20::new(token_addr, &provider);
+ let formatted_amount = format_token_amount(&token_contract, amount).await?;
+
+ 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 {
+ let token_contract = IERC20::new(token_addr, &provider);
+ let formatted_amount = format_token_amount(&token_contract, amount).await?;
+
+ 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(())
}