Skip to content

Implement pay CLI tool for token transfers#5

Merged
juntao merged 1 commit intomainfrom
implement-pay
Jan 31, 2026
Merged

Implement pay CLI tool for token transfers#5
juntao merged 1 commit intomainfrom
implement-pay

Conversation

@juntao
Copy link
Member

@juntao juntao commented Jan 31, 2026

Summary

  • Implement the pay CLI tool that transfers ERC-20 tokens or native ETH from an x402 wallet
  • Reads configuration from ~/.x402/config.toml via x402_common::Config
  • Uses alloy crate for Ethereum provider and transaction handling
  • Supports balance checking, transaction confirmation, and various CLI options

Test plan

  • Build and verify no clippy warnings: cargo build -p pay && cargo clippy -p pay
  • Test with pay --help to verify CLI arguments
  • Test on Base Sepolia with a funded wallet

🤖 Generated with Claude Code

Add the pay tool that transfers ERC-20 tokens or native ETH from an x402 wallet:

- Reads configuration from ~/.x402/config.toml via x402_common::Config
- Supports ERC-20 token transfers and native ETH transfers
- Decrypts wallet keystore using eth-keystore
- Uses alloy crate for Ethereum provider and transaction handling
- Checks balance before sending to provide clear error messages
- Waits for transaction confirmation by default (--no-wait to skip)
- CLI options for --to, --amount, --token, --rpc, --wallet, --password,
  --password-file, --chain-id, --config, --no-wait
- Exit codes: 0 (success), 1 (insufficient balance), 2 (tx failed),
  3 (network error), 10 (missing config), 11 (invalid config),
  12 (wallet not found), 20 (invalid argument)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@juntao juntao requested a review from Copilot January 31, 2026 06:23
@juntao juntao merged commit 1fb4e4c into main Jan 31, 2026
15 checks passed
@juntao juntao deleted the implement-pay branch January 31, 2026 06:28
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a new pay CLI tool that enables token transfers (both ERC-20 tokens and native ETH) from x402 wallets. The tool integrates with the existing x402 configuration system and provides comprehensive features including balance checking, transaction confirmation, and chain ID verification.

Changes:

  • Added complete pay CLI tool implementation with support for ERC-20 and native ETH transfers
  • Comprehensive README documentation with usage examples, configuration details, and troubleshooting guide
  • Cargo.toml with appropriate dependencies (alloy, clap, tokio, eth-keystore)

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 16 comments.

File Description
pay/src/main.rs Main implementation with CLI argument parsing, wallet decryption, balance checking, and transaction handling for both ERC-20 and native ETH transfers
pay/README.md Comprehensive user documentation including usage examples, configuration details, exit codes, and troubleshooting guide
pay/Cargo.toml Package configuration with dependencies for blockchain interaction (alloy), CLI parsing (clap), async runtime (tokio), and wallet decryption (eth-keystore)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +29
| `--password <PASSWORD>` | Wallet password |
| `--password-file <PATH>` | Read wallet password from file |
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README documents the --password option without warning about security implications. Passing passwords via command-line arguments is insecure because they're visible in process listings and shell history. Consider adding a security warning in the documentation recommending users prefer --password-file over --password, similar to how many CLI tools document this risk.

Copilot uses AI. Check for mistakes.
Comment on lines +270 to +273
let tx = token_contract.transfer(to_address, amount);
let pending_tx = tx.send().await.map_err(|e| {
PayError::TransactionFailed(format!("Failed to send transaction: {}", e))
})?;
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ERC-20 transfer function returns a boolean indicating success, but the code doesn't check this return value. While the transaction receipt status is checked later (line 285), some tokens may return false without reverting. According to ERC-20 specification, a transfer returning false should be treated as a failure. Consider checking the return value of the transfer call, or at minimum document that this implementation relies on reverting behavior and may not work correctly with non-standard tokens that return false instead of reverting.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +215
// Get the directory and filename for eth_keystore
let keystore_dir = wallet_path.parent().unwrap_or(std::path::Path::new("."));
let keystore_name = wallet_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("wallet.json");

let private_key = eth_keystore::decrypt_key(keystore_dir.join(keystore_name), &password)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wallet path is being unnecessarily reconstructed here. The eth_keystore::decrypt_key function takes a path to the keystore file directly, so you should pass &wallet_path instead of keystore_dir.join(keystore_name). While both would produce the same path in most cases, the reconstruction is redundant and could potentially fail if the wallet path doesn't have a parent directory (e.g., if it's just a filename in the current directory).

The keystore_dir and keystore_name variables extracted on lines 209-213 appear to be unused since they're just reconstructed. Consider simplifying this to just use the wallet_path directly.

Suggested change
// Get the directory and filename for eth_keystore
let keystore_dir = wallet_path.parent().unwrap_or(std::path::Path::new("."));
let keystore_name = wallet_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("wallet.json");
let private_key = eth_keystore::decrypt_key(keystore_dir.join(keystore_name), &password)
let private_key = eth_keystore::decrypt_key(&wallet_path, &password)

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +51
/// Wallet password
#[arg(long)]
password: Option<String>,

/// Read wallet password from file
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing passwords via the --password flag on the command line is a security risk because command-line arguments are visible in process listings (ps, top, etc.) and may be logged in shell history. While this option is provided for convenience, consider adding a warning in the help text or documentation about the security implications and recommending the use of --password-file instead.

Suggested change
/// Wallet password
#[arg(long)]
password: Option<String>,
/// Read wallet password from file
/// Wallet password (INSECURE: using --password exposes the value in shell
/// history and process listings; prefer --password-file for better security)
#[arg(long)]
password: Option<String>,
/// Read wallet password from file (recommended over --password)

Copilot uses AI. Check for mistakes.
balance, amount
)));
}

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token balance check doesn't account for gas costs needed to execute the ERC-20 transfer transaction. While the token balance itself might be sufficient, the transaction will still fail if the wallet doesn't have enough native ETH/gas token to pay for the transaction fee. Consider adding a check for the native token balance to ensure it's sufficient to cover gas costs, similar to what's done in the ETH transfer path.

Suggested change
// Check native token balance to ensure there is enough to pay gas for the ERC-20 transfer
let native_balance = provider
.get_balance(from_address)
.await
.map_err(|e| {
PayError::NetworkError(format!("Failed to get native token balance: {}", e))
})?;
if native_balance.is_zero() {
return Err(PayError::InsufficientBalance(
"Insufficient native token balance to cover gas fees for token transfer"
.to_string(),
));
}

Copilot uses AI. Check for mistakes.
.map_err(|e| PayError::NetworkError(format!("Failed to get gas price: {}", e)))?;

let gas_limit = U256::from(21000); // Standard ETH transfer gas
let total_cost = amount + (gas_limit * U256::from(gas_price));
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculation gas_limit * U256::from(gas_price) could potentially overflow for very high gas prices or if gas_limit is misspecified. While U256 is a 256-bit integer and overflows are unlikely in practice, the subsequent addition amount + (gas_limit * U256::from(gas_price)) could also overflow if the amount is near U256::MAX. Consider using checked arithmetic operations (checked_mul, checked_add) to explicitly handle overflow cases with a proper error message, rather than relying on wrapping behavior.

Suggested change
let total_cost = amount + (gas_limit * U256::from(gas_price));
let gas_price_u256 = U256::from(gas_price);
let gas_cost = gas_limit
.checked_mul(gas_price_u256)
.ok_or_else(|| {
PayError::TransactionFailed("Gas cost overflow when calculating total cost".to_string())
})?;
let total_cost = amount
.checked_add(gas_cost)
.ok_or_else(|| {
PayError::TransactionFailed("Total cost overflow when adding amount and gas".to_string())
})?;

Copilot uses AI. Check for mistakes.
// Send transfer transaction
let tx = token_contract.transfer(to_address, amount);
let pending_tx = tx.send().await.map_err(|e| {
PayError::TransactionFailed(format!("Failed to send transaction: {}", e))
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "Failed to send transaction" is ambiguous and could be clearer. This error could occur due to many reasons: invalid nonce, gas too low, network issues, or node rejection. Consider capturing more specific error information from the alloy error to help users diagnose the issue. For example, if the error indicates "insufficient funds for gas", that's different from "invalid nonce" or "transaction underpriced".

Suggested change
PayError::TransactionFailed(format!("Failed to send transaction: {}", e))
let err_msg = e.to_string();
let hint = if err_msg.to_lowercase().contains("insufficient funds") {
" Possible cause: the sender account does not have enough ETH to cover gas and value."
} else if err_msg.to_lowercase().contains("nonce too low")
|| err_msg.to_lowercase().contains("invalid nonce")
{
" Possible cause: invalid or outdated nonce; ensure there are no conflicting pending transactions and retry."
} else if err_msg.to_lowercase().contains("underpriced")
|| err_msg
.to_lowercase()
.contains("max fee per gas less than block base fee")
{
" Possible cause: gas price/fees are too low; try increasing the gas price or fee caps."
} else {
""
};
PayError::TransactionFailed(format!(
"Failed to send transaction: {}{}",
err_msg, hint
))

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +19
// ERC-20 transfer function
sol! {
#[sol(rpc)]
contract IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
}
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IERC20 interface defined here only includes transfer and balanceOf functions, but doesn't include other standard ERC-20 functions like approve, transferFrom, or allowance. While this is sufficient for the current use case (simple transfers), the comment "ERC-20 transfer function" on line 12 is misleading as it suggests this is a complete ERC-20 interface. Consider either:

  1. Renaming the comment to "Minimal ERC-20 interface for transfers" or similar
  2. Adding the full ERC-20 interface if other tools might need it

Copilot uses AI. Check for mistakes.
PayError::InvalidConfig(_) => 11,
PayError::WalletNotFound(_) => 12,
PayError::InvalidArgument(_) => 20,
_ => 1,
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch-all pattern at line 88 makes the PayError enum non-exhaustive, which reduces the compiler's ability to help detect when new error variants are added but not handled. Since you explicitly enumerate all expected error types in the match above, the _ pattern should never be reached. Consider either removing it to make the match exhaustive, or adding a comment explaining when this case might occur.

Suggested change
_ => 1,
PayError::Other(_) => 1,

Copilot uses AI. Check for mistakes.
.with_value(amount);

let pending_tx = provider.send_transaction(tx).await.map_err(|e| {
PayError::TransactionFailed(format!("Failed to send transaction: {}", e))
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "Failed to send transaction" is ambiguous and could be clearer. This error could occur due to many reasons: invalid nonce, gas too low, network issues, or node rejection. Consider capturing more specific error information from the alloy error to help users diagnose the issue. For example, if the error indicates "insufficient funds for gas", that's different from "invalid nonce" or "transaction underpriced".

Suggested change
PayError::TransactionFailed(format!("Failed to send transaction: {}", e))
let msg = e.to_string();
let reason = if msg.to_lowercase().contains("insufficient funds") {
"insufficient funds for value and/or gas"
} else if msg.to_lowercase().contains("nonce too low") {
"invalid or too-low nonce"
} else if msg.to_lowercase().contains("replacement transaction underpriced")
|| msg.to_lowercase().contains("transaction underpriced")
{
"transaction gas price too low (underpriced)"
} else if msg.to_lowercase().contains("already known") {
"transaction already known to the network"
} else {
"unknown error, check node/provider logs for details"
};
PayError::TransactionFailed(format!(
"Failed to send transaction ({}): {}",
reason, msg
))

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants