Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 168 additions & 9 deletions crates/cast/src/cmd/erc20.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +18,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<bool> {
// 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 {
Expand Down Expand Up @@ -55,6 +78,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 <TOKEN> <TO> <AMOUNT> [OPTIONS]
/// ```
#[command(visible_alias = "t")]
Transfer {
/// The ERC20 token contract address.
Expand All @@ -65,7 +97,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)]
Expand All @@ -76,6 +108,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 <TOKEN> <SPENDER> <AMOUNT> [OPTIONS]
/// ```
#[command(visible_alias = "a")]
Approve {
/// The ERC20 token contract address.
Expand All @@ -86,7 +127,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)]
Expand Down Expand Up @@ -306,21 +347,139 @@ 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)?;

// 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)")
}
};

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)?;

// 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)")
}
};

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, .. } => {
Expand Down
70 changes: 70 additions & 0 deletions crates/cast/tests/cli/erc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
8 changes: 4 additions & 4 deletions crates/common/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,10 @@ fn resolve_path(path: &Path) -> Result<PathBuf, ()> {

#[cfg(windows)]
fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
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(())
}
Expand Down
Loading