From d849b29315a7464e86b6a801f8b6ed9464f4aea5 Mon Sep 17 00:00:00 2001 From: steven Date: Sat, 28 Mar 2026 12:41:02 -0600 Subject: [PATCH] feat(cast): add erc20 create subcommand for TIP-20 token deployment --- Cargo.lock | 1 + Cargo.toml | 1 + crates/cast/Cargo.toml | 1 + crates/cast/src/cmd/erc20.rs | 134 ++++++++++++++++++++- crates/cast/src/opts.rs | 2 +- crates/cast/src/tempo/iso4217.rs | 24 ++++ crates/cast/src/{tempo.rs => tempo/mod.rs} | 2 + 7 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 crates/cast/src/tempo/iso4217.rs rename crates/cast/src/{tempo.rs => tempo/mod.rs} (97%) diff --git a/Cargo.lock b/Cargo.lock index 1368285866f2b..3486152adf3c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2716,6 +2716,7 @@ dependencies = [ "serde_json", "tempfile", "tempo-alloy", + "tempo-contracts", "tempo-primitives", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index db7157ef3e55f..f28cdd88672fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -447,6 +447,7 @@ tempo-primitives = { git = "https://github.com/tempoxyz/tempo", branch = "alloy- tempo-alloy = { git = "https://github.com/tempoxyz/tempo", branch = "alloy-2.0", default-features = false } tempo-evm = { git = "https://github.com/tempoxyz/tempo", branch = "alloy-2.0", default-features = false } tempo-revm = { git = "https://github.com/tempoxyz/tempo", branch = "alloy-2.0", default-features = false } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", branch = "alloy-2.0", default-features = false } ## Pinned dependencies. Enabled for the workspace in crates/test-utils. diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 5be40b7a253bc..344f0a0b61ea8 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -58,6 +58,7 @@ alloy-ens = { workspace = true, features = ["provider"] } alloy-eips.workspace = true tempo-alloy.workspace = true tempo-primitives.workspace = true +tempo-contracts.workspace = true alloy-evm.workspace = true op-alloy-flz.workspace = true diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index b3c39936fe759..5910b18a237e7 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -1,11 +1,16 @@ use std::{str::FromStr, time::Duration}; -use crate::{cmd::send::cast_send, format_uint_exp, tx::SendTxOpts}; +use crate::{ + cmd::send::cast_send, + format_uint_exp, + tempo::iso4217::{is_iso4217_currency, iso4217_warning_message}, + tx::SendTxOpts, +}; use alloy_consensus::{SignableTransaction, Signed}; use alloy_eips::BlockId; use alloy_ens::NameOrAddress; use alloy_network::{AnyNetwork, EthereumWallet, Network, TransactionBuilder}; -use alloy_primitives::{U64, U256}; +use alloy_primitives::{B256, U64, U256}; use alloy_provider::{Provider, fillers::RecommendedFillers}; use alloy_signer::Signature; use alloy_sol_types::sol; @@ -24,6 +29,7 @@ pub use foundry_config::{Chain, utils::*}; use foundry_primitives::FoundryTransactionBuilder; use foundry_wallets::{TempoAccessKeyConfig, WalletSigner}; use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::TIP20_FACTORY_ADDRESS; sol! { #[sol(rpc)] @@ -40,6 +46,18 @@ sol! { function mint(address to, uint256 amount) external; function burn(uint256 amount) external; } + + #[sol(rpc)] + interface ITIP20Factory { + function createToken( + string memory name, + string memory symbol, + string memory currency, + address quoteToken, + address admin, + bytes32 salt + ) external returns (address token); + } } /// Transaction options for ERC20 operations. @@ -296,6 +314,42 @@ pub enum Erc20Subcommand { #[command(flatten)] tx: Erc20TxOpts, }, + + /// Create a new TIP-20 token via the TIP20Factory. + #[command(visible_alias = "c")] + Create { + /// The token name (e.g. "US Dollar Coin"). + name: String, + + /// The token symbol (e.g. "USDC"). + symbol: String, + + /// The ISO 4217 currency code (e.g. "USD", "EUR", "GBP"). + /// This field is IMMUTABLE after creation and affects fee payment + /// eligibility, DEX routing, and quote token pairing. + currency: String, + + /// The TIP-20 quote token address used for exchange pricing. + #[arg(value_parser = NameOrAddress::from_str)] + quote_token: NameOrAddress, + + /// The admin address to receive DEFAULT_ADMIN_ROLE on the new token. + #[arg(value_parser = NameOrAddress::from_str)] + admin: NameOrAddress, + + /// A unique salt for deterministic address derivation (hex-encoded bytes32). + salt: B256, + + /// Skip the ISO 4217 currency code validation warning. + #[arg(long)] + force: bool, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: Erc20TxOpts, + }, } impl Erc20Subcommand { @@ -311,6 +365,7 @@ impl Erc20Subcommand { Self::TotalSupply { rpc, .. } => rpc, Self::Mint { send_tx, .. } => &send_tx.eth.rpc, Self::Burn { send_tx, .. } => &send_tx.eth.rpc, + Self::Create { send_tx, .. } => &send_tx.eth.rpc, } } @@ -319,7 +374,8 @@ impl Erc20Subcommand { Self::Approve { tx, .. } | Self::Transfer { tx, .. } | Self::Mint { tx, .. } - | Self::Burn { tx, .. } => Some(tx), + | Self::Burn { tx, .. } + | Self::Create { tx, .. } => Some(tx), Self::Allowance { .. } | Self::Balance { .. } | Self::Name { .. } @@ -335,7 +391,8 @@ impl Erc20Subcommand { Self::Transfer { send_tx, .. } | Self::Approve { send_tx, .. } | Self::Mint { send_tx, .. } - | Self::Burn { send_tx, .. } => { + | Self::Burn { send_tx, .. } + | Self::Create { send_tx, .. } => { // Only attempt Tempo lookup if --from is set (avoids unnecessary I/O). if send_tx.eth.wallet.from.is_some() { let (s, ak) = send_tx.eth.wallet.maybe_signer().await?; @@ -547,6 +604,75 @@ impl Erc20Subcommand { erc20.burn(U256::from_str(&amount)?) }) } + Self::Create { + name, + symbol, + currency, + quote_token, + admin, + salt, + force, + send_tx, + tx: tx_opts, + } => { + // Validate currency code against ISO 4217 + if !is_iso4217_currency(¤cy) && !force { + sh_warn!("{}", iso4217_warning_message(¤cy))?; + let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?; + if !matches!(response.trim(), "y" | "Y") { + sh_println!("Aborted.")?; + return Ok(()); + } + } + + let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout); + if let Some(ref access_key) = tempo_keychain { + let signer = + pre_resolved_signer.as_ref().expect("signer required for access key"); + let provider = + ProviderBuilder::::from_config(&config)?.build()?; + let quote_token_addr = quote_token.resolve(&provider).await?; + let admin_addr = admin.resolve(&provider).await?; + let mut tx = ITIP20Factory::new(TIP20_FACTORY_ADDRESS, &provider) + .createToken(name, symbol, currency, quote_token_addr, admin_addr, salt) + .into_transaction_request(); + tx_opts.apply::( + &mut tx, + get_chain(config.chain, &provider).await?.is_legacy(), + ); + apply_tempo_access_key::(&mut tx, Some(access_key)); + // TODO: pass `send_tx.sync` once `send_raw_sync` is added to `CastTxSender` + send_tempo_keychain( + &provider, + tx, + signer, + access_key, + send_tx.cast_async, + send_tx.confirmations, + timeout, + ) + .await? + } else { + let signer = pre_resolved_signer.unwrap_or(send_tx.eth.wallet.signer().await?); + let provider = build_provider_with_signer::(&send_tx, signer)?; + let quote_token_addr = quote_token.resolve(&provider).await?; + let admin_addr = admin.resolve(&provider).await?; + let mut tx = ITIP20Factory::new(TIP20_FACTORY_ADDRESS, &provider) + .createToken(name, symbol, currency, quote_token_addr, admin_addr, salt) + .into_transaction_request(); + tx_opts + .apply::(&mut tx, get_chain(config.chain, &provider).await?.is_legacy()); + cast_send( + provider, + tx, + send_tx.cast_async, + send_tx.sync, + send_tx.confirmations, + timeout, + ) + .await? + } + } }; Ok(()) } diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index ad36557deef6e..62336f1c2e796 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -1158,7 +1158,7 @@ pub enum CastSubcommand { DAEstimate(DAEstimateArgs), /// ERC20 token operations. - #[command(visible_alias = "erc20")] + #[command(visible_alias = "erc20", aliases = ["tip20"])] Erc20Token { #[command(subcommand)] command: Erc20Subcommand, diff --git a/crates/cast/src/tempo/iso4217.rs b/crates/cast/src/tempo/iso4217.rs new file mode 100644 index 0000000000000..28cb58c1c2acb --- /dev/null +++ b/crates/cast/src/tempo/iso4217.rs @@ -0,0 +1,24 @@ +pub use tempo_contracts::precompiles::is_iso4217_currency; + +/// Returns a warning message for non-ISO 4217 currency codes used in TIP-20 token creation. +pub fn iso4217_warning_message(currency: &str) -> String { + let hyperlink = |url: &str| format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); + let tip20_docs = hyperlink("https://docs.tempo.xyz/protocol/tip20/overview"); + let iso_docs = hyperlink("https://www.iso.org/iso-4217-currency-codes.html"); + + format!( + "\"{currency}\" is not a recognized ISO 4217 currency code.\n\ + \n\ + If the token you are trying to deploy is a fiat-backed stablecoin, Tempo strongly\n\ + recommends that the currency code field be the ISO-4217 currency code of the fiat\n\ + currency your token tracks (e.g. \"USD\", \"EUR\", \"GBP\").\n\ + \n\ + The currency field is IMMUTABLE after token creation and affects fee payment\n\ + eligibility, DEX routing, and quote token pairing. Only \"USD\"-denominated tokens\n\ + can be used to pay transaction fees on Tempo.\n\ + \n\ + Learn more:\n \ + - Tempo TIP-20 docs: {tip20_docs}\n \ + - ISO 4217 standard: {iso_docs}" + ) +} diff --git a/crates/cast/src/tempo.rs b/crates/cast/src/tempo/mod.rs similarity index 97% rename from crates/cast/src/tempo.rs rename to crates/cast/src/tempo/mod.rs index 3c64491aaea63..73a0e22fe523f 100644 --- a/crates/cast/src/tempo.rs +++ b/crates/cast/src/tempo/mod.rs @@ -1,3 +1,5 @@ +pub mod iso4217; + use alloy_primitives::Address; use alloy_provider::Provider; use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};