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, 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, TempoAccessKeyConfig, WalletOpts, WalletSigner};
27use itertools::Itertools;
28use serde_json::value::RawValue;
29use std::{fmt::Write, marker::PhantomData, str::FromStr, time::Duration};
30
31#[derive(Debug, Clone, Args)]
32pub struct SendTxOpts {
33 #[arg(id = "async", long = "async", alias = "cast-async", env = "CAST_ASYNC")]
35 pub cast_async: bool,
36
37 #[arg(long, conflicts_with = "async")]
40 pub sync: bool,
41
42 #[arg(long, default_value = "1")]
44 pub confirmations: u64,
45
46 #[arg(long, env = "ETH_TIMEOUT")]
48 pub timeout: Option<u64>,
49
50 #[arg(long, alias = "poll-interval", env = "ETH_POLL_INTERVAL")]
52 pub poll_interval: Option<u64>,
53
54 #[command(flatten)]
56 pub eth: EthereumOpts,
57
58 #[command(flatten)]
60 pub browser: BrowserWalletOpts,
61}
62
63#[derive(Debug, Clone, Args)]
65#[command(next_help_heading = "Transaction options")]
66pub struct TxParams {
67 #[arg(long, env = "ETH_GAS_LIMIT")]
69 pub gas_limit: Option<U256>,
70
71 #[arg(long, env = "ETH_GAS_PRICE")]
73 pub gas_price: Option<U256>,
74
75 #[arg(long, env = "ETH_PRIORITY_GAS_PRICE")]
77 pub priority_gas_price: Option<U256>,
78
79 #[arg(long)]
81 pub nonce: Option<U64>,
82
83 #[command(flatten)]
84 pub tempo: TempoOpts,
85}
86
87impl TxParams {
88 pub(crate) fn apply<N: Network>(&self, tx: &mut N::TransactionRequest, legacy: bool)
89 where
90 N::TransactionRequest: FoundryTransactionBuilder<N>,
91 {
92 if let Some(gas_limit) = self.gas_limit {
93 tx.set_gas_limit(gas_limit.to());
94 }
95
96 if let Some(gas_price) = self.gas_price {
97 if legacy {
98 tx.set_gas_price(gas_price.to());
99 } else {
100 tx.set_max_fee_per_gas(gas_price.to());
101 }
102 }
103
104 if !legacy && let Some(priority_fee) = self.priority_gas_price {
105 tx.set_max_priority_fee_per_gas(priority_fee.to());
106 }
107
108 self.tempo.apply::<N>(tx, self.nonce.map(|n| n.to()));
109 }
110}
111
112pub enum SenderKind<'a> {
114 Address(Address),
117 Signer(&'a WalletSigner),
119 OwnedSigner(Box<WalletSigner>),
121}
122
123impl SenderKind<'_> {
124 pub fn address(&self) -> Address {
126 match self {
127 Self::Address(addr) => *addr,
128 Self::Signer(signer) => signer.address(),
129 Self::OwnedSigner(signer) => signer.address(),
130 }
131 }
132
133 pub async fn from_wallet_opts(opts: WalletOpts) -> Result<Self> {
141 if let (Some(signer), _) = opts.maybe_signer().await? {
142 Ok(Self::OwnedSigner(Box::new(signer)))
143 } else if let Some(from) = opts.from {
144 Ok(from.into())
145 } else {
146 Ok(Address::ZERO.into())
147 }
148 }
149
150 pub fn as_signer(&self) -> Option<&WalletSigner> {
152 match self {
153 Self::Signer(signer) => Some(signer),
154 Self::OwnedSigner(signer) => Some(signer.as_ref()),
155 _ => None,
156 }
157 }
158}
159
160impl From<Address> for SenderKind<'_> {
161 fn from(addr: Address) -> Self {
162 Self::Address(addr)
163 }
164}
165
166impl<'a> From<&'a WalletSigner> for SenderKind<'a> {
167 fn from(signer: &'a WalletSigner) -> Self {
168 Self::Signer(signer)
169 }
170}
171
172impl From<WalletSigner> for SenderKind<'_> {
173 fn from(signer: WalletSigner) -> Self {
174 Self::OwnedSigner(Box::new(signer))
175 }
176}
177
178pub fn validate_from_address(
180 specified_from: Option<Address>,
181 signer_address: Address,
182) -> Result<()> {
183 if let Some(specified_from) = specified_from
184 && specified_from != signer_address
185 {
186 eyre::bail!(
187 "\
188The specified sender via CLI/env vars does not match the sender configured via
189the hardware wallet's HD Path.
190Please use the `--hd-path <PATH>` parameter to specify the BIP32 Path which
191corresponds to the sender, or let foundry automatically detect it by not specifying any sender address."
192 );
193 }
194 Ok(())
195}
196
197#[derive(Debug)]
199pub struct InitState;
200
201#[derive(Debug)]
203pub struct ToState {
204 to: Option<Address>,
205}
206
207#[derive(Debug)]
209pub struct InputState {
210 kind: TxKind,
211 input: Vec<u8>,
212 func: Option<Function>,
213}
214
215pub struct CastTxSender<N, P> {
216 provider: P,
217 _phantom: PhantomData<N>,
218}
219
220impl<N: Network, P: Provider<N>> CastTxSender<N, P>
221where
222 N::TransactionRequest: FoundryTransactionBuilder<N>,
223 N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
224{
225 pub const fn new(provider: P) -> Self {
227 Self { provider, _phantom: PhantomData }
228 }
229
230 pub async fn send_sync(&self, tx: N::TransactionRequest) -> Result<(B256, String)> {
232 let mut receipt = TransactionReceiptWithRevertReason::<N> {
233 receipt: self.provider.send_transaction_sync(tx).await?,
234 revert_reason: None,
235 };
236 let tx_hash = receipt.receipt.transaction_hash();
237 let _ = receipt.update_revert_reason(&self.provider).await;
239
240 self.format_receipt(receipt, None).map(|formatted| (tx_hash, formatted))
241 }
242
243 pub async fn send(&self, tx: N::TransactionRequest) -> Result<PendingTransactionBuilder<N>> {
278 let res = self.provider.send_transaction(tx).await?;
279
280 Ok(res)
281 }
282
283 pub async fn send_raw(&self, raw_tx: &[u8]) -> Result<PendingTransactionBuilder<N>> {
288 let res = self.provider.send_raw_transaction(raw_tx).await?;
289 Ok(res)
290 }
291
292 pub async fn print_tx_result(
297 &self,
298 tx_hash: B256,
299 cast_async: bool,
300 confs: u64,
301 timeout: u64,
302 ) -> Result<()> {
303 if cast_async {
304 sh_println!("{tx_hash:#x}")?;
305 } else {
306 let receipt =
307 self.receipt(format!("{tx_hash:#x}"), None, confs, Some(timeout), false).await?;
308 sh_println!("{receipt}")?;
309 }
310 Ok(())
311 }
312
313 pub async fn receipt(
330 &self,
331 tx_hash: String,
332 field: Option<String>,
333 confs: u64,
334 timeout: Option<u64>,
335 cast_async: bool,
336 ) -> Result<String> {
337 let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?;
338
339 let mut receipt = TransactionReceiptWithRevertReason::<N> {
340 receipt: match self.provider.get_transaction_receipt(tx_hash).await? {
341 Some(r) => r,
342 None => {
343 if cast_async {
346 eyre::bail!("tx not found: {:?}", tx_hash);
347 }
348 PendingTransactionBuilder::<N>::new(self.provider.root().clone(), tx_hash)
349 .with_required_confirmations(confs)
350 .with_timeout(timeout.map(Duration::from_secs))
351 .get_receipt()
352 .await?
353 }
354 },
355 revert_reason: None,
356 };
357
358 let _ = receipt.update_revert_reason(&self.provider).await;
360
361 self.format_receipt(receipt, field)
362 }
363
364 fn format_receipt(
366 &self,
367 receipt: TransactionReceiptWithRevertReason<N>,
368 field: Option<String>,
369 ) -> Result<String> {
370 Ok(if let Some(ref field) = field {
371 get_pretty_receipt_w_reason_attr(&receipt, field)
372 .ok_or_else(|| eyre::eyre!("invalid receipt field: {}", field))?
373 } else if shell::is_json() {
374 serde_json::to_value(&receipt)?.to_string()
376 } else {
377 receipt.pretty()
378 })
379 }
380}
381
382#[derive(Debug)]
387pub struct CastTxBuilder<N: Network, P, S> {
388 provider: P,
389 pub(crate) tx: N::TransactionRequest,
390 legacy: bool,
392 blob: bool,
393 eip4844: bool,
395 fill: bool,
398 browser: bool,
400 eip1559_fee_estimate: Eip1559FeeEstimatePreset,
402 auth: Vec<CliAuthorizationList>,
403 chain: Chain,
404 etherscan_api_key: Option<String>,
405 etherscan_api_url: Option<String>,
406 access_list: Option<Option<AccessList>>,
407 state: S,
408}
409
410impl<N: Network, P, S> CastTxBuilder<N, P, S> {
411 pub const fn chain(&self) -> Chain {
413 self.chain
414 }
415
416 pub const fn with_browser_wallet(mut self) -> Self {
418 self.browser = true;
419 self
420 }
421}
422
423impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, InitState>
424where
425 N::TransactionRequest: FoundryTransactionBuilder<N>,
426{
427 pub async fn new(provider: P, tx_opts: TransactionOpts, config: &Config) -> Result<Self> {
430 let mut tx = N::TransactionRequest::default();
431
432 let chain = utils::get_chain(config.chain, &provider).await?;
433 let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten();
434 let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone());
435 let etherscan_api_url = etherscan_config.map(|c| c.api_url);
436 let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty());
438
439 tx_opts.apply::<N>(&mut tx, legacy);
441
442 Ok(Self {
443 provider,
444 tx,
445 legacy,
446 blob: tx_opts.blob,
447 eip4844: tx_opts.eip4844,
448 fill: true,
449 browser: false,
450 eip1559_fee_estimate: config.eip1559_fee_estimate,
451 chain,
452 etherscan_api_key,
453 etherscan_api_url,
454 auth: tx_opts.auth,
455 access_list: tx_opts.access_list,
456 state: InitState,
457 })
458 }
459
460 pub async fn with_to(self, to: Option<NameOrAddress>) -> Result<CastTxBuilder<N, P, ToState>> {
462 let to = if let Some(to) = to { Some(to.resolve(&self.provider).await?) } else { None };
463 Ok(CastTxBuilder {
464 provider: self.provider,
465 tx: self.tx,
466 legacy: self.legacy,
467 blob: self.blob,
468 eip4844: self.eip4844,
469 fill: self.fill,
470 browser: self.browser,
471 eip1559_fee_estimate: self.eip1559_fee_estimate,
472 chain: self.chain,
473 etherscan_api_key: self.etherscan_api_key,
474 etherscan_api_url: self.etherscan_api_url,
475 auth: self.auth,
476 access_list: self.access_list,
477 state: ToState { to },
478 })
479 }
480}
481
482impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, ToState>
483where
484 N::TransactionRequest: FoundryTransactionBuilder<N>,
485{
486 pub async fn with_code_sig_and_args(
490 self,
491 code: Option<String>,
492 sig: Option<String>,
493 args: Vec<String>,
494 ) -> Result<CastTxBuilder<N, P, InputState>> {
495 let (mut args, func) = if let Some(sig) = sig {
496 parse_function_args(
497 &sig,
498 args,
499 self.state.to,
500 self.chain,
501 &self.provider,
502 self.etherscan_api_key.as_deref(),
503 self.etherscan_api_url.as_deref(),
504 )
505 .await?
506 } else {
507 (Vec::new(), None)
508 };
509
510 let input = if let Some(code) = &code {
511 let mut code = hex::decode(code)?;
512 code.append(&mut args);
513 code
514 } else {
515 args
516 };
517
518 if self.state.to.is_none() && code.is_none() {
519 let has_value = self.tx.value().is_some_and(|v| !v.is_zero());
520 let has_auth = !self.auth.is_empty();
521 if !has_auth || has_value {
524 eyre::bail!("Must specify a recipient address or contract code to deploy");
525 }
526 }
527
528 Ok(CastTxBuilder {
529 provider: self.provider,
530 tx: self.tx,
531 legacy: self.legacy,
532 blob: self.blob,
533 eip4844: self.eip4844,
534 fill: self.fill,
535 browser: self.browser,
536 eip1559_fee_estimate: self.eip1559_fee_estimate,
537 chain: self.chain,
538 etherscan_api_key: self.etherscan_api_key,
539 etherscan_api_url: self.etherscan_api_url,
540 auth: self.auth,
541 access_list: self.access_list,
542 state: InputState { kind: self.state.to.into(), input, func },
543 })
544 }
545}
546
547impl<N: Network, P: Provider<N>> CastTxBuilder<N, P, InputState>
548where
549 N::TransactionRequest: FoundryTransactionBuilder<N>,
550{
551 pub async fn build(
554 self,
555 sender: impl Into<SenderKind<'_>>,
556 ) -> Result<(N::TransactionRequest, Option<Function>)> {
557 let fill = self.fill;
558 self._build(sender, fill, None).await
559 }
560
561 pub async fn build_with_access_key(
567 mut self,
568 sender: impl Into<SenderKind<'_>>,
569 access_key: &TempoAccessKeyConfig,
570 ) -> Result<(N::TransactionRequest, Option<Function>)> {
571 self.tx.set_key_id(access_key.key_address);
572 let fill = self.fill;
573 self._build(sender, fill, Some(access_key)).await
574 }
575
576 async fn _build(
577 mut self,
578 sender: impl Into<SenderKind<'_>>,
579 fill: bool,
580 access_key: Option<&TempoAccessKeyConfig>,
581 ) -> Result<(N::TransactionRequest, Option<Function>)> {
582 let sender = sender.into();
584 self.prepare(&sender);
585
586 self.tx.clear_batch_to();
590
591 if fill || !self.auth.is_empty() {
595 let tx_nonce = self.resolve_nonce(sender.address(), fill).await?;
596 self.resolve_auth(&sender, tx_nonce).await?;
597 }
598 if let Some(access_key) = access_key {
599 self.tx
600 .prepare_access_key_authorization(
601 &self.provider,
602 access_key.wallet_address,
603 access_key.key_address,
604 access_key.key_authorization.as_ref(),
605 )
606 .await?;
607 }
608 if fill {
609 self.fill_fees().await?;
610 }
611 self.resolve_access_list().await?;
612 if fill {
613 self.fill_gas_limit().await?;
614 }
615
616 Ok((self.tx, self.state.func))
617 }
618
619 fn prepare(&mut self, sender: &SenderKind<'_>) {
622 self.tx.set_kind(self.state.kind);
623 self.tx.set_input_kind(self.state.input.clone(), TransactionInputKind::Both);
626 let sender = sender.address();
627 if !sender.is_zero() {
628 self.tx.set_from(sender);
629 }
630 self.tx.set_chain_id(self.chain.id());
631 }
632
633 async fn resolve_nonce(&mut self, from: Address, fill: bool) -> Result<u64> {
636 if let Some(nonce) = self.tx.nonce() {
637 Ok(nonce)
638 } else {
639 let nonce = self.provider.get_transaction_count(from).await?;
640 if fill {
641 self.tx.set_nonce(nonce);
642 }
643 Ok(nonce)
644 }
645 }
646
647 async fn resolve_access_list(&mut self) -> Result<()> {
650 if let Some(access_list) = match self.access_list.take() {
651 None => None,
652 Some(None) => Some(self.provider.create_access_list(&self.tx).await?.access_list),
653 Some(Some(access_list)) => Some(access_list),
654 } {
655 self.tx.set_access_list(access_list);
656 }
657 Ok(())
658 }
659
660 async fn resolve_auth(&mut self, sender: &SenderKind<'_>, tx_nonce: u64) -> Result<()> {
665 if self.auth.is_empty() {
666 return Ok(());
667 }
668
669 let auths = std::mem::take(&mut self.auth);
670
671 let address_auth_count =
674 auths.iter().filter(|a| matches!(a, CliAuthorizationList::Address(_))).count();
675 if address_auth_count > 1 {
676 eyre::bail!(
677 "Multiple address-based authorizations provided. Only one address can be specified; \
678 use pre-signed authorizations (hex-encoded) for multiple authorizations."
679 );
680 }
681
682 let mut signed_auths = Vec::with_capacity(auths.len());
683
684 for auth in auths {
685 let signed_auth = match auth {
686 CliAuthorizationList::Address(address) => {
687 let auth = Authorization {
688 chain_id: U256::from(self.chain.id()),
689 nonce: tx_nonce + 1,
690 address,
691 };
692
693 let Some(signer) = sender.as_signer() else {
694 eyre::bail!(
695 "No signer available to sign authorization. \
696 Provide a pre-signed authorization (hex-encoded) instead."
697 );
698 };
699 let signature = signer.sign_hash(&auth.signature_hash()).await?;
700
701 auth.into_signed(signature)
702 }
703 CliAuthorizationList::Signed(auth) => auth,
704 };
705 signed_auths.push(signed_auth);
706 }
707
708 self.tx.set_authorization_list(signed_auths);
709
710 Ok(())
711 }
712
713 async fn fill_fees(&mut self) -> Result<()> {
717 if self.blob && self.tx.max_fee_per_blob_gas().is_none() {
718 self.tx.set_max_fee_per_blob_gas(self.provider.get_blob_base_fee().await?)
719 }
720
721 fill_transaction_gas_fees(
722 &self.provider,
723 &mut self.tx,
724 self.legacy,
725 self.browser,
726 self.eip1559_fee_estimate,
727 )
728 .await
729 }
730
731 async fn fill_gas_limit(&mut self) -> Result<()> {
733 if self.tx.gas_limit().is_none() {
734 self.estimate_gas().await?;
735 }
736
737 Ok(())
738 }
739
740 async fn estimate_gas(&mut self) -> Result<()> {
742 match self.provider.estimate_gas(self.tx.clone()).await {
743 Ok(estimated) => {
744 self.tx.set_gas_limit(estimated);
745 Ok(())
746 }
747 Err(err) => {
748 if let TransportError::ErrorResp(payload) = &err {
749 if payload.code == 3
752 && let Some(data) = &payload.data
753 && let Ok(Some(decoded_error)) = decode_execution_revert(data).await
754 {
755 eyre::bail!("Failed to estimate gas: {}: {}", err, decoded_error);
756 }
757 }
758 eyre::bail!("Failed to estimate gas: {}", err);
759 }
760 }
761 }
762
763 pub fn with_blob_data(mut self, blob_data: Option<Vec<u8>>) -> Result<Self> {
765 let Some(blob_data) = blob_data else { return Ok(self) };
766
767 let mut coder = SidecarBuilder::<SimpleCoder>::default();
768 coder.ingest(&blob_data);
769
770 if self.eip4844 {
771 let sidecar = coder.build_4844()?;
772 self.tx.set_blob_sidecar_4844(sidecar);
773 } else {
774 let sidecar = coder.build_7594()?;
775 self.tx.set_blob_sidecar_7594(sidecar);
776 }
777
778 Ok(self)
779 }
780
781 pub const fn raw(mut self) -> Self {
784 self.fill = false;
785 self
786 }
787}
788
789pub(crate) async fn fill_transaction_gas_fees<N: Network, P: Provider<N>>(
791 provider: &P,
792 tx: &mut N::TransactionRequest,
793 legacy: bool,
794 browser: bool,
795 eip1559_fee_estimate: Eip1559FeeEstimatePreset,
796) -> Result<()>
797where
798 N::TransactionRequest: FoundryTransactionBuilder<N>,
799{
800 if legacy {
801 if tx.gas_price().is_none() {
802 tx.set_gas_price(provider.get_gas_price().await?);
803 }
804 return Ok(());
805 }
806
807 if tx.max_fee_per_gas().is_none() || tx.max_priority_fee_per_gas().is_none() {
808 let estimate = estimate_eip1559_fees(provider, eip1559_fee_estimate).await?;
809
810 let browser_suggested_tip = if browser && tx.max_priority_fee_per_gas().is_none() {
813 provider.get_max_priority_fee_per_gas().await.ok()
814 } else {
815 None
816 };
817
818 let estimate = resolve_broadcast_eip1559_fees(estimate, None, None, browser_suggested_tip)?;
821
822 if tx.max_fee_per_gas().is_none() {
823 tx.set_max_fee_per_gas(estimate.max_fee_per_gas);
824 }
825
826 if tx.max_priority_fee_per_gas().is_none() {
827 tx.set_max_priority_fee_per_gas(estimate.max_priority_fee_per_gas);
828 }
829 }
830
831 if let (Some(max_fee), Some(priority)) = (tx.max_fee_per_gas(), tx.max_priority_fee_per_gas()) {
832 eyre::ensure!(
833 priority <= max_fee,
834 "max priority fee per gas ({priority}) cannot exceed max fee per gas ({max_fee})"
835 );
836 }
837
838 Ok(())
839}
840
841async fn decode_execution_revert(data: &RawValue) -> Result<Option<String>> {
843 let err_data = serde_json::from_str::<Bytes>(data.get())?;
844 let Some(selector) = err_data.get(..4) else { return Ok(None) };
845 if let Some(known_error) =
846 SignaturesIdentifier::new(false)?.identify_error(selector.try_into().unwrap()).await
847 {
848 let mut decoded_error = known_error.name.clone();
849 if !known_error.inputs.is_empty()
850 && let Ok(error) = known_error.decode_error(&err_data)
851 {
852 write!(decoded_error, "({})", format_tokens(&error.body).format(", "))?;
853 }
854 return Ok(Some(decoded_error));
855 }
856 Ok(None)
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use alloy_network::Ethereum;
863 use alloy_provider::{ProviderBuilder, mock::Asserter};
864 use clap::Parser;
865
866 #[tokio::test]
867 async fn raw_build_skips_nonce_request() {
868 let provider =
871 ProviderBuilder::new_with_network::<Ethereum>().connect_mocked_client(Asserter::new());
872 let config = Config { chain: Some(Chain::mainnet()), ..Default::default() };
873
874 CastTxBuilder::new(&provider, TransactionOpts::parse_from(["test"]), &config)
875 .await
876 .unwrap()
877 .with_to(Some(Address::repeat_byte(0x11).into()))
878 .await
879 .unwrap()
880 .with_code_sig_and_args(None, None, Vec::new())
881 .await
882 .unwrap()
883 .raw()
884 .build(Address::repeat_byte(0x22))
885 .await
886 .unwrap();
887 }
888}