Skip to main content

cast/cmd/
erc20.rs

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