Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cast/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
170 changes: 160 additions & 10 deletions crates/cast/src/cmd/erc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,

Expand All @@ -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.
Expand All @@ -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,

Expand Down Expand Up @@ -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, .. } => {
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 @@ -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",
Expand Down Expand Up @@ -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",
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 --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);
});
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