Skip to main content

cast/cmd/
erc20.rs

1use std::{str::FromStr, time::Duration};
2
3use crate::{cmd::send::cast_send, format_uint_exp, tx::SendTxOpts};
4use alloy_consensus::{SignableTransaction, Signed};
5use alloy_eips::BlockId;
6use alloy_ens::NameOrAddress;
7use alloy_network::{Ethereum, EthereumWallet, Network, TransactionBuilder};
8use alloy_primitives::{U64, U256};
9use alloy_provider::{Provider, fillers::RecommendedFillers};
10use alloy_signer::Signature;
11use alloy_sol_types::sol;
12use clap::{Args, Parser};
13use foundry_cli::{
14    opts::{RpcOpts, TempoOpts},
15    utils::{LoadConfig, get_chain, get_provider},
16};
17use foundry_common::{
18    FoundryTransactionBuilder,
19    fmt::{UIfmt, UIfmtReceiptExt},
20    provider::{ProviderBuilder, RetryProviderWithSigner},
21    shell,
22};
23#[doc(hidden)]
24pub use foundry_config::{Chain, utils::*};
25use foundry_wallets::{TempoAccessKeyConfig, WalletSigner};
26use tempo_alloy::TempoNetwork;
27
28sol! {
29    #[sol(rpc)]
30    interface IERC20 {
31        #[derive(Debug)]
32        function name() external view returns (string);
33        function symbol() external view returns (string);
34        function decimals() external view returns (uint8);
35        function totalSupply() external view returns (uint256);
36        function balanceOf(address owner) external view returns (uint256);
37        function transfer(address to, uint256 amount) external returns (bool);
38        function approve(address spender, uint256 amount) external returns (bool);
39        function allowance(address owner, address spender) external view returns (uint256);
40        function mint(address to, uint256 amount) external;
41        function burn(uint256 amount) external;
42    }
43}
44
45/// Transaction options for ERC20 operations.
46///
47/// This struct contains only the transaction options relevant to ERC20 token interactions
48#[derive(Debug, Clone, Args)]
49#[command(next_help_heading = "Transaction options")]
50pub struct Erc20TxOpts {
51    /// Gas limit for the transaction.
52    #[arg(long, env = "ETH_GAS_LIMIT")]
53    pub gas_limit: Option<U256>,
54
55    /// Gas price for legacy transactions, or max fee per gas for EIP1559 transactions.
56    #[arg(long, env = "ETH_GAS_PRICE")]
57    pub gas_price: Option<U256>,
58
59    /// Max priority fee per gas for EIP1559 transactions.
60    #[arg(long, env = "ETH_PRIORITY_GAS_PRICE")]
61    pub priority_gas_price: Option<U256>,
62
63    /// Nonce for the transaction.
64    #[arg(long)]
65    pub nonce: Option<U64>,
66
67    #[command(flatten)]
68    pub tempo: TempoOpts,
69}
70
71/// Creates a provider with a pre-resolved signer.
72pub(crate) fn build_provider_with_signer<N: Network + RecommendedFillers>(
73    tx_opts: &SendTxOpts,
74    signer: WalletSigner,
75) -> eyre::Result<RetryProviderWithSigner<N>>
76where
77    N::TxEnvelope: From<Signed<N::UnsignedTx>>,
78    N::UnsignedTx: SignableTransaction<Signature>,
79{
80    let config = tx_opts.eth.load_config()?;
81    let wallet = EthereumWallet::from(signer);
82    let provider = ProviderBuilder::<N>::from_config(&config)?.build_with_wallet(wallet)?;
83    if let Some(interval) = tx_opts.poll_interval {
84        provider.client().set_poll_interval(Duration::from_secs(interval))
85    }
86    Ok(provider)
87}
88
89impl Erc20TxOpts {
90    /// Applies gas, fee, nonce, and Tempo options to a transaction request.
91    fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, legacy: bool)
92    where
93        N::TransactionRequest: FoundryTransactionBuilder<N>,
94    {
95        if let Some(gas_limit) = self.gas_limit {
96            tx.set_gas_limit(gas_limit.to());
97        }
98
99        if let Some(gas_price) = self.gas_price {
100            if legacy {
101                tx.set_gas_price(gas_price.to());
102            } else {
103                tx.set_max_fee_per_gas(gas_price.to());
104            }
105        }
106
107        if !legacy && let Some(priority_fee) = self.priority_gas_price {
108            tx.set_max_priority_fee_per_gas(priority_fee.to());
109        }
110
111        self.tempo.apply::<N>(tx, self.nonce.map(|n| n.to()));
112    }
113}
114
115/// Interact with ERC20 tokens.
116#[derive(Debug, Parser, Clone)]
117pub enum Erc20Subcommand {
118    /// Query ERC20 token balance.
119    #[command(visible_alias = "b")]
120    Balance {
121        /// The ERC20 token contract address.
122        #[arg(value_parser = NameOrAddress::from_str)]
123        token: NameOrAddress,
124
125        /// The owner to query balance for.
126        #[arg(value_parser = NameOrAddress::from_str)]
127        owner: NameOrAddress,
128
129        /// The block height to query at.
130        #[arg(long, short = 'B')]
131        block: Option<BlockId>,
132
133        #[command(flatten)]
134        rpc: RpcOpts,
135    },
136
137    /// Transfer ERC20 tokens.
138    #[command(visible_aliases = ["t", "send"])]
139    Transfer {
140        /// The ERC20 token contract address.
141        #[arg(value_parser = NameOrAddress::from_str)]
142        token: NameOrAddress,
143
144        /// The recipient address.
145        #[arg(value_parser = NameOrAddress::from_str)]
146        to: NameOrAddress,
147
148        /// The amount to transfer.
149        amount: String,
150
151        #[command(flatten)]
152        send_tx: SendTxOpts,
153
154        #[command(flatten)]
155        tx: Erc20TxOpts,
156    },
157
158    /// Approve ERC20 token spending.
159    #[command(visible_alias = "a")]
160    Approve {
161        /// The ERC20 token contract address.
162        #[arg(value_parser = NameOrAddress::from_str)]
163        token: NameOrAddress,
164
165        /// The spender address.
166        #[arg(value_parser = NameOrAddress::from_str)]
167        spender: NameOrAddress,
168
169        /// The amount to approve.
170        amount: String,
171
172        #[command(flatten)]
173        send_tx: SendTxOpts,
174
175        #[command(flatten)]
176        tx: Erc20TxOpts,
177    },
178
179    /// Query ERC20 token allowance.
180    #[command(visible_alias = "al")]
181    Allowance {
182        /// The ERC20 token contract address.
183        #[arg(value_parser = NameOrAddress::from_str)]
184        token: NameOrAddress,
185
186        /// The owner address.
187        #[arg(value_parser = NameOrAddress::from_str)]
188        owner: NameOrAddress,
189
190        /// The spender address.
191        #[arg(value_parser = NameOrAddress::from_str)]
192        spender: NameOrAddress,
193
194        /// The block height to query at.
195        #[arg(long, short = 'B')]
196        block: Option<BlockId>,
197
198        #[command(flatten)]
199        rpc: RpcOpts,
200    },
201
202    /// Query ERC20 token name.
203    #[command(visible_alias = "n")]
204    Name {
205        /// The ERC20 token contract address.
206        #[arg(value_parser = NameOrAddress::from_str)]
207        token: NameOrAddress,
208
209        /// The block height to query at.
210        #[arg(long, short = 'B')]
211        block: Option<BlockId>,
212
213        #[command(flatten)]
214        rpc: RpcOpts,
215    },
216
217    /// Query ERC20 token symbol.
218    #[command(visible_alias = "s")]
219    Symbol {
220        /// The ERC20 token contract address.
221        #[arg(value_parser = NameOrAddress::from_str)]
222        token: NameOrAddress,
223
224        /// The block height to query at.
225        #[arg(long, short = 'B')]
226        block: Option<BlockId>,
227
228        #[command(flatten)]
229        rpc: RpcOpts,
230    },
231
232    /// Query ERC20 token decimals.
233    #[command(visible_alias = "d")]
234    Decimals {
235        /// The ERC20 token contract address.
236        #[arg(value_parser = NameOrAddress::from_str)]
237        token: NameOrAddress,
238
239        /// The block height to query at.
240        #[arg(long, short = 'B')]
241        block: Option<BlockId>,
242
243        #[command(flatten)]
244        rpc: RpcOpts,
245    },
246
247    /// Query ERC20 token total supply.
248    #[command(visible_alias = "ts")]
249    TotalSupply {
250        /// The ERC20 token contract address.
251        #[arg(value_parser = NameOrAddress::from_str)]
252        token: NameOrAddress,
253
254        /// The block height to query at.
255        #[arg(long, short = 'B')]
256        block: Option<BlockId>,
257
258        #[command(flatten)]
259        rpc: RpcOpts,
260    },
261
262    /// Mint ERC20 tokens (if the token supports minting).
263    #[command(visible_alias = "m")]
264    Mint {
265        /// The ERC20 token contract address.
266        #[arg(value_parser = NameOrAddress::from_str)]
267        token: NameOrAddress,
268
269        /// The recipient address.
270        #[arg(value_parser = NameOrAddress::from_str)]
271        to: NameOrAddress,
272
273        /// The amount to mint.
274        amount: String,
275
276        #[command(flatten)]
277        send_tx: SendTxOpts,
278
279        #[command(flatten)]
280        tx: Erc20TxOpts,
281    },
282
283    /// Burn ERC20 tokens.
284    #[command(visible_alias = "bu")]
285    Burn {
286        /// The ERC20 token contract address.
287        #[arg(value_parser = NameOrAddress::from_str)]
288        token: NameOrAddress,
289
290        /// The amount to burn.
291        amount: String,
292
293        #[command(flatten)]
294        send_tx: SendTxOpts,
295
296        #[command(flatten)]
297        tx: Erc20TxOpts,
298    },
299}
300
301impl Erc20Subcommand {
302    const fn rpc_opts(&self) -> &RpcOpts {
303        match self {
304            Self::Allowance { rpc, .. } => rpc,
305            Self::Approve { send_tx, .. } => &send_tx.eth.rpc,
306            Self::Balance { rpc, .. } => rpc,
307            Self::Transfer { send_tx, .. } => &send_tx.eth.rpc,
308            Self::Name { rpc, .. } => rpc,
309            Self::Symbol { rpc, .. } => rpc,
310            Self::Decimals { rpc, .. } => rpc,
311            Self::TotalSupply { rpc, .. } => rpc,
312            Self::Mint { send_tx, .. } => &send_tx.eth.rpc,
313            Self::Burn { send_tx, .. } => &send_tx.eth.rpc,
314        }
315    }
316
317    const fn erc20_opts(&self) -> Option<&Erc20TxOpts> {
318        match self {
319            Self::Approve { tx, .. }
320            | Self::Transfer { tx, .. }
321            | Self::Mint { tx, .. }
322            | Self::Burn { tx, .. } => Some(tx),
323            Self::Allowance { .. }
324            | Self::Balance { .. }
325            | Self::Name { .. }
326            | Self::Symbol { .. }
327            | Self::Decimals { .. }
328            | Self::TotalSupply { .. } => None,
329        }
330    }
331
332    pub async fn run(self) -> eyre::Result<()> {
333        // Resolve the signer once for state-changing variants.
334        let (signer, tempo_access_key) = match &self {
335            Self::Transfer { send_tx, .. }
336            | Self::Approve { send_tx, .. }
337            | Self::Mint { send_tx, .. }
338            | Self::Burn { send_tx, .. } => {
339                // Only attempt Tempo lookup if --from is set (avoids unnecessary I/O).
340                if send_tx.eth.wallet.from.is_some() {
341                    let (s, ak) = send_tx.eth.wallet.maybe_signer().await?;
342                    (s, ak)
343                } else {
344                    (None, None)
345                }
346            }
347            _ => (None, None),
348        };
349
350        let is_tempo = self.erc20_opts().is_some_and(|erc20| erc20.tempo.is_tempo())
351            || tempo_access_key.is_some();
352
353        if is_tempo {
354            self.run_generic::<TempoNetwork>(signer, tempo_access_key).await
355        } else {
356            self.run_generic::<Ethereum>(signer, None).await
357        }
358    }
359
360    pub async fn run_generic<N: Network + RecommendedFillers>(
361        self,
362        pre_resolved_signer: Option<WalletSigner>,
363        tempo_keychain: Option<TempoAccessKeyConfig>,
364    ) -> eyre::Result<()>
365    where
366        N::TxEnvelope: From<Signed<N::UnsignedTx>>,
367        N::UnsignedTx: SignableTransaction<Signature>,
368        N::TransactionRequest: FoundryTransactionBuilder<N>,
369        N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
370    {
371        let config = self.rpc_opts().load_config()?;
372
373        // Macro to DRY the keychain-vs-normal send pattern for state-changing ops.
374        // The only thing that varies per variant is the IERC20 call expression.
375        macro_rules! erc20_send {
376            (
377                $token:expr,
378                $send_tx:expr,
379                $tx_opts:expr, |
380                $erc20:ident,
381                $provider:ident |
382                $build_tx:expr
383            ) => {{
384                let timeout = $send_tx.timeout.unwrap_or(config.transaction_timeout);
385                if let Some(ref access_key) = tempo_keychain {
386                    let signer =
387                        pre_resolved_signer.as_ref().expect("signer required for access key");
388                    let $provider =
389                        ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
390                    let $erc20 = IERC20::new($token.resolve(&$provider).await?, &$provider);
391                    let mut tx = { $build_tx }.into_transaction_request();
392                    $tx_opts.apply::<TempoNetwork>(
393                        &mut tx,
394                        get_chain(config.chain, &$provider).await?.is_legacy(),
395                    );
396                    apply_tempo_access_key::<TempoNetwork>(&mut tx, Some(access_key));
397                    send_tempo_keychain(
398                        &$provider,
399                        tx,
400                        signer,
401                        access_key,
402                        $send_tx.cast_async,
403                        $send_tx.confirmations,
404                        timeout,
405                    )
406                    .await?
407                } else {
408                    let signer = pre_resolved_signer.unwrap_or($send_tx.eth.wallet.signer().await?);
409                    let $provider = build_provider_with_signer::<N>(&$send_tx, signer)?;
410                    let $erc20 = IERC20::new($token.resolve(&$provider).await?, &$provider);
411                    let mut tx = { $build_tx }.into_transaction_request();
412                    $tx_opts.apply::<N>(
413                        &mut tx,
414                        get_chain(config.chain, &$provider).await?.is_legacy(),
415                    );
416                    cast_send(
417                        $provider,
418                        tx,
419                        $send_tx.cast_async,
420                        $send_tx.sync,
421                        $send_tx.confirmations,
422                        timeout,
423                    )
424                    .await?
425                }
426            }};
427        }
428
429        match self {
430            // Read-only
431            Self::Allowance { token, owner, spender, block, .. } => {
432                let provider = get_provider(&config)?;
433                let token = token.resolve(&provider).await?;
434                let owner = owner.resolve(&provider).await?;
435                let spender = spender.resolve(&provider).await?;
436
437                let allowance = IERC20::new(token, &provider)
438                    .allowance(owner, spender)
439                    .block(block.unwrap_or_default())
440                    .call()
441                    .await?;
442
443                if shell::is_json() {
444                    sh_println!("{}", serde_json::to_string(&allowance.to_string())?)?
445                } else {
446                    sh_println!("{}", format_uint_exp(allowance))?
447                }
448            }
449            Self::Balance { token, owner, block, .. } => {
450                let provider = get_provider(&config)?;
451                let token = token.resolve(&provider).await?;
452                let owner = owner.resolve(&provider).await?;
453
454                let balance = IERC20::new(token, &provider)
455                    .balanceOf(owner)
456                    .block(block.unwrap_or_default())
457                    .call()
458                    .await?;
459
460                if shell::is_json() {
461                    sh_println!("{}", serde_json::to_string(&balance.to_string())?)?
462                } else {
463                    sh_println!("{}", format_uint_exp(balance))?
464                }
465            }
466            Self::Name { token, block, .. } => {
467                let provider = get_provider(&config)?;
468                let token = token.resolve(&provider).await?;
469
470                let name = IERC20::new(token, &provider)
471                    .name()
472                    .block(block.unwrap_or_default())
473                    .call()
474                    .await?;
475
476                if shell::is_json() {
477                    sh_println!("{}", serde_json::to_string(&name)?)?
478                } else {
479                    sh_println!("{}", name)?
480                }
481            }
482            Self::Symbol { token, block, .. } => {
483                let provider = get_provider(&config)?;
484                let token = token.resolve(&provider).await?;
485
486                let symbol = IERC20::new(token, &provider)
487                    .symbol()
488                    .block(block.unwrap_or_default())
489                    .call()
490                    .await?;
491
492                if shell::is_json() {
493                    sh_println!("{}", serde_json::to_string(&symbol)?)?
494                } else {
495                    sh_println!("{}", symbol)?
496                }
497            }
498            Self::Decimals { token, block, .. } => {
499                let provider = get_provider(&config)?;
500                let token = token.resolve(&provider).await?;
501
502                let decimals = IERC20::new(token, &provider)
503                    .decimals()
504                    .block(block.unwrap_or_default())
505                    .call()
506                    .await?;
507                if shell::is_json() {
508                    sh_println!("{}", serde_json::to_string(&decimals)?)?
509                } else {
510                    sh_println!("{}", decimals)?
511                }
512            }
513            Self::TotalSupply { token, block, .. } => {
514                let provider = get_provider(&config)?;
515                let token = token.resolve(&provider).await?;
516
517                let total_supply = IERC20::new(token, &provider)
518                    .totalSupply()
519                    .block(block.unwrap_or_default())
520                    .call()
521                    .await?;
522
523                if shell::is_json() {
524                    sh_println!("{}", serde_json::to_string(&total_supply.to_string())?)?
525                } else {
526                    sh_println!("{}", format_uint_exp(total_supply))?
527                }
528            }
529            // State-changing
530            Self::Transfer { token, to, amount, send_tx, tx: tx_opts, .. } => {
531                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
532                    erc20.transfer(to.resolve(&provider).await?, U256::from_str(&amount)?)
533                })
534            }
535            Self::Approve { token, spender, amount, send_tx, tx: tx_opts, .. } => {
536                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
537                    erc20.approve(spender.resolve(&provider).await?, U256::from_str(&amount)?)
538                })
539            }
540            Self::Mint { token, to, amount, send_tx, tx: tx_opts, .. } => {
541                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
542                    erc20.mint(to.resolve(&provider).await?, U256::from_str(&amount)?)
543                })
544            }
545            Self::Burn { token, amount, send_tx, tx: tx_opts, .. } => {
546                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
547                    erc20.burn(U256::from_str(&amount)?)
548                })
549            }
550        };
551        Ok(())
552    }
553}
554
555/// Applies Tempo access key fields (from, key_id) to a transaction request.
556///
557/// Note: `key_authorization` is intentionally not set here. It is only included
558/// if the key is not yet provisioned on-chain (checked in [`send_tempo_keychain`]).
559fn apply_tempo_access_key<N: Network>(
560    tx: &mut N::TransactionRequest,
561    config: Option<&TempoAccessKeyConfig>,
562) where
563    N::TransactionRequest: FoundryTransactionBuilder<N>,
564{
565    if let Some(config) = config {
566        tx.set_from(config.wallet_address);
567        tx.set_key_id(config.key_address);
568    }
569}
570
571/// Sends a Tempo transaction using access key (keychain V2 mode).
572///
573/// Signs the transaction with the access key and sends it via `send_raw_transaction`,
574/// bypassing `EthereumWallet`. Only includes `key_authorization` if the key is not yet
575/// provisioned on-chain.
576async fn send_tempo_keychain<P: Provider<TempoNetwork>>(
577    provider: &P,
578    tx: <TempoNetwork as Network>::TransactionRequest,
579    signer: &WalletSigner,
580    access_key: &TempoAccessKeyConfig,
581    cast_async: bool,
582    confirmations: u64,
583    timeout: u64,
584) -> eyre::Result<()> {
585    let raw_tx = tx
586        .sign_with_access_key(
587            provider,
588            signer,
589            access_key.wallet_address,
590            access_key.key_address,
591            access_key.key_authorization.as_ref(),
592        )
593        .await?;
594
595    let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash();
596
597    let cast = crate::tx::CastTxSender::new(provider);
598    cast.print_tx_result(tx_hash, cast_async, confirmations, timeout).await
599}