Skip to main content

cast/
tx.rs

1use crate::traces::identifier::SignaturesIdentifier;
2use alloy_consensus::{SidecarBuilder, SimpleCoder};
3use alloy_dyn_abi::ErrorExt;
4use alloy_ens::NameOrAddress;
5use alloy_json_abi::Function;
6use alloy_network::{Network, ReceiptResponse, TransactionBuilder};
7use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U64, U256, hex};
8use alloy_provider::{PendingTransactionBuilder, Provider};
9use alloy_rpc_types::{AccessList, Authorization, TransactionInputKind};
10use alloy_signer::Signer;
11use alloy_transport::TransportError;
12use clap::Args;
13use eyre::{Result, WrapErr};
14use foundry_cli::{
15    opts::{CliAuthorizationList, EthereumOpts, TempoOpts, TransactionOpts},
16    utils::{self, apply_gas_estimate_multiplier, parse_function_args},
17};
18use foundry_common::{
19    FoundryTransactionBuilder, TransactionReceiptWithRevertReason,
20    fmt::*,
21    get_pretty_receipt_w_reason_attr,
22    provider::fee::{estimate_eip1559_fees, resolve_broadcast_eip1559_fees},
23    shell,
24};
25use foundry_config::{Chain, Config, Eip1559FeeEstimatePreset};
26use foundry_wallets::{BrowserWalletOpts, TempoAccountsWallet, WalletOpts, WalletSigner};
27use itertools::Itertools;
28use std::{fmt::Write, marker::PhantomData, str::FromStr, time::Duration};
29
30#[derive(Debug, Clone, Args)]
31pub struct SendTxOpts {
32    /// Only print the transaction hash and exit immediately.
33    #[arg(id = "async", long = "async", alias = "cast-async", env = "CAST_ASYNC")]
34    pub cast_async: bool,
35
36    /// Wait for transaction receipt synchronously instead of polling.
37    /// Note: uses `eth_sendTransactionSync` or `eth_sendRawTransactionSync`, which may not be
38    /// supported by all clients.
39    #[arg(long, conflicts_with = "async")]
40    pub sync: bool,
41
42    /// The number of confirmations until the receipt is fetched.
43    #[arg(long, default_value = "1")]
44    pub confirmations: u64,
45
46    /// Timeout for sending the transaction.
47    #[arg(long, env = "ETH_TIMEOUT")]
48    pub timeout: Option<u64>,
49
50    /// Polling interval for transaction receipts (in seconds).
51    #[arg(long, alias = "poll-interval", env = "ETH_POLL_INTERVAL")]
52    pub poll_interval: Option<u64>,
53
54    /// Ethereum options
55    #[command(flatten)]
56    pub eth: EthereumOpts,
57
58    /// Browser wallet options
59    #[command(flatten)]
60    pub browser: BrowserWalletOpts,
61}
62
63/// Applies the `--poll-interval` (in seconds) to a provider's receipt polling when one was given.
64pub(crate) fn apply_poll_interval<N: Network>(provider: &impl Provider<N>, interval: Option<u64>) {
65    if let Some(interval) = interval {
66        provider.client().set_poll_interval(Duration::from_secs(interval));
67    }
68}
69
70/// Transaction options shared across cast commands that submit on-chain transactions.
71#[derive(Debug, Clone, Args)]
72#[command(next_help_heading = "Transaction options")]
73pub struct TxParams {
74    /// Gas limit for the transaction.
75    #[arg(long, env = "ETH_GAS_LIMIT")]
76    pub gas_limit: Option<U256>,
77
78    /// Gas price for legacy transactions, or max fee per gas for EIP1559 transactions.
79    #[arg(long, env = "ETH_GAS_PRICE")]
80    pub gas_price: Option<U256>,
81
82    /// Max priority fee per gas for EIP1559 transactions.
83    #[arg(long, env = "ETH_PRIORITY_GAS_PRICE")]
84    pub priority_gas_price: Option<U256>,
85
86    /// Nonce for the transaction.
87    #[arg(long)]
88    pub nonce: Option<U64>,
89
90    #[command(flatten)]
91    pub tempo: TempoOpts,
92}
93
94impl TxParams {
95    pub(crate) fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, legacy: bool)
96    where
97        N::TransactionRequest: FoundryTransactionBuilder<N>,
98    {
99        self.clone().into_transaction_opts().apply::<N>(tx, legacy);
100    }
101
102    /// Converts the shared compact transaction options into the full `cast send` options.
103    pub(crate) fn into_transaction_opts(self) -> TransactionOpts {
104        TransactionOpts {
105            gas_limit: self.gas_limit,
106            gas_price: self.gas_price,
107            priority_gas_price: self.priority_gas_price,
108            value: None,
109            nonce: self.nonce,
110            legacy: false,
111            blob: false,
112            eip4844: false,
113            blob_gas_price: None,
114            auth: Vec::new(),
115            access_list: None,
116            tempo: self.tempo,
117        }
118    }
119}
120
121/// Different sender kinds used by [`CastTxBuilder`].
122pub enum SenderKind<'a> {
123    /// An address without signer. Used for read-only calls and transactions sent through unlocked
124    /// accounts.
125    Address(Address),
126    /// A reference to a signer.
127    Signer(&'a WalletSigner),
128    /// An owned signer.
129    OwnedSigner(Box<WalletSigner>),
130}
131
132impl SenderKind<'_> {
133    /// Resolves the name to an Ethereum Address.
134    pub fn address(&self) -> Address {
135        match self {
136            Self::Address(addr) => *addr,
137            Self::Signer(signer) => signer.address(),
138            Self::OwnedSigner(signer) => signer.address(),
139        }
140    }
141
142    /// Resolves the sender from the wallet options.
143    ///
144    /// Prefers a configured signer (or Tempo wallet account) over `from`, and falls back to the
145    /// zero address when neither is available.
146    pub async fn from_wallet_opts(mut opts: WalletOpts) -> Result<Self> {
147        let from = opts.from.take();
148        let (signer, tempo_wallet) = opts.maybe_signer().await?;
149        Ok(if let Some(signer) = signer {
150            signer.into()
151        } else if let Some(tempo_wallet) = tempo_wallet {
152            tempo_wallet.account().into()
153        } else {
154            from.unwrap_or_default().into()
155        })
156    }
157
158    /// Returns the signer if available.
159    pub fn as_signer(&self) -> Option<&WalletSigner> {
160        match self {
161            Self::Signer(signer) => Some(signer),
162            Self::OwnedSigner(signer) => Some(signer.as_ref()),
163            Self::Address(_) => None,
164        }
165    }
166}
167
168impl From<Address> for SenderKind<'_> {
169    fn from(addr: Address) -> Self {
170        Self::Address(addr)
171    }
172}
173
174impl<'a> From<&'a WalletSigner> for SenderKind<'a> {
175    fn from(signer: &'a WalletSigner) -> Self {
176        Self::Signer(signer)
177    }
178}
179
180impl From<WalletSigner> for SenderKind<'_> {
181    fn from(signer: WalletSigner) -> Self {
182        Self::OwnedSigner(Box::new(signer))
183    }
184}
185
186/// Resolves the sender of a read-only request: the browser wallet address when `--browser` is
187/// set, otherwise the wallet options. Also returns whether the browser wallet was used.
188pub(crate) async fn read_only_sender<N: Network>(
189    browser: &BrowserWalletOpts,
190    wallet: WalletOpts,
191    tempo: &TempoOpts,
192    chain_id: u64,
193) -> Result<(SenderKind<'static>, bool)> {
194    crate::tempo::ensure_session_not_browser(tempo, browser.browser)?;
195    if let Some(session) = tempo.session_signer_for_wallet(&wallet, chain_id)? {
196        return Ok((session.access_key.account().into(), false));
197    }
198
199    Ok(match browser.run::<N>().await? {
200        Some(browser) => (browser.address().into(), true),
201        None => (SenderKind::from_wallet_opts(wallet).await?, false),
202    })
203}
204
205/// Validates that `sender` can resolve every EIP-7702 authorization.
206pub(crate) fn validate_authorizations(
207    authorizations: &[CliAuthorizationList],
208    sender: &SenderKind<'_>,
209) -> Result<()> {
210    let address_auth_count = authorizations
211        .iter()
212        .filter(|auth| matches!(auth, CliAuthorizationList::Address(_)))
213        .count();
214    if address_auth_count > 1 {
215        eyre::bail!(
216            "Multiple address-based authorizations provided. Only one address can be specified; \
217             use pre-signed authorizations (hex-encoded) for multiple authorizations."
218        );
219    }
220    if address_auth_count == 1 && sender.as_signer().is_none() {
221        eyre::bail!(
222            "No signer available to sign authorization. \
223             Provide a pre-signed authorization (hex-encoded) instead."
224        );
225    }
226
227    Ok(())
228}
229
230/// Resolves the sending signer, falling back to the wallet options, and validates it against an
231/// explicit `--from`.
232pub(crate) async fn resolve_send_signer(
233    pre_resolved: Option<WalletSigner>,
234    eth: &EthereumOpts,
235) -> Result<(WalletSigner, Address)> {
236    let signer = match pre_resolved {
237        Some(signer) => signer,
238        None => eth.wallet.signer().await?,
239    };
240    let from = signer.address();
241    validate_from_address(eth.wallet.from, from)?;
242    Ok((signer, from))
243}
244
245/// Prevents a misconfigured hwlib from sending a transaction that defies user-specified --from
246pub fn validate_from_address(
247    specified_from: Option<Address>,
248    signer_address: Address,
249) -> Result<()> {
250    if let Some(specified_from) = specified_from
251        && specified_from != signer_address
252    {
253        eyre::bail!(
254                "\
255The specified sender via CLI/env vars does not match the sender configured via
256the hardware wallet's HD Path.
257Please use the `--hd-path <PATH>` parameter to specify the BIP32 Path which
258corresponds to the sender, or let foundry automatically detect it by not specifying any sender address."
259            );
260    }
261    Ok(())
262}
263
264/// Initial state.
265#[derive(Debug)]
266pub struct InitState;
267
268/// State with known [TxKind].
269#[derive(Debug)]
270pub struct ToState {
271    to: Option<Address>,
272}
273
274/// State with known input for the transaction.
275#[derive(Debug)]
276pub struct InputState {
277    kind: TxKind,
278    input: Vec<u8>,
279    func: Option<Function>,
280}
281
282pub struct CastTxSender<N, P> {
283    provider: P,
284    _phantom: PhantomData<N>,
285}
286
287impl<N: Network, P: Provider<N>> CastTxSender<N, P>
288where
289    N::TransactionRequest: FoundryTransactionBuilder<N>,
290    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
291{
292    /// Creates a new Cast instance responsible for sending transactions.
293    pub const fn new(provider: P) -> Self {
294        Self { provider, _phantom: PhantomData }
295    }
296
297    /// Sends a transaction and waits for receipt synchronously
298    pub async fn send_sync(&self, tx: N::TransactionRequest) -> Result<(B256, String)> {
299        let receipt = self.provider.send_transaction_sync(tx).await?;
300        self.finish(receipt, None).await
301    }
302
303    /// Sends a transaction and returns the pending transaction handle.
304    pub async fn send(&self, tx: N::TransactionRequest) -> Result<PendingTransactionBuilder<N>> {
305        Ok(self.provider.send_transaction(tx).await?)
306    }
307
308    /// Sends a raw RLP-encoded transaction and waits for its receipt synchronously.
309    pub async fn send_raw_sync(&self, raw_tx: &[u8]) -> Result<(B256, String)> {
310        let receipt = self.provider.send_raw_transaction_sync(raw_tx).await?;
311        self.finish(receipt, None).await
312    }
313
314    /// Prints the transaction hash (if async) or waits for the receipt and prints it.
315    ///
316    /// This is the shared "output" path used by both the normal send flow and the browser wallet
317    /// flow (which sends the transaction out-of-band and only has a tx hash).
318    pub async fn print_tx_result(
319        &self,
320        tx_hash: B256,
321        cast_async: bool,
322        confs: u64,
323        timeout: u64,
324    ) -> Result<()> {
325        if cast_async {
326            sh_println!("{tx_hash:#x}")?;
327        } else {
328            let receipt =
329                self.receipt(format!("{tx_hash:#x}"), None, confs, Some(timeout), false).await?;
330            sh_println!("{receipt}")?;
331        }
332        Ok(())
333    }
334
335    /// Fetches the receipt of `tx_hash`, polling for it unless `cast_async` is set, and formats
336    /// it (or the requested `field`).
337    pub async fn receipt(
338        &self,
339        tx_hash: String,
340        field: Option<String>,
341        confs: u64,
342        timeout: Option<u64>,
343        cast_async: bool,
344    ) -> Result<String> {
345        let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?;
346        let receipt = match self.provider.get_transaction_receipt(tx_hash).await? {
347            Some(receipt) => receipt,
348            None if cast_async => eyre::bail!("tx not found: {tx_hash:?}"),
349            None => {
350                PendingTransactionBuilder::<N>::new(self.provider.root().clone(), tx_hash)
351                    .with_required_confirmations(confs)
352                    .with_timeout(timeout.map(Duration::from_secs))
353                    .get_receipt()
354                    .await?
355            }
356        };
357        Ok(self.finish(receipt, field).await?.1)
358    }
359
360    /// Attaches the revert reason (best effort) and formats the receipt.
361    async fn finish(
362        &self,
363        receipt: N::ReceiptResponse,
364        field: Option<String>,
365    ) -> Result<(B256, String)> {
366        let mut receipt = TransactionReceiptWithRevertReason::<N> { receipt, revert_reason: None };
367        let tx_hash = receipt.receipt.transaction_hash();
368        let _ = receipt.update_revert_reason(&self.provider).await;
369        let formatted = if let Some(field) = field {
370            get_pretty_receipt_w_reason_attr(&receipt, &field)
371                .ok_or_else(|| eyre::eyre!("invalid receipt field: {field}"))?
372        } else if shell::is_json() {
373            // to_value first to sort json object keys
374            serde_json::to_value(&receipt)?.to_string()
375        } else {
376            receipt.pretty()
377        };
378        Ok((tx_hash, formatted))
379    }
380}
381
382/// Builder type constructing generic TransactionRequest from cast send/mktx inputs.
383///
384/// It is implemented as a stateful builder with expected state transition of [InitState] ->
385/// [ToState] -> [InputState].
386#[derive(Debug)]
387pub struct CastTxBuilder<N: Network, P, S> {
388    inner: TxBuilderInner<N, P>,
389    state: S,
390}
391
392/// The state-independent part of [`CastTxBuilder`].
393#[derive(Debug)]
394struct TxBuilderInner<N: Network, P> {
395    provider: P,
396    tx: N::TransactionRequest,
397    /// Whether the transaction should be sent as a legacy transaction.
398    legacy: bool,
399    blob: bool,
400    /// Whether the blob transaction should use EIP-4844 (legacy) format instead of EIP-7594.
401    eip4844: bool,
402    /// Whether to fill gas, fees and nonce. Set to `false` for read-only calls
403    /// (eth_call, eth_estimateGas, eth_createAccessList).
404    fill: bool,
405    /// Whether the filled transaction will be submitted through a browser wallet.
406    browser: bool,
407    /// The preset used when estimating EIP-1559 fees.
408    eip1559_fee_estimate: Eip1559FeeEstimatePreset,
409    /// Optional percentage applied to provider gas estimates.
410    gas_estimate_multiplier: Option<u64>,
411    auth: Vec<CliAuthorizationList>,
412    chain: Chain,
413    etherscan_api_key: Option<String>,
414    etherscan_api_url: Option<String>,
415    access_list: Option<Option<AccessList>>,
416}
417
418impl<N: Network, P, S> CastTxBuilder<N, P, S> {
419    /// Returns the resolved chain for this builder.
420    pub const fn chain(&self) -> Chain {
421        self.inner.chain
422    }
423
424    /// Returns the Etherscan API key and URL resolved for this builder's chain.
425    pub(crate) fn etherscan_api(&self) -> (Option<&str>, Option<&str>) {
426        (self.inner.etherscan_api_key.as_deref(), self.inner.etherscan_api_url.as_deref())
427    }
428
429    /// Returns the transaction request being built.
430    pub(crate) const fn tx_mut(&mut self) -> &mut N::TransactionRequest {
431        &mut self.inner.tx
432    }
433
434    /// Marks this transaction as destined for browser wallet submission.
435    pub const fn with_browser_wallet(mut self) -> Self {
436        self.inner.browser = true;
437        self
438    }
439
440    /// Applies a percentage multiplier to provider gas estimates.
441    pub const fn with_gas_estimate_multiplier(mut self, multiplier: Option<u64>) -> Self {
442        self.inner.gas_estimate_multiplier = multiplier;
443        self
444    }
445
446    /// Returns whether this builder contains any EIP-7702 authorizations.
447    pub(crate) const fn has_auth(&self) -> bool {
448        !self.inner.auth.is_empty()
449    }
450
451    /// Validates that the configured sender can resolve all EIP-7702 authorizations.
452    pub(crate) fn validate_auth(&self, sender: &SenderKind<'_>) -> Result<()> {
453        validate_authorizations(&self.inner.auth, sender)
454    }
455
456    /// Returns whether building this request will disclose an EIP-7702 authorization to an RPC
457    /// endpoint.
458    pub(crate) fn will_disclose_auth_during_build(&self) -> bool {
459        // Generating an access list or estimating gas sends the authorization-bearing request
460        // to the RPC.
461        self.has_auth()
462            && (matches!(self.inner.access_list, Some(None))
463                || (self.inner.fill && self.inner.tx.gas_limit().is_none()))
464    }
465
466    fn with_state<S2>(self, state: S2) -> CastTxBuilder<N, P, S2> {
467        CastTxBuilder { inner: self.inner, state }
468    }
469}
470
471impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, InitState>
472where
473    N::TransactionRequest: FoundryTransactionBuilder<N>,
474{
475    /// Creates a new instance of [CastTxBuilder] filling transaction with fields present in
476    /// provided [TransactionOpts].
477    pub async fn new(provider: P, tx_opts: TransactionOpts, config: &Config) -> Result<Self> {
478        let mut tx = N::TransactionRequest::default();
479
480        let chain = utils::get_chain(config.chain, &provider).await?;
481        let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten();
482        let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone());
483        let etherscan_api_url = etherscan_config.map(|c| c.api_url);
484        // mark it as legacy if requested or the chain is legacy and no 7702 is provided.
485        let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty());
486
487        tx_opts.apply::<N>(&mut tx, legacy);
488
489        Ok(Self {
490            inner: TxBuilderInner {
491                provider,
492                tx,
493                legacy,
494                blob: tx_opts.blob,
495                eip4844: tx_opts.eip4844,
496                fill: true,
497                browser: false,
498                eip1559_fee_estimate: config.eip1559_fee_estimate,
499                gas_estimate_multiplier: None,
500                chain,
501                etherscan_api_key,
502                etherscan_api_url,
503                auth: tx_opts.auth,
504                access_list: tx_opts.access_list,
505            },
506            state: InitState,
507        })
508    }
509
510    /// Sets [TxKind] for this builder and changes state to [ToState].
511    pub async fn with_to(self, to: Option<NameOrAddress>) -> Result<CastTxBuilder<N, P, ToState>> {
512        let to = match to {
513            Some(to) => Some(to.resolve(&self.inner.provider).await?),
514            None => None,
515        };
516        Ok(self.with_state(ToState { to }))
517    }
518}
519
520impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, ToState>
521where
522    N::TransactionRequest: FoundryTransactionBuilder<N>,
523{
524    /// Accepts user-provided code, sig and args params and constructs calldata for the transaction.
525    /// If code is present, input will be set to code + encoded constructor arguments. If no code is
526    /// present, input is set to just provided arguments.
527    pub async fn with_code_sig_and_args(
528        self,
529        code: Option<String>,
530        sig: Option<String>,
531        args: Vec<String>,
532    ) -> Result<CastTxBuilder<N, P, InputState>> {
533        let to = self.state.to;
534        let (mut args, func) = if let Some(sig) = sig {
535            let (key, url) = self.etherscan_api();
536            parse_function_args(&sig, args, to, self.chain(), &self.inner.provider, key, url)
537                .await?
538        } else {
539            (Vec::new(), None)
540        };
541
542        let input = if let Some(code) = &code {
543            let mut code = hex::decode(code)?;
544            code.append(&mut args);
545            code
546        } else {
547            args
548        };
549
550        // We only allow user to omit the recipient address if transaction is an EIP-7702 tx
551        // without a value. The sender is used as the destination in that case, see `build`.
552        if to.is_none()
553            && code.is_none()
554            && (!self.has_auth() || self.inner.tx.value().is_some_and(|v| !v.is_zero()))
555        {
556            eyre::bail!("Must specify a recipient address or contract code to deploy");
557        }
558        if to.is_none() && code.is_some() && self.has_auth() {
559            eyre::bail!(
560                "EIP-7702 transactions can't be CREATE transactions and require a destination address"
561            );
562        }
563
564        Ok(self.with_state(InputState { kind: to.into(), input, func }))
565    }
566}
567
568impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, InputState>
569where
570    N::TransactionRequest: FoundryTransactionBuilder<N>,
571{
572    /// Builds the TransactionRequest. Fills gas, fees and nonce unless [`raw`](Self::raw) was
573    /// called.
574    pub async fn build(
575        self,
576        sender: impl Into<SenderKind<'_>>,
577    ) -> Result<(N::TransactionRequest, Option<Function>)> {
578        self.inner.build(self.state, &sender.into(), None).await
579    }
580
581    /// Builds a transaction that will be signed by a Tempo access key.
582    ///
583    /// The access-key id is set before gas estimation. If the access key needs on-chain
584    /// provisioning, its authorization is embedded before access-list/gas estimation and before
585    /// any sponsor digest can be computed.
586    pub async fn build_with_tempo_wallet(
587        self,
588        wallet: &TempoAccountsWallet,
589    ) -> Result<(N::TransactionRequest, Option<Function>, TempoAccountsWallet)> {
590        let mut prepared = wallet.clone();
591        let (tx, func) =
592            self.inner.build(self.state, &wallet.account().into(), Some(&mut prepared)).await?;
593        Ok((tx, func, prepared))
594    }
595
596    /// Populates the blob sidecar for the transaction if any blob data was provided.
597    pub fn with_blob_data(mut self, blob_data: Option<Vec<u8>>) -> Result<Self> {
598        let Some(blob_data) = blob_data else { return Ok(self) };
599
600        let mut coder = SidecarBuilder::<SimpleCoder>::default();
601        coder.ingest(&blob_data);
602        if self.inner.eip4844 {
603            self.inner.tx.set_blob_sidecar_4844(coder.build_4844()?);
604        } else {
605            self.inner.tx.set_blob_sidecar_7594(coder.build_7594()?);
606        }
607        Ok(self)
608    }
609
610    /// Skips gas, fee and nonce filling. Use for read-only calls
611    /// (eth_call, eth_estimateGas, eth_createAccessList).
612    pub const fn raw(mut self) -> Self {
613        self.inner.fill = false;
614        self
615    }
616}
617
618impl<N: Network, P: Provider<N>> TxBuilderInner<N, P>
619where
620    N::TransactionRequest: FoundryTransactionBuilder<N>,
621{
622    async fn build(
623        mut self,
624        state: InputState,
625        sender: &SenderKind<'_>,
626        tempo_wallet: Option<&mut TempoAccountsWallet>,
627    ) -> Result<(N::TransactionRequest, Option<Function>)> {
628        let fill = self.fill;
629        let from = sender.address();
630
631        // An EIP-7702 transaction can't be a CREATE. If the recipient was omitted, the sender
632        // itself is the destination, which is the common case for self-delegation.
633        let kind = match state.kind {
634            TxKind::Create if !self.auth.is_empty() => TxKind::Call(from),
635            kind => kind,
636        };
637        self.tx.set_kind(kind);
638        // We set both fields to the same value because some nodes only accept the legacy
639        // `data` field: https://github.com/foundry-rs/foundry/issues/7764#issuecomment-2210453249
640        self.tx.set_input_kind(state.input, TransactionInputKind::Both);
641        if !from.is_zero() {
642            self.tx.set_from(from);
643        }
644        self.tx.set_chain_id(self.chain.id());
645        // For batch transactions with calls, clear `to` and `value` so the node correctly
646        // identifies this as an AA batch transaction. The `calls` field determines the actual
647        // targets. If `to` is set, `build_aa()` would add a spurious extra call.
648        self.tx.clear_batch_to();
649
650        // Read-only calls do not need a nonce unless it is required to sign an authorization.
651        // Avoid an otherwise unused `eth_getTransactionCount` request for raw transactions.
652        let resolve_in_parallel =
653            fill && self.auth.is_empty() && tempo_wallet.is_none() && !self.chain.is_tempo();
654        let tx_nonce = if resolve_in_parallel {
655            let fees_are_complete = if self.legacy {
656                self.tx.gas_price().is_some()
657            } else {
658                matches!(
659                    (self.tx.max_fee_per_gas(), self.tx.max_priority_fee_per_gas()),
660                    (Some(max_fee), Some(priority_fee)) if priority_fee <= max_fee
661                )
662            } && (!self.blob || self.tx.max_fee_per_blob_gas().is_some());
663            let gas_request =
664                (fees_are_complete && self.access_list.is_none() && self.tx.gas_limit().is_none())
665                    .then(|| self.tx.clone());
666            let (blob, legacy, browser, eip1559_fee_estimate, gas_estimate_multiplier) = (
667                self.blob,
668                self.legacy,
669                self.browser,
670                self.eip1559_fee_estimate,
671                self.gas_estimate_multiplier,
672            );
673            let Self { provider, tx, .. } = &mut self;
674            let provider = &*provider;
675            let (tx_nonce, (), gas_limit) = tokio::try_join!(
676                Self::resolve_nonce(provider, from, tx.nonce()),
677                Self::fill_fees(provider, tx, blob, legacy, browser, eip1559_fee_estimate),
678                async {
679                    match gas_request {
680                        Some(request) => {
681                            Self::estimate_gas(provider, request, gas_estimate_multiplier)
682                                .await
683                                .map(Some)
684                        }
685                        None => Ok(None),
686                    }
687                },
688            )?;
689            if let Some(gas_limit) = gas_limit {
690                self.tx.set_gas_limit(gas_limit);
691            }
692            Some(tx_nonce)
693        } else if fill || !self.auth.is_empty() {
694            Some(Self::resolve_nonce(&self.provider, from, self.tx.nonce()).await?)
695        } else {
696            None
697        };
698        if let Some(tx_nonce) = tx_nonce {
699            if fill {
700                self.tx.set_nonce(tx_nonce);
701            }
702            self.resolve_auth(sender, tx_nonce).await?;
703        }
704        if let Some(wallet) = tempo_wallet {
705            *wallet = self.tx.prepare_with_tempo_wallet(&self.provider, wallet).await?;
706        }
707        if fill && !resolve_in_parallel {
708            Self::fill_fees(
709                &self.provider,
710                &mut self.tx,
711                self.blob,
712                self.legacy,
713                self.browser,
714                self.eip1559_fee_estimate,
715            )
716            .await?;
717        }
718        // Fetch the access list from the provider if `--access-list` was passed without a value.
719        let access_list = match self.access_list.take() {
720            None => None,
721            Some(None) => Some(self.provider.create_access_list(&self.tx).await?.access_list),
722            Some(Some(access_list)) => Some(access_list),
723        };
724        if let Some(access_list) = access_list {
725            self.tx.set_access_list(access_list);
726        }
727        if fill && self.tx.gas_limit().is_none() {
728            let request = if self.browser && self.chain.is_tempo() {
729                self.tx.browser_wallet_gas_estimation_request()
730            } else {
731                self.tx.clone()
732            };
733            let estimated =
734                Self::estimate_gas(&self.provider, request, self.gas_estimate_multiplier).await?;
735            self.tx.set_gas_limit(estimated);
736        }
737
738        Ok((self.tx, state.func))
739    }
740
741    /// Resolves the transaction nonce. Returns the existing nonce or fetches one from the provider.
742    async fn resolve_nonce(provider: &P, from: Address, nonce: Option<u64>) -> Result<u64> {
743        match nonce {
744            Some(nonce) => Ok(nonce),
745            None => Ok(provider.get_transaction_count(from).await?),
746        }
747    }
748
749    /// Parses the passed --auth values and sets the authorization list on the transaction.
750    ///
751    /// If a signer is available in `sender`, address-based auths will be signed.
752    /// If no signer is available, all auths must be pre-signed.
753    async fn resolve_auth(&mut self, sender: &SenderKind<'_>, tx_nonce: u64) -> Result<()> {
754        if self.auth.is_empty() {
755            return Ok(());
756        }
757        validate_authorizations(&self.auth, sender)?;
758
759        let mut signed_auths = Vec::with_capacity(self.auth.len());
760        for auth in std::mem::take(&mut self.auth) {
761            signed_auths.push(match auth {
762                CliAuthorizationList::Address(address) => {
763                    let auth = Authorization {
764                        chain_id: U256::from(self.chain.id()),
765                        nonce: tx_nonce + 1,
766                        address,
767                    };
768                    let signer =
769                        sender.as_signer().expect("address-based authorization requires a signer");
770                    let signature = signer.sign_hash(&auth.signature_hash()).await?;
771                    auth.into_signed(signature)
772                }
773                CliAuthorizationList::Signed(auth) => auth,
774            });
775        }
776        self.tx.set_authorization_list(signed_auths);
777        Ok(())
778    }
779
780    /// Fills gas price, EIP-1559 fees, and blob fees from the provider.
781    ///
782    /// Only fills values that haven't been explicitly set by the user.
783    async fn fill_fees(
784        provider: &P,
785        tx: &mut N::TransactionRequest,
786        blob: bool,
787        legacy: bool,
788        browser: bool,
789        eip1559_fee_estimate: Eip1559FeeEstimatePreset,
790    ) -> Result<()> {
791        if blob && tx.max_fee_per_blob_gas().is_none() {
792            tx.set_max_fee_per_blob_gas(provider.get_blob_base_fee().await?)
793        }
794        fill_transaction_gas_fees(provider, tx, legacy, browser, eip1559_fee_estimate).await
795    }
796
797    /// Estimate tx gas from provider call. Tries to decode custom error if execution reverted.
798    async fn estimate_gas(
799        provider: &P,
800        request: N::TransactionRequest,
801        multiplier: Option<u64>,
802    ) -> Result<u64> {
803        let err = match provider.estimate_gas(request).await {
804            Ok(estimated) => return apply_gas_estimate_multiplier(estimated, multiplier),
805            Err(err) => err,
806        };
807        // If execution reverted with code 3 during provider gas estimation then try to decode
808        // custom errors and append it to the error message.
809        if let TransportError::ErrorResp(payload) = &err
810            && payload.code == 3
811            && let Some(data) = &payload.data
812            && let Ok(data) = serde_json::from_str::<Bytes>(data.get())
813            && let Ok(Some(decoded_error)) = decode_custom_error(&data).await
814        {
815            eyre::bail!("Failed to estimate gas: {err}: {decoded_error}");
816        }
817        eyre::bail!("Failed to estimate gas: {err}");
818    }
819}
820
821/// Fills gas price or EIP-1559 fee fields from the provider and validates the final pair.
822pub(crate) async fn fill_transaction_gas_fees<N: Network, P: Provider<N>>(
823    provider: &P,
824    tx: &mut N::TransactionRequest,
825    legacy: bool,
826    browser: bool,
827    eip1559_fee_estimate: Eip1559FeeEstimatePreset,
828) -> Result<()>
829where
830    N::TransactionRequest: FoundryTransactionBuilder<N>,
831{
832    if legacy {
833        if tx.gas_price().is_none() {
834            tx.set_gas_price(provider.get_gas_price().await?);
835        }
836        return Ok(());
837    }
838
839    if tx.max_fee_per_gas().is_none() || tx.max_priority_fee_per_gas().is_none() {
840        let estimate = estimate_eip1559_fees(provider, eip1559_fee_estimate).await?;
841
842        // Only honor the browser-suggested tip when the user has not pinned a
843        // priority fee; `resolve_broadcast_eip1559_fees` ignores a lower tip.
844        let browser_suggested_tip = if browser && tx.max_priority_fee_per_gas().is_none() {
845            provider.get_max_priority_fee_per_gas().await.ok()
846        } else {
847            None
848        };
849
850        // User `--gas-price`/`--priority-gas-price` overrides are already applied
851        // to `tx`; pass `None` so they are not double-applied here.
852        let estimate = resolve_broadcast_eip1559_fees(estimate, None, None, browser_suggested_tip)?;
853
854        if tx.max_fee_per_gas().is_none() {
855            tx.set_max_fee_per_gas(estimate.max_fee_per_gas);
856        }
857
858        if tx.max_priority_fee_per_gas().is_none() {
859            tx.set_max_priority_fee_per_gas(estimate.max_priority_fee_per_gas);
860        }
861    }
862
863    if let (Some(max_fee), Some(priority)) = (tx.max_fee_per_gas(), tx.max_priority_fee_per_gas()) {
864        eyre::ensure!(
865            priority <= max_fee,
866            "max priority fee per gas ({priority}) cannot exceed max fee per gas ({max_fee})"
867        );
868    }
869
870    Ok(())
871}
872
873/// Tries to decode a custom error name and inputs from revert data.
874pub(crate) async fn decode_custom_error(data: &[u8]) -> Result<Option<String>> {
875    let Some(selector) = data.get(..4) else { return Ok(None) };
876    let Some(known_error) =
877        SignaturesIdentifier::new(false)?.identify_error(selector.try_into().unwrap()).await
878    else {
879        return Ok(None);
880    };
881    let mut decoded_error = known_error.name.clone();
882    if !known_error.inputs.is_empty()
883        && let Ok(error) = known_error.decode_error(data)
884    {
885        write!(decoded_error, "({})", format_tokens(&error.body).format(", "))?;
886    }
887    Ok(Some(decoded_error))
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use alloy_json_rpc::{RequestPacket, ResponsePacket};
894    use alloy_network::{Ethereum, NetworkTransactionBuilder};
895    use alloy_provider::{ProviderBuilder, mock::Asserter};
896    use alloy_rpc_client::RpcClient;
897    use alloy_sol_types::SolValue;
898    use alloy_transport::{TransportFut, mock::MockTransport};
899    use clap::Parser;
900    use foundry_common::tempo::resolve_and_set_fee_token;
901    use std::{
902        sync::{Arc, Mutex},
903        task::{Context, Poll},
904    };
905    use tempo_alloy::TempoNetwork;
906    use tokio::{sync::Barrier, time::timeout};
907    use tower::Service;
908
909    #[derive(Clone)]
910    struct BarrierTransport {
911        inner: MockTransport,
912        barrier: Arc<Barrier>,
913        fill_methods: Arc<Mutex<Vec<String>>>,
914    }
915
916    impl Service<RequestPacket> for BarrierTransport {
917        type Response = ResponsePacket;
918        type Error = TransportError;
919        type Future = TransportFut<'static>;
920
921        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
922            self.inner.poll_ready(cx)
923        }
924
925        fn call(&mut self, req: RequestPacket) -> Self::Future {
926            let fill_method = match &req {
927                RequestPacket::Single(req)
928                    if matches!(
929                        req.method(),
930                        "eth_getTransactionCount" | "eth_gasPrice" | "eth_estimateGas"
931                    ) =>
932                {
933                    Some(req.method().to_string())
934                }
935                _ => None,
936            };
937            let Some(fill_method) = fill_method else {
938                return self.inner.call(req);
939            };
940            self.fill_methods.lock().unwrap().push(fill_method);
941
942            let barrier = self.barrier.clone();
943            let mut inner = self.inner.clone();
944            Box::pin(async move {
945                barrier.wait().await;
946                inner.call(req).await
947            })
948        }
949    }
950
951    const TO: Address = Address::repeat_byte(0x11);
952
953    /// Builds a transaction to [`TO`] from the given `cast send` style arguments.
954    async fn builder<P: Provider<Ethereum>>(
955        provider: P,
956        args: &[&str],
957    ) -> CastTxBuilder<Ethereum, P, InputState> {
958        let config = Config { chain: Some(Chain::mainnet()), ..Default::default() };
959        let opts = TransactionOpts::parse_from([&["test"], args].concat());
960        CastTxBuilder::new(provider, opts, &config)
961            .await
962            .unwrap()
963            .with_to(Some(TO.into()))
964            .await
965            .unwrap()
966            .with_code_sig_and_args(None, None, Vec::new())
967            .await
968            .unwrap()
969    }
970
971    #[tokio::test]
972    async fn filled_build_applies_multiplier_to_concurrent_gas_estimate() {
973        let asserter = Asserter::new();
974        for _ in 0..2 {
975            asserter.push_success(&U64::from(100));
976        }
977        let fill_methods = Arc::new(Mutex::new(Vec::new()));
978        let transport = BarrierTransport {
979            inner: MockTransport::new(asserter),
980            barrier: Arc::new(Barrier::new(2)),
981            fill_methods: fill_methods.clone(),
982        };
983        let provider = ProviderBuilder::new_with_network::<Ethereum>()
984            .connect_client(RpcClient::new(transport, true));
985
986        let builder = builder(&provider, &["--legacy", "--gas-price", "1"])
987            .await
988            .with_gas_estimate_multiplier(Some(150));
989        let (tx, _) = timeout(Duration::from_secs(1), builder.build(Address::repeat_byte(0x22)))
990            .await
991            .expect("nonce and gas requests were not in flight together")
992            .unwrap();
993
994        assert_eq!(tx.nonce, Some(100));
995        assert_eq!(tx.gas_price, Some(1));
996        assert_eq!(tx.gas, Some(150));
997        let mut fill_methods = fill_methods.lock().unwrap().clone();
998        fill_methods.sort();
999        assert_eq!(fill_methods, ["eth_estimateGas", "eth_getTransactionCount"]);
1000    }
1001
1002    #[tokio::test]
1003    async fn browser_submission_resolves_type_after_tempo_fee_token() {
1004        let asserter = Asserter::new();
1005        let token = Address::repeat_byte(0x42);
1006        asserter.push_success(&token.abi_encode());
1007        let provider =
1008            ProviderBuilder::new_with_network::<TempoNetwork>().connect_mocked_client(asserter);
1009        let chain = Chain::from_id(4217);
1010        let config = Config { chain: Some(chain), ..Default::default() };
1011        let opts = TransactionOpts::parse_from([
1012            "test",
1013            "--gas-limit",
1014            "21000",
1015            "--nonce",
1016            "0",
1017            "--gas-price",
1018            "1",
1019            "--priority-gas-price",
1020            "1",
1021        ]);
1022        let (mut tx, _) = CastTxBuilder::new(&provider, opts, &config)
1023            .await
1024            .unwrap()
1025            .with_browser_wallet()
1026            .with_to(Some(TO.into()))
1027            .await
1028            .unwrap()
1029            .with_code_sig_and_args(None, None, Vec::new())
1030            .await
1031            .unwrap()
1032            .build(Address::repeat_byte(0x22))
1033            .await
1034            .unwrap();
1035        assert_eq!(tx.inner.transaction_type, None);
1036        assert_eq!(tx.fee_token, None);
1037
1038        // Fee resolution happens after building and can change the submission type to Tempo AA.
1039        resolve_and_set_fee_token(Some(&provider), Some(chain), &mut tx, None).await.unwrap();
1040        tx.prep_for_submission();
1041        assert_eq!(tx.inner.transaction_type, Some(0x76));
1042        assert_eq!(tx.fee_token, Some(token));
1043    }
1044
1045    #[tokio::test]
1046    async fn raw_build_skips_nonce_request() {
1047        // No responses are queued, so any RPC request would fail this test. In particular, this
1048        // guards against restoring the unused `eth_getTransactionCount` request.
1049        let provider =
1050            ProviderBuilder::new_with_network::<Ethereum>().connect_mocked_client(Asserter::new());
1051        builder(&provider, &[]).await.raw().build(Address::repeat_byte(0x22)).await.unwrap();
1052    }
1053
1054    #[tokio::test]
1055    async fn detects_auth_rpc_disclosure() {
1056        let provider =
1057            ProviderBuilder::new_with_network::<Ethereum>().connect_mocked_client(Asserter::new());
1058        let auth = TO.to_string();
1059
1060        let no_auth = builder(&provider, &["--gas-limit", "21000"]).await.raw();
1061        assert!(!no_auth.has_auth());
1062        assert!(!no_auth.will_disclose_auth_during_build());
1063
1064        let rpc_call = builder(&provider, &["--auth", &auth, "--gas-limit", "21000"]).await.raw();
1065        assert!(rpc_call.has_auth());
1066        assert!(!rpc_call.will_disclose_auth_during_build());
1067
1068        let generated_access_list = builder(&provider, &["--auth", &auth, "--access-list"]).await;
1069        assert!(generated_access_list.raw().will_disclose_auth_during_build());
1070
1071        let explicit_access_list =
1072            builder(&provider, &["--auth", &auth, "--access-list", "[]"]).await;
1073        assert!(!explicit_access_list.raw().will_disclose_auth_during_build());
1074
1075        let estimated_gas = builder(&provider, &["--auth", &auth]).await;
1076        assert!(estimated_gas.will_disclose_auth_during_build());
1077    }
1078}