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, tempo,
6    tx::{CastTxSender, SendTxOpts, TxParams, fill_transaction_gas_fees},
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    json::{print_json_success, print_scalar},
19    opts::RpcOpts,
20    utils::{LoadConfig, get_chain, get_provider},
21};
22use foundry_common::{
23    FoundryTransactionBuilder,
24    fmt::{UIfmt, UIfmtReceiptExt},
25    provider::{ProviderBuilder, RetryProviderWithSigner},
26    shell,
27    tempo::{TEMPO_BROWSER_GAS_BUFFER, print_resolved_fee_token_selection},
28};
29#[doc(hidden)]
30pub use foundry_config::{Chain, utils::*};
31use foundry_wallets::{TempoAccessKeyConfig, WalletSigner};
32use tempo_alloy::TempoNetwork;
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        has_session: bool,
300    ) -> eyre::Result<bool> {
301        if self.erc20_opts().is_some_and(|erc20| erc20.tempo.is_tempo())
302            || has_session
303            || tempo_access_key.is_some()
304        {
305            return Ok(true);
306        }
307
308        if self.uses_browser_send() {
309            let config = self.rpc_opts().load_config()?;
310            return Ok(get_chain(config.chain, &get_provider(&config)?).await?.is_tempo());
311        }
312
313        Ok(false)
314    }
315
316    fn has_tempo_session(&self) -> eyre::Result<bool> {
317        self.erc20_opts().map_or(Ok(false), |opts| opts.tempo.session_id().map(|id| id.is_some()))
318    }
319
320    pub async fn run(self) -> eyre::Result<()> {
321        let has_session = self.has_tempo_session()?;
322        // Resolve the signer once for state-changing variants.
323        let (signer, tempo_access_key) = match &self {
324            Self::Transfer { send_tx, .. }
325            | Self::Approve { send_tx, .. }
326            | Self::Mint { send_tx, .. }
327            | Self::Burn { send_tx, .. } => {
328                // Only attempt persistent Tempo lookup if --from is set (avoids unnecessary I/O).
329                // Explicit Tempo sessions are resolved after network selection, once the chain is
330                // known.
331                if !has_session && send_tx.eth.wallet.from.is_some() {
332                    let (s, ak) = send_tx.eth.wallet.maybe_signer().await?;
333                    (s, ak)
334                } else {
335                    (None, None)
336                }
337            }
338            _ => (None, None),
339        };
340
341        let is_tempo = self.should_use_tempo_network(&tempo_access_key, has_session).await?;
342
343        if is_tempo {
344            self.run_generic::<TempoNetwork>(signer, tempo_access_key, has_session).await
345        } else {
346            self.run_generic::<Ethereum>(signer, None, has_session).await
347        }
348    }
349
350    #[allow(clippy::large_stack_frames)]
351    pub async fn run_generic<N: Network + RecommendedFillers>(
352        self,
353        pre_resolved_signer: Option<WalletSigner>,
354        tempo_keychain: Option<TempoAccessKeyConfig>,
355        has_session: bool,
356    ) -> eyre::Result<()>
357    where
358        N::TxEnvelope: From<Signed<N::UnsignedTx>>,
359        N::UnsignedTx: SignableTransaction<Signature>,
360        N::TransactionRequest: FoundryTransactionBuilder<N>,
361        N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
362    {
363        let config = self.rpc_opts().load_config()?;
364
365        // Macro to DRY the keychain-vs-normal send pattern for state-changing ops.
366        // The only thing that varies per variant is the IERC20 call expression.
367        macro_rules! erc20_send {
368            (
369                $token:expr,
370                $send_tx:expr,
371                $tx_opts:expr, |
372                $erc20:ident,
373                $provider:ident |
374                $build_tx:expr
375            ) => {{
376                let mut tx_opts = $tx_opts;
377                tempo::ensure_session_not_browser(&tx_opts.tempo, $send_tx.browser.browser)?;
378                let (pre_resolved_signer, tempo_keychain) = if has_session {
379                    let $provider =
380                        ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
381                    let chain = get_chain(config.chain, &$provider).await?;
382                    tempo::resolve_session_or_wallet_signer(
383                        &tx_opts.tempo,
384                        &$send_tx.eth.wallet,
385                        chain.id(),
386                    )
387                    .await?
388                } else {
389                    (pre_resolved_signer, tempo_keychain)
390                };
391                let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash;
392                let expires_at = tx_opts.tempo.resolve_expires();
393                let tempo_sponsor =
394                    if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? };
395                let needs_sponsor_payload = print_sponsor_hash || tempo_sponsor.is_some();
396                if let Some(ts) = expires_at {
397                    sh_status!("Transaction expires at unix timestamp {ts}")?;
398                }
399
400                let timeout = $send_tx.timeout.unwrap_or(config.transaction_timeout);
401                if let Some(ref access_key) = tempo_keychain {
402                    let signer = pre_resolved_signer
403                        .as_ref()
404                        .ok_or_else(|| eyre::eyre!("signer required for access key"))?;
405                    let $provider =
406                        ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
407                    let $erc20 = IERC20::new($token.resolve(&$provider).await?, &$provider);
408                    let mut tx = { $build_tx }.into_transaction_request();
409                    let chain = get_chain(config.chain, &$provider).await?;
410                    tx_opts.apply::<TempoNetwork>(&mut tx, chain.is_legacy());
411                    tempo::fill_access_key_transaction(&$provider, &mut tx, access_key, chain)
412                        .await?;
413                    if needs_sponsor_payload {
414                        if print_sponsor_hash {
415                            let hash = tx
416                                .compute_sponsor_hash(access_key.wallet_address)
417                                .ok_or_else(|| {
418                                    eyre::eyre!(
419                                        "This network does not support sponsored transactions"
420                                    )
421                                })?;
422                            sh_println!("{hash:?}")?;
423                            return Ok(());
424                        }
425                        if let Some(sponsor) = &tempo_sponsor {
426                            sponsor
427                                .attach_and_print::<TempoNetwork>(
428                                    &mut tx,
429                                    access_key.wallet_address,
430                                )
431                                .await?;
432                        }
433                    }
434                    cast_send_with_access_key(
435                        &$provider,
436                        tx,
437                        signer,
438                        access_key,
439                        Some(chain),
440                        $send_tx.cast_async,
441                        $send_tx.confirmations,
442                        timeout,
443                    )
444                    .await?;
445                } else if let Some(browser) = $send_tx.browser.run::<N>().await? {
446                    let $provider = ProviderBuilder::<N>::from_config(&config)?.build()?;
447                    if let Some(interval) = $send_tx.poll_interval {
448                        $provider.client().set_poll_interval(Duration::from_secs(interval));
449                    }
450                    let $erc20 = IERC20::new($token.resolve(&$provider).await?, &$provider);
451                    let mut tx = { $build_tx }.into_transaction_request();
452                    let chain = get_chain(config.chain, &$provider).await?;
453                    tx_opts.apply::<N>(&mut tx, chain.is_legacy());
454                    fill_tx(&$provider, &mut tx, browser.address(), chain, true).await?;
455                    if print_sponsor_hash {
456                        let hash = tx.compute_sponsor_hash(browser.address()).ok_or_else(|| {
457                            eyre::eyre!("This network does not support sponsored transactions")
458                        })?;
459                        sh_println!("{hash:?}")?;
460                        return Ok(());
461                    }
462                    if let Some(sponsor) = &tempo_sponsor {
463                        sponsor.attach_and_print::<N>(&mut tx, browser.address()).await?;
464                    }
465                    print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
466                    let tx_hash = browser.send_transaction_via_browser(tx).await?;
467                    CastTxSender::new(&$provider)
468                        .print_tx_result(
469                            tx_hash,
470                            $send_tx.cast_async,
471                            $send_tx.confirmations,
472                            timeout,
473                        )
474                        .await?
475                } else {
476                    let signer = pre_resolved_signer.unwrap_or($send_tx.eth.wallet.signer().await?);
477                    let from = signer.address();
478                    let $provider = build_provider_with_signer::<N>(&$send_tx, signer)?;
479                    let $erc20 = IERC20::new($token.resolve(&$provider).await?, &$provider);
480                    let mut tx = { $build_tx }.into_transaction_request();
481                    let chain = get_chain(config.chain, &$provider).await?;
482                    tx_opts.apply::<N>(&mut tx, chain.is_legacy());
483                    if needs_sponsor_payload {
484                        fill_tx(&$provider, &mut tx, from, chain, false).await?;
485                        if print_sponsor_hash {
486                            let hash = tx.compute_sponsor_hash(from).ok_or_else(|| {
487                                eyre::eyre!("This network does not support sponsored transactions")
488                            })?;
489                            sh_println!("{hash:?}")?;
490                            return Ok(());
491                        }
492                        if let Some(sponsor) = &tempo_sponsor {
493                            sponsor.attach_and_print::<N>(&mut tx, from).await?;
494                        }
495                    }
496                    cast_send(
497                        $provider,
498                        tx,
499                        Some(chain),
500                        $send_tx.cast_async,
501                        $send_tx.sync,
502                        $send_tx.confirmations,
503                        timeout,
504                    )
505                    .await?;
506                }
507            }};
508        }
509
510        match self {
511            // Read-only
512            Self::Allowance { token, owner, spender, block, .. } => {
513                let provider = get_provider(&config)?;
514                let token = token.resolve(&provider).await?;
515                let owner = owner.resolve(&provider).await?;
516                let spender = spender.resolve(&provider).await?;
517
518                let allowance = IERC20::new(token, &provider)
519                    .allowance(owner, spender)
520                    .block(block.unwrap_or_default())
521                    .call()
522                    .await?;
523
524                if shell::is_json() {
525                    print_json_success(allowance.to_string())?;
526                } else {
527                    sh_println!("{}", format_uint_exp(allowance))?;
528                }
529            }
530            Self::Balance { token, owner, block, .. } => {
531                let provider = get_provider(&config)?;
532                let token = token.resolve(&provider).await?;
533                let owner = owner.resolve(&provider).await?;
534
535                let balance = IERC20::new(token, &provider)
536                    .balanceOf(owner)
537                    .block(block.unwrap_or_default())
538                    .call()
539                    .await?;
540
541                if shell::is_json() {
542                    print_json_success(balance.to_string())?;
543                } else {
544                    sh_println!("{balance}")?;
545                }
546            }
547            Self::Name { token, block, .. } => {
548                let provider = get_provider(&config)?;
549                let token = token.resolve(&provider).await?;
550
551                let name = IERC20::new(token, &provider)
552                    .name()
553                    .block(block.unwrap_or_default())
554                    .call()
555                    .await?;
556
557                print_scalar(name)?;
558            }
559            Self::Symbol { token, block, .. } => {
560                let provider = get_provider(&config)?;
561                let token = token.resolve(&provider).await?;
562
563                let symbol = IERC20::new(token, &provider)
564                    .symbol()
565                    .block(block.unwrap_or_default())
566                    .call()
567                    .await?;
568
569                print_scalar(symbol)?;
570            }
571            Self::Decimals { token, block, .. } => {
572                let provider = get_provider(&config)?;
573                let token = token.resolve(&provider).await?;
574
575                let decimals = IERC20::new(token, &provider)
576                    .decimals()
577                    .block(block.unwrap_or_default())
578                    .call()
579                    .await?;
580                print_scalar(decimals)?;
581            }
582            Self::TotalSupply { token, block, .. } => {
583                let provider = get_provider(&config)?;
584                let token = token.resolve(&provider).await?;
585
586                let total_supply = IERC20::new(token, &provider)
587                    .totalSupply()
588                    .block(block.unwrap_or_default())
589                    .call()
590                    .await?;
591
592                if shell::is_json() {
593                    print_json_success(total_supply.to_string())?;
594                } else {
595                    sh_println!("{}", format_uint_exp(total_supply))?
596                }
597            }
598            // State-changing
599            Self::Transfer { token, to, amount, send_tx, tx: tx_opts, .. } => {
600                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
601                    erc20.transfer(to.resolve(&provider).await?, U256::from_str(&amount)?)
602                })
603            }
604            Self::Approve { token, spender, amount, send_tx, tx: tx_opts, .. } => {
605                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
606                    erc20.approve(spender.resolve(&provider).await?, U256::from_str(&amount)?)
607                })
608            }
609            Self::Mint { token, to, amount, send_tx, tx: tx_opts, .. } => {
610                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
611                    erc20.mint(to.resolve(&provider).await?, U256::from_str(&amount)?)
612                })
613            }
614            Self::Burn { token, amount, send_tx, tx: tx_opts, .. } => {
615                erc20_send!(token, send_tx, tx_opts, |erc20, provider| {
616                    erc20.burn(U256::from_str(&amount)?)
617                })
618            }
619        };
620        Ok(())
621    }
622}
623
624/// Fills from, chain_id, nonce, fees, and gas limit on a transaction request for sponsor/browser
625/// wallet flows. Mirrors the filling logic in the shared tx builder but operates on a
626/// pre-built transaction request from the sol! macro rather than through the builder pipeline.
627/// Only fills fields that haven't already been set by the user.
628async fn fill_tx<N: Network, P: Provider<N>>(
629    provider: &P,
630    tx: &mut N::TransactionRequest,
631    from: Address,
632    chain: Chain,
633    browser: bool,
634) -> eyre::Result<()>
635where
636    N::TransactionRequest: FoundryTransactionBuilder<N>,
637{
638    tx.set_from(from);
639    tx.set_chain_id(chain.id());
640
641    if tx.nonce().is_none() {
642        tx.set_nonce(provider.get_transaction_count(from).await?);
643    }
644
645    let legacy = chain.is_legacy();
646
647    fill_transaction_gas_fees(provider, tx, legacy, browser).await?;
648
649    if tx.gas_limit().is_none() {
650        let mut estimated = provider.estimate_gas(tx.clone()).await?;
651
652        // Browser wallets may sign with P256/WebAuthn instead of secp256k1, which
653        // costs more gas for signature verification on Tempo chains. Add a
654        // conservative buffer since we can't determine the signature type beforehand.
655        if chain.is_tempo() {
656            estimated += TEMPO_BROWSER_GAS_BUFFER;
657        }
658
659        tx.set_gas_limit(estimated);
660    }
661
662    Ok(())
663}