Skip to main content

cast/cmd/
erc20.rs

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