cast/
tx.rs

1use crate::traces::identifier::SignaturesIdentifier;
2use alloy_consensus::{SidecarBuilder, SignableTransaction, SimpleCoder};
3use alloy_dyn_abi::ErrorExt;
4use alloy_ens::NameOrAddress;
5use alloy_json_abi::Function;
6use alloy_network::{
7    AnyNetwork, AnyTypedTransaction, TransactionBuilder, TransactionBuilder4844,
8    TransactionBuilder7702,
9};
10use alloy_primitives::{Address, Bytes, TxKind, U256, hex};
11use alloy_provider::Provider;
12use alloy_rpc_types::{AccessList, Authorization, TransactionInputKind, TransactionRequest};
13use alloy_serde::WithOtherFields;
14use alloy_signer::Signer;
15use alloy_transport::TransportError;
16use clap::Args;
17use eyre::Result;
18use foundry_cli::{
19    opts::{CliAuthorizationList, EthereumOpts, TransactionOpts},
20    utils::{self, LoadConfig, get_provider_builder, parse_function_args},
21};
22use foundry_common::{fmt::format_tokens, provider::RetryProviderWithSigner};
23use foundry_config::{Chain, Config};
24use foundry_wallets::{WalletOpts, WalletSigner};
25use itertools::Itertools;
26use serde_json::value::RawValue;
27use std::{fmt::Write, time::Duration};
28
29#[derive(Debug, Clone, Args)]
30pub struct SendTxOpts {
31    /// Only print the transaction hash and exit immediately.
32    #[arg(id = "async", long = "async", alias = "cast-async", env = "CAST_ASYNC")]
33    pub cast_async: bool,
34
35    /// Wait for transaction receipt synchronously instead of polling.
36    /// Note: uses `eth_sendTransactionSync` which may not be supported by all clients.
37    #[arg(long, conflicts_with = "async")]
38    pub sync: bool,
39
40    /// The number of confirmations until the receipt is fetched.
41    #[arg(long, default_value = "1")]
42    pub confirmations: u64,
43
44    /// Timeout for sending the transaction.
45    #[arg(long, env = "ETH_TIMEOUT")]
46    pub timeout: Option<u64>,
47
48    /// Polling interval for transaction receipts (in seconds).
49    #[arg(long, alias = "poll-interval", env = "ETH_POLL_INTERVAL")]
50    pub poll_interval: Option<u64>,
51
52    /// Ethereum options
53    #[command(flatten)]
54    pub eth: EthereumOpts,
55}
56
57/// Different sender kinds used by [`CastTxBuilder`].
58pub enum SenderKind<'a> {
59    /// An address without signer. Used for read-only calls and transactions sent through unlocked
60    /// accounts.
61    Address(Address),
62    /// A reference to a signer.
63    Signer(&'a WalletSigner),
64    /// An owned signer.
65    OwnedSigner(Box<WalletSigner>),
66}
67
68impl SenderKind<'_> {
69    /// Resolves the name to an Ethereum Address.
70    pub fn address(&self) -> Address {
71        match self {
72            Self::Address(addr) => *addr,
73            Self::Signer(signer) => signer.address(),
74            Self::OwnedSigner(signer) => signer.address(),
75        }
76    }
77
78    /// Resolves the sender from the wallet options.
79    ///
80    /// This function prefers the `from` field and may return a different address from the
81    /// configured signer
82    /// If from is specified, returns it
83    /// If from is not specified, but there is a signer configured, returns the signer's address
84    /// If from is not specified and there is no signer configured, returns zero address
85    pub async fn from_wallet_opts(opts: WalletOpts) -> Result<Self> {
86        if let Some(from) = opts.from {
87            Ok(from.into())
88        } else if let Ok(signer) = opts.signer().await {
89            Ok(Self::OwnedSigner(Box::new(signer)))
90        } else {
91            Ok(Address::ZERO.into())
92        }
93    }
94
95    /// Returns the signer if available.
96    pub fn as_signer(&self) -> Option<&WalletSigner> {
97        match self {
98            Self::Signer(signer) => Some(signer),
99            Self::OwnedSigner(signer) => Some(signer.as_ref()),
100            _ => None,
101        }
102    }
103}
104
105impl From<Address> for SenderKind<'_> {
106    fn from(addr: Address) -> Self {
107        Self::Address(addr)
108    }
109}
110
111impl<'a> From<&'a WalletSigner> for SenderKind<'a> {
112    fn from(signer: &'a WalletSigner) -> Self {
113        Self::Signer(signer)
114    }
115}
116
117impl From<WalletSigner> for SenderKind<'_> {
118    fn from(signer: WalletSigner) -> Self {
119        Self::OwnedSigner(Box::new(signer))
120    }
121}
122
123/// Prevents a misconfigured hwlib from sending a transaction that defies user-specified --from
124pub fn validate_from_address(
125    specified_from: Option<Address>,
126    signer_address: Address,
127) -> Result<()> {
128    if let Some(specified_from) = specified_from
129        && specified_from != signer_address
130    {
131        eyre::bail!(
132                "\
133The specified sender via CLI/env vars does not match the sender configured via
134the hardware wallet's HD Path.
135Please use the `--hd-path <PATH>` parameter to specify the BIP32 Path which
136corresponds to the sender, or let foundry automatically detect it by not specifying any sender address."
137            )
138    }
139    Ok(())
140}
141
142/// Initial state.
143#[derive(Debug)]
144pub struct InitState;
145
146/// State with known [TxKind].
147#[derive(Debug)]
148pub struct ToState {
149    to: Option<Address>,
150}
151
152/// State with known input for the transaction.
153#[derive(Debug)]
154pub struct InputState {
155    kind: TxKind,
156    input: Vec<u8>,
157    func: Option<Function>,
158}
159
160/// Builder type constructing [TransactionRequest] from cast send/mktx inputs.
161///
162/// It is implemented as a stateful builder with expected state transition of [InitState] ->
163/// [ToState] -> [InputState].
164#[derive(Debug)]
165pub struct CastTxBuilder<P, S> {
166    provider: P,
167    tx: WithOtherFields<TransactionRequest>,
168    /// Whether the transaction should be sent as a legacy transaction.
169    legacy: bool,
170    blob: bool,
171    auth: Vec<CliAuthorizationList>,
172    chain: Chain,
173    etherscan_api_key: Option<String>,
174    access_list: Option<Option<AccessList>>,
175    state: S,
176}
177
178impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InitState> {
179    /// Creates a new instance of [CastTxBuilder] filling transaction with fields present in
180    /// provided [TransactionOpts].
181    pub async fn new(provider: P, tx_opts: TransactionOpts, config: &Config) -> Result<Self> {
182        let mut tx = WithOtherFields::<TransactionRequest>::default();
183
184        let chain = utils::get_chain(config.chain, &provider).await?;
185        let etherscan_api_key = config.get_etherscan_api_key(Some(chain));
186        // mark it as legacy if requested or the chain is legacy and no 7702 is provided.
187        let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty());
188
189        if let Some(gas_limit) = tx_opts.gas_limit {
190            tx.set_gas_limit(gas_limit.to());
191        }
192
193        if let Some(value) = tx_opts.value {
194            tx.set_value(value);
195        }
196
197        if let Some(gas_price) = tx_opts.gas_price {
198            if legacy {
199                tx.set_gas_price(gas_price.to());
200            } else {
201                tx.set_max_fee_per_gas(gas_price.to());
202            }
203        }
204
205        if !legacy && let Some(priority_fee) = tx_opts.priority_gas_price {
206            tx.set_max_priority_fee_per_gas(priority_fee.to());
207        }
208
209        if let Some(max_blob_fee) = tx_opts.blob_gas_price {
210            tx.set_max_fee_per_blob_gas(max_blob_fee.to())
211        }
212
213        if let Some(nonce) = tx_opts.nonce {
214            tx.set_nonce(nonce.to());
215        }
216
217        Ok(Self {
218            provider,
219            tx,
220            legacy,
221            blob: tx_opts.blob,
222            chain,
223            etherscan_api_key,
224            auth: tx_opts.auth,
225            access_list: tx_opts.access_list,
226            state: InitState,
227        })
228    }
229
230    /// Sets [TxKind] for this builder and changes state to [ToState].
231    pub async fn with_to(self, to: Option<NameOrAddress>) -> Result<CastTxBuilder<P, ToState>> {
232        let to = if let Some(to) = to { Some(to.resolve(&self.provider).await?) } else { None };
233        Ok(CastTxBuilder {
234            provider: self.provider,
235            tx: self.tx,
236            legacy: self.legacy,
237            blob: self.blob,
238            chain: self.chain,
239            etherscan_api_key: self.etherscan_api_key,
240            auth: self.auth,
241            access_list: self.access_list,
242            state: ToState { to },
243        })
244    }
245}
246
247impl<P: Provider<AnyNetwork>> CastTxBuilder<P, ToState> {
248    /// Accepts user-provided code, sig and args params and constructs calldata for the transaction.
249    /// If code is present, input will be set to code + encoded constructor arguments. If no code is
250    /// present, input is set to just provided arguments.
251    pub async fn with_code_sig_and_args(
252        self,
253        code: Option<String>,
254        sig: Option<String>,
255        args: Vec<String>,
256    ) -> Result<CastTxBuilder<P, InputState>> {
257        let (mut args, func) = if let Some(sig) = sig {
258            parse_function_args(
259                &sig,
260                args,
261                self.state.to,
262                self.chain,
263                &self.provider,
264                self.etherscan_api_key.as_deref(),
265            )
266            .await?
267        } else {
268            (Vec::new(), None)
269        };
270
271        let input = if let Some(code) = &code {
272            let mut code = hex::decode(code)?;
273            code.append(&mut args);
274            code
275        } else {
276            args
277        };
278
279        if self.state.to.is_none() && code.is_none() {
280            let has_value = self.tx.value.is_some_and(|v| !v.is_zero());
281            let has_auth = !self.auth.is_empty();
282            // We only allow user to omit the recipient address if transaction is an EIP-7702 tx
283            // without a value.
284            if !has_auth || has_value {
285                eyre::bail!("Must specify a recipient address or contract code to deploy");
286            }
287        }
288
289        Ok(CastTxBuilder {
290            provider: self.provider,
291            tx: self.tx,
292            legacy: self.legacy,
293            blob: self.blob,
294            chain: self.chain,
295            etherscan_api_key: self.etherscan_api_key,
296            auth: self.auth,
297            access_list: self.access_list,
298            state: InputState { kind: self.state.to.into(), input, func },
299        })
300    }
301}
302
303impl<P: Provider<AnyNetwork>> CastTxBuilder<P, InputState> {
304    /// Builds [TransactionRequest] and fills missing fields. Returns a transaction which is ready
305    /// to be broadcasted.
306    pub async fn build(
307        self,
308        sender: impl Into<SenderKind<'_>>,
309    ) -> Result<(WithOtherFields<TransactionRequest>, Option<Function>)> {
310        self._build(sender, true, false).await
311    }
312
313    /// Builds [TransactionRequest] without filling missing fields. Used for read-only calls such as
314    /// eth_call, eth_estimateGas, etc
315    pub async fn build_raw(
316        self,
317        sender: impl Into<SenderKind<'_>>,
318    ) -> Result<(WithOtherFields<TransactionRequest>, Option<Function>)> {
319        self._build(sender, false, false).await
320    }
321
322    /// Builds an unsigned RLP-encoded raw transaction.
323    ///
324    /// Returns the hex encoded string representation of the transaction.
325    pub async fn build_unsigned_raw(self, from: Address) -> Result<String> {
326        let (tx, _) = self._build(SenderKind::Address(from), true, true).await?;
327        let tx = tx.build_unsigned()?;
328        match tx {
329            AnyTypedTransaction::Ethereum(t) => Ok(hex::encode_prefixed(t.encoded_for_signing())),
330            _ => eyre::bail!("Cannot generate unsigned transaction for non-Ethereum transactions"),
331        }
332    }
333
334    async fn _build(
335        mut self,
336        sender: impl Into<SenderKind<'_>>,
337        fill: bool,
338        unsigned: bool,
339    ) -> Result<(WithOtherFields<TransactionRequest>, Option<Function>)> {
340        let sender = sender.into();
341        let from = sender.address();
342
343        self.tx.set_kind(self.state.kind);
344
345        // we set both fields to the same value because some nodes only accept the legacy `data` field: <https://github.com/foundry-rs/foundry/issues/7764#issuecomment-2210453249>
346        self.tx.set_input_kind(self.state.input.clone(), TransactionInputKind::Both);
347
348        self.tx.set_from(from);
349        self.tx.set_chain_id(self.chain.id());
350
351        let tx_nonce = if let Some(nonce) = self.tx.nonce {
352            nonce
353        } else {
354            let nonce = self.provider.get_transaction_count(from).await?;
355            if fill {
356                self.tx.nonce = Some(nonce);
357            }
358            nonce
359        };
360
361        if !unsigned {
362            self.resolve_auth(sender, tx_nonce).await?;
363        } else if !self.auth.is_empty() {
364            let mut signed_auths = Vec::with_capacity(self.auth.len());
365            for auth in std::mem::take(&mut self.auth) {
366                let CliAuthorizationList::Signed(signed_auth) = auth else {
367                    eyre::bail!(
368                        "SignedAuthorization needs to be provided for generating unsigned 7702 txs"
369                    )
370                };
371                signed_auths.push(signed_auth);
372            }
373
374            self.tx.set_authorization_list(signed_auths);
375        }
376
377        if let Some(access_list) = match self.access_list.take() {
378            None => None,
379            // --access-list provided with no value, call the provider to create it
380            Some(None) => Some(self.provider.create_access_list(&self.tx).await?.access_list),
381            // Access list provided as a string, attempt to parse it
382            Some(Some(access_list)) => Some(access_list),
383        } {
384            self.tx.set_access_list(access_list);
385        }
386
387        if !fill {
388            return Ok((self.tx, self.state.func));
389        }
390
391        if self.legacy && self.tx.gas_price.is_none() {
392            self.tx.gas_price = Some(self.provider.get_gas_price().await?);
393        }
394
395        if self.blob && self.tx.max_fee_per_blob_gas.is_none() {
396            self.tx.max_fee_per_blob_gas = Some(self.provider.get_blob_base_fee().await?)
397        }
398
399        if !self.legacy
400            && (self.tx.max_fee_per_gas.is_none() || self.tx.max_priority_fee_per_gas.is_none())
401        {
402            let estimate = self.provider.estimate_eip1559_fees().await?;
403
404            if self.tx.max_fee_per_gas.is_none() {
405                self.tx.max_fee_per_gas = Some(estimate.max_fee_per_gas);
406            }
407
408            if self.tx.max_priority_fee_per_gas.is_none() {
409                self.tx.max_priority_fee_per_gas = Some(estimate.max_priority_fee_per_gas);
410            }
411        }
412
413        if self.tx.gas.is_none() {
414            self.estimate_gas().await?;
415        }
416
417        Ok((self.tx, self.state.func))
418    }
419
420    /// Estimate tx gas from provider call. Tries to decode custom error if execution reverted.
421    async fn estimate_gas(&mut self) -> Result<()> {
422        match self.provider.estimate_gas(self.tx.clone()).await {
423            Ok(estimated) => {
424                self.tx.gas = Some(estimated);
425                Ok(())
426            }
427            Err(err) => {
428                if let TransportError::ErrorResp(payload) = &err {
429                    // If execution reverted with code 3 during provider gas estimation then try
430                    // to decode custom errors and append it to the error message.
431                    if payload.code == 3
432                        && let Some(data) = &payload.data
433                        && let Ok(Some(decoded_error)) = decode_execution_revert(data).await
434                    {
435                        eyre::bail!("Failed to estimate gas: {}: {}", err, decoded_error)
436                    }
437                }
438                eyre::bail!("Failed to estimate gas: {}", err)
439            }
440        }
441    }
442
443    /// Parses the passed --auth values and sets the authorization list on the transaction.
444    async fn resolve_auth(&mut self, sender: SenderKind<'_>, tx_nonce: u64) -> Result<()> {
445        if self.auth.is_empty() {
446            return Ok(());
447        }
448
449        let auths = std::mem::take(&mut self.auth);
450
451        // Validate that at most one address-based auth is provided (multiple addresses are
452        // almost always unintended).
453        let address_auth_count =
454            auths.iter().filter(|a| matches!(a, CliAuthorizationList::Address(_))).count();
455        if address_auth_count > 1 {
456            eyre::bail!(
457                "Multiple address-based authorizations provided. Only one address can be specified; \
458                use pre-signed authorizations (hex-encoded) for multiple authorizations."
459            );
460        }
461
462        let mut signed_auths = Vec::with_capacity(auths.len());
463
464        for auth in auths {
465            let signed_auth = match auth {
466                CliAuthorizationList::Address(address) => {
467                    let auth = Authorization {
468                        chain_id: U256::from(self.chain.id()),
469                        nonce: tx_nonce + 1,
470                        address,
471                    };
472
473                    let Some(signer) = sender.as_signer() else {
474                        eyre::bail!("No signer available to sign authorization");
475                    };
476                    let signature = signer.sign_hash(&auth.signature_hash()).await?;
477
478                    auth.into_signed(signature)
479                }
480                CliAuthorizationList::Signed(auth) => auth,
481            };
482            signed_auths.push(signed_auth);
483        }
484
485        self.tx.set_authorization_list(signed_auths);
486
487        Ok(())
488    }
489}
490
491impl<P, S> CastTxBuilder<P, S>
492where
493    P: Provider<AnyNetwork>,
494{
495    /// Populates the blob sidecar for the transaction if any blob data was provided.
496    pub fn with_blob_data(mut self, blob_data: Option<Vec<u8>>) -> Result<Self> {
497        let Some(blob_data) = blob_data else { return Ok(self) };
498
499        let mut coder = SidecarBuilder::<SimpleCoder>::default();
500        coder.ingest(&blob_data);
501        let sidecar = coder.build()?;
502
503        self.tx.set_blob_sidecar(sidecar);
504        self.tx.populate_blob_hashes();
505
506        Ok(self)
507    }
508}
509
510/// Helper function that tries to decode custom error name and inputs from error payload data.
511async fn decode_execution_revert(data: &RawValue) -> Result<Option<String>> {
512    let err_data = serde_json::from_str::<Bytes>(data.get())?;
513    let Some(selector) = err_data.get(..4) else { return Ok(None) };
514    if let Some(known_error) =
515        SignaturesIdentifier::new(false)?.identify_error(selector.try_into().unwrap()).await
516    {
517        let mut decoded_error = known_error.name.clone();
518        if !known_error.inputs.is_empty()
519            && let Ok(error) = known_error.decode_error(&err_data)
520        {
521            write!(decoded_error, "({})", format_tokens(&error.body).format(", "))?;
522        }
523        return Ok(Some(decoded_error));
524    }
525    Ok(None)
526}
527
528/// Creates a provider with wallet for signing transactions locally.
529pub(crate) async fn signing_provider(
530    tx_opts: &SendTxOpts,
531) -> eyre::Result<RetryProviderWithSigner> {
532    let config = tx_opts.eth.load_config()?;
533    let signer = tx_opts.eth.wallet.signer().await?;
534    let wallet = alloy_network::EthereumWallet::from(signer);
535    let provider = get_provider_builder(&config)?.build_with_wallet(wallet)?;
536    if let Some(interval) = tx_opts.poll_interval {
537        provider.client().set_poll_interval(Duration::from_secs(interval))
538    }
539    Ok(provider)
540}