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
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