Skip to main content

cast/cmd/
send.rs

1use std::{path::PathBuf, str::FromStr, time::Duration};
2use url::Url;
3
4use alloy_consensus::{SignableTransaction, Signed};
5use alloy_ens::NameOrAddress;
6use alloy_network::{Ethereum, EthereumWallet, Network, TransactionBuilder};
7use alloy_primitives::{Address, B256};
8use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
9use alloy_signer::{Signature, Signer};
10use clap::Parser;
11use eyre::{Result, eyre};
12use foundry_cli::{
13    opts::TransactionOpts,
14    utils::{LoadConfig, get_chain, maybe_print_resolved_lane, resolve_lane},
15};
16use foundry_common::{
17    FoundryTransactionBuilder,
18    fmt::{UIfmt, UIfmtReceiptExt},
19    provider::ProviderBuilder,
20    tempo::{maybe_print_fee_token, resolve_and_set_fee_token},
21};
22use foundry_config::Chain;
23use foundry_wallets::{TempoAccountsWallet, WalletSigner};
24use tempo_alloy::TempoNetwork;
25use tempo_primitives::transaction::FEE_PAYER_SIGNATURE_MARKER;
26
27use crate::{
28    cmd::{auth::confirm_auth_rpc_disclosure_during_build, tip20::iso4217_warning_message},
29    tempo,
30    tx::{self, CastTxBuilder, CastTxSender, SendTxOpts},
31};
32use tempo_contracts::precompiles::{TIP20_FACTORY_ADDRESS, is_iso4217_currency};
33
34/// CLI arguments for `cast send`.
35#[derive(Debug, Parser)]
36pub struct SendTxArgs {
37    /// The destination of the transaction.
38    ///
39    /// If not provided, you must use cast send --create.
40    #[arg(value_parser = NameOrAddress::from_str)]
41    to: Option<NameOrAddress>,
42
43    /// The signature of the function to call.
44    sig: Option<String>,
45
46    /// The arguments of the function to call.
47    #[arg(allow_negative_numbers = true)]
48    args: Vec<String>,
49
50    /// Raw hex-encoded data for the transaction. Used instead of `SIG` and `ARGS`.
51    #[arg(
52        long,
53        conflicts_with_all = &["sig", "args"]
54    )]
55    data: Option<String>,
56
57    #[command(flatten)]
58    send_tx: SendTxOpts,
59
60    #[command(subcommand)]
61    command: Option<SendTxSubcommands>,
62
63    /// Send via `eth_sendTransaction` using the `--from` argument or $ETH_FROM as sender
64    #[arg(long, requires = "from")]
65    unlocked: bool,
66
67    /// Skip confirmation prompts (e.g. non-ISO 4217 currency warnings).
68    #[arg(long)]
69    force: bool,
70
71    /// Relative percentage to multiply the gas estimate by.
72    #[arg(long, value_name = "PERCENT", help_heading = "Transaction options")]
73    gas_estimate_multiplier: Option<u64>,
74
75    #[command(flatten)]
76    tx: TransactionOpts,
77
78    /// The path of blob data to be sent.
79    #[arg(
80        long,
81        value_name = "BLOB_DATA_PATH",
82        conflicts_with = "legacy",
83        requires = "blob",
84        help_heading = "Transaction options"
85    )]
86    path: Option<PathBuf>,
87}
88
89#[derive(Debug, Parser)]
90pub enum SendTxSubcommands {
91    /// Use to deploy raw contract bytecode.
92    #[command(name = "--create")]
93    Create {
94        /// The bytecode of the contract to deploy.
95        code: String,
96
97        /// The signature of the function to call.
98        sig: Option<String>,
99
100        /// The arguments of the function to call.
101        #[arg(allow_negative_numbers = true)]
102        args: Vec<String>,
103    },
104}
105
106impl SendTxArgs {
107    pub async fn run(self) -> Result<()> {
108        if self.tx.tempo.session_id()?.is_some() {
109            return self.run_generic::<TempoNetwork>(None, None).await;
110        }
111
112        let (is_tempo, signer, tempo_access_key) =
113            tempo::resolve_transaction_network_and_signer(&self.tx.tempo, &self.send_tx.eth)
114                .await?;
115
116        if is_tempo {
117            self.run_generic::<TempoNetwork>(signer, tempo_access_key).await
118        } else {
119            self.run_generic::<Ethereum>(signer, None).await
120        }
121    }
122
123    pub async fn run_generic<N: Network>(
124        self,
125        pre_resolved_signer: Option<WalletSigner>,
126        mut access_key: Option<TempoAccountsWallet>,
127    ) -> Result<()>
128    where
129        N::TxEnvelope: From<Signed<N::UnsignedTx>>,
130        N::UnsignedTx: SignableTransaction<Signature>,
131        N::TransactionRequest: FoundryTransactionBuilder<N>,
132        N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
133    {
134        let Self {
135            to,
136            mut sig,
137            mut args,
138            data,
139            send_tx,
140            mut tx,
141            command,
142            unlocked,
143            force,
144            gas_estimate_multiplier,
145            path,
146        } = self;
147
148        let has_session = tx.tempo.session_id()?.is_some();
149        if has_session && unlocked {
150            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked");
151        }
152        if has_session && send_tx.browser.browser {
153            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
154        }
155
156        let print_sponsor_hash = tx.tempo.print_sponsor_hash;
157        let sponsor_url = tx.tempo.sponsor_url.clone();
158        let sponsor_fee_payer = tx.tempo.sponsor;
159        let expires_at = tx.tempo.resolve_expires();
160        let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() {
161            None
162        } else {
163            tx.tempo.sponsor_config().await?
164        };
165
166        let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None };
167
168        if let Some(data) = data {
169            sig = Some(data);
170        }
171
172        let code = if let Some(SendTxSubcommands::Create {
173            code,
174            sig: constructor_sig,
175            args: constructor_args,
176        }) = command
177        {
178            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
179            // which require mandatory target
180            if to.is_none() && !tx.auth.is_empty() {
181                return Err(eyre!(
182                    "EIP-7702 transactions can't be CREATE transactions and require a destination address"
183                ));
184            }
185            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
186            // which require mandatory target
187            if to.is_none() && blob_data.is_some() {
188                return Err(eyre!(
189                    "EIP-4844 transactions can't be CREATE transactions and require a destination address"
190                ));
191            }
192
193            sig = constructor_sig;
194            args = constructor_args;
195            Some(code)
196        } else {
197            None
198        };
199
200        // Validate ISO 4217 currency code for TIP20Factory createToken calls.
201        if let Some(ref to_addr) = to {
202            let is_factory = match to_addr {
203                NameOrAddress::Address(addr) => *addr == TIP20_FACTORY_ADDRESS,
204                NameOrAddress::Name(name) => {
205                    Address::from_str(name).ok() == Some(TIP20_FACTORY_ADDRESS)
206                }
207            };
208
209            if !force
210                && is_factory
211                && let Some(ref sig_str) = sig
212                && sig_str.starts_with("createToken")
213                && let Some(currency) = args.get(2)
214                && !is_iso4217_currency(currency)
215            {
216                sh_warn!("{}", iso4217_warning_message(currency))?;
217                let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?;
218                if !matches!(response.trim(), "y" | "Y") {
219                    sh_status!("Aborted.")?;
220                    return Ok(());
221                }
222            }
223        }
224
225        let config = send_tx.eth.load_config()?;
226        let provider = ProviderBuilder::<N>::from_config(&config)?.build()?;
227
228        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
229
230        if let Some(interval) = send_tx.poll_interval {
231            provider.client().set_poll_interval(Duration::from_secs(interval))
232        }
233
234        if has_session || access_key.is_some() {
235            let chain = get_chain(config.chain, &provider).await?;
236            let (_, resolved_access_key) =
237                tempo::resolve_session_or_wallet_signer(&tx.tempo, &send_tx.eth.wallet, chain.id())
238                    .await?;
239            access_key = resolved_access_key;
240        }
241
242        // Inject access key ID into TempoOpts so it's set before gas estimation.
243        if let Some(ref ak) = access_key {
244            tx.tempo.key_id = Some(ak.key_id()?);
245        }
246
247        let builder = CastTxBuilder::new(&provider, tx, &config)
248            .await?
249            .with_gas_estimate_multiplier(gas_estimate_multiplier)
250            .with_to(to)
251            .await?
252            .with_code_sig_and_args(code, sig, args)
253            .await?
254            .with_blob_data(blob_data)?;
255
256        // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit.
257        if print_sponsor_hash {
258            let chain = builder.chain();
259            let (mut tx, from) = if let Some(ref ak) = access_key {
260                if !confirm_auth_rpc_disclosure_during_build(&builder, ak.account(), force)? {
261                    return Ok(());
262                }
263                let (tx, _, prepared) = builder.build_with_tempo_wallet(ak).await?;
264                (tx, prepared.account())
265            } else {
266                // Use the pre-resolved signer to derive the actual sender address, since the
267                // sponsor hash commits to the sender.
268                let signer = pre_resolved_signer.as_ref().ok_or_else(|| {
269                    eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)")
270                })?;
271                let from = signer.address();
272                if !confirm_auth_rpc_disclosure_during_build(&builder, signer, force)? {
273                    return Ok(());
274                }
275                let (tx, _) = builder.build(signer).await?;
276                (tx, from)
277            };
278            if let Some(fee_payer) = sponsor_fee_payer {
279                resolve_and_set_fee_token(
280                    (!config.eth_rpc_curl).then_some(&provider),
281                    Some(chain),
282                    &mut tx,
283                    Some(fee_payer),
284                )
285                .await?;
286            }
287            let hash = tx
288                .compute_sponsor_hash(from)
289                .ok_or_else(|| eyre!("This network does not support sponsored transactions"))?;
290            sh_println!("{hash:?}")?;
291            return Ok(());
292        }
293
294        if let Some(ts) = expires_at {
295            sh_status!("Transaction expires at unix timestamp {ts}")?;
296        }
297
298        let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
299
300        // --sponsor-url is valid with local signers and Tempo access keys. Bail early rather than
301        // silently ignoring it in signing paths that cannot produce a raw transaction locally.
302        if let Some(ref url) = sponsor_url {
303            validate_sponsor_url(url)?;
304            if unlocked {
305                eyre::bail!("--sponsor-url cannot be combined with --unlocked");
306            }
307            if send_tx.browser.browser {
308                eyre::bail!("--sponsor-url cannot be combined with --browser");
309            }
310        }
311
312        // Launch browser signer if `--browser` flag is set
313        let browser = send_tx.browser.run::<N>().await?;
314
315        // Case 1:
316        // Default to sending via eth_sendTransaction if the --unlocked flag is passed.
317        // This should be the only way this RPC method is used as it requires a local node
318        // or remote RPC with unlocked accounts.
319        if unlocked && browser.is_none() {
320            // only check current chain id if it was specified in the config
321            if let Some(config_chain) = config.chain {
322                let current_chain_id = provider.get_chain_id().await?;
323                let config_chain_id = config_chain.id();
324                // switch chain if current chain id is not the same as the one specified in the
325                // config
326                if config_chain_id != current_chain_id {
327                    sh_warn!("Switching to chain {}", config_chain)?;
328                    provider
329                        .raw_request::<_, ()>(
330                            "wallet_switchEthereumChain".into(),
331                            [serde_json::json!({
332                                "chainId": format!("0x{:x}", config_chain_id),
333                            })],
334                        )
335                        .await?;
336                }
337            }
338
339            let chain = builder.chain();
340            if !confirm_auth_rpc_disclosure_during_build(&builder, config.sender, force)? {
341                return Ok(());
342            }
343            let (mut tx_request, _) = builder.build(config.sender).await?;
344            maybe_print_resolved_lane(
345                resolved_lane.as_ref(),
346                tx_request.nonce().unwrap_or_default(),
347            )?;
348            if let Some(sponsor) = &tempo_sponsor {
349                sponsor
350                    .resolve_and_set_fee_token(
351                        (!config.eth_rpc_curl).then_some(&provider),
352                        Some(chain),
353                        &mut tx_request,
354                    )
355                    .await?;
356                sponsor.attach_and_print::<N>(&mut tx_request, config.sender).await?;
357            }
358
359            cast_send(
360                provider,
361                tx_request,
362                tempo_sponsor.is_none().then_some(chain),
363                None,
364                send_tx.cast_async,
365                send_tx.sync,
366                send_tx.confirmations,
367                timeout,
368                tempo_sponsor.is_none() && !config.eth_rpc_curl,
369            )
370            .await?;
371        // Case 2:
372        // Browser wallet signs and sends the transaction in one step.
373        } else if let Some(browser) = browser {
374            let chain = builder.chain();
375            if !confirm_auth_rpc_disclosure_during_build(&builder, browser.address(), force)? {
376                return Ok(());
377            }
378            let (mut tx_request, _) =
379                builder.with_browser_wallet().build(browser.address()).await?;
380            maybe_print_resolved_lane(
381                resolved_lane.as_ref(),
382                tx_request.nonce().unwrap_or_default(),
383            )?;
384
385            if let Some(sponsor) = &tempo_sponsor {
386                sponsor
387                    .resolve_and_set_fee_token(
388                        (!config.eth_rpc_curl).then_some(&provider),
389                        Some(chain),
390                        &mut tx_request,
391                    )
392                    .await?;
393                sponsor.attach_and_print::<N>(&mut tx_request, browser.address()).await?;
394            } else {
395                let fee_token = resolve_and_set_fee_token(
396                    (!config.eth_rpc_curl).then_some(&provider),
397                    Some(chain),
398                    &mut tx_request,
399                    Some(browser.address()),
400                )
401                .await?;
402                maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token)
403                    .await?;
404            }
405
406            if chain.id() != browser.chain_id() {
407                sh_warn!("Switching browser wallet to chain {}", chain)?;
408                browser.switch_chain(chain.id()).await?;
409            }
410
411            let tx_hash = browser.send_transaction_via_browser(tx_request).await?;
412
413            let cast = CastTxSender::new(&provider);
414            cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
415                .await?;
416        // Case 3: Tempo access-key wallet.
417        } else if let Some(ak) = access_key {
418            let chain = builder.chain();
419            if !confirm_auth_rpc_disclosure_during_build(&builder, ak.account(), force)? {
420                return Ok(());
421            }
422            let (mut tx_request, _, prepared) = builder.build_with_tempo_wallet(&ak).await?;
423            maybe_print_resolved_lane(
424                resolved_lane.as_ref(),
425                tx_request.nonce().unwrap_or_default(),
426            )?;
427            if let Some(sponsor) = &tempo_sponsor {
428                sponsor
429                    .resolve_and_set_fee_token(
430                        (!config.eth_rpc_curl).then_some(&provider),
431                        Some(chain),
432                        &mut tx_request,
433                    )
434                    .await?;
435                sponsor.attach_and_print::<N>(&mut tx_request, prepared.account()).await?;
436            }
437            if let Some(sponsor_url) = sponsor_url.as_deref() {
438                cast_send_with_tempo_wallet_via_sponsor(
439                    &provider,
440                    tx_request,
441                    &prepared,
442                    sponsor_url,
443                    send_tx.cast_async,
444                    send_tx.sync,
445                    send_tx.confirmations,
446                    timeout,
447                )
448                .await?;
449            } else {
450                cast_send_with_tempo_wallet(
451                    &provider,
452                    tx_request,
453                    &prepared,
454                    tempo_sponsor.is_none().then_some(chain),
455                    None,
456                    send_tx.cast_async,
457                    send_tx.sync,
458                    send_tx.confirmations,
459                    timeout,
460                    tempo_sponsor.is_none() && !config.eth_rpc_curl,
461                )
462                .await?;
463            }
464        // Case 4:
465        // Remote sponsor URL: sign locally, ask the sponsor service for a fee-payer signature,
466        // then submit the fully-sponsored tx to the regular RPC.
467        } else if let Some(sponsor_url) = sponsor_url {
468            let signer = match pre_resolved_signer {
469                Some(s) => s,
470                None => send_tx.eth.wallet.signer().await?,
471            };
472            let from = signer.address();
473
474            tx::validate_from_address(send_tx.eth.wallet.from, from)?;
475
476            if !confirm_auth_rpc_disclosure_during_build(&builder, &signer, force)? {
477                return Ok(());
478            }
479            let (mut tx_request, _) = builder.build(&signer).await?;
480            maybe_print_resolved_lane(
481                resolved_lane.as_ref(),
482                tx_request.nonce().unwrap_or_default(),
483            )?;
484
485            tx_request.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
486
487            let wallet = EthereumWallet::from(signer);
488            let connector = tempo::sponsor_relay_connector(&provider, &sponsor_url)?;
489            let provider = AlloyProviderBuilder::<_, _, N>::default()
490                .wallet(wallet)
491                .connect_with(&connector)
492                .await?;
493
494            cast_send(
495                provider,
496                tx_request,
497                None,
498                None,
499                send_tx.cast_async,
500                send_tx.sync,
501                send_tx.confirmations,
502                timeout,
503                false,
504            )
505            .await?;
506        // Case 5:
507        // An option to use a local signer was provided.
508        // If we cannot successfully instantiate a local signer, then we will assume we don't have
509        // enough information to sign and we must bail.
510        } else {
511            let signer = match pre_resolved_signer {
512                Some(s) => s,
513                None => send_tx.eth.wallet.signer().await?,
514            };
515            let from = signer.address();
516
517            tx::validate_from_address(send_tx.eth.wallet.from, from)?;
518
519            let chain = builder.chain();
520            if !confirm_auth_rpc_disclosure_during_build(&builder, &signer, force)? {
521                return Ok(());
522            }
523            let (mut tx_request, _) = builder.build(&signer).await?;
524            maybe_print_resolved_lane(
525                resolved_lane.as_ref(),
526                tx_request.nonce().unwrap_or_default(),
527            )?;
528
529            if let Some(sponsor) = &tempo_sponsor {
530                sponsor
531                    .resolve_and_set_fee_token(
532                        (!config.eth_rpc_curl).then_some(&provider),
533                        Some(chain),
534                        &mut tx_request,
535                    )
536                    .await?;
537                sponsor.attach_and_print::<N>(&mut tx_request, from).await?;
538            }
539
540            let wallet = EthereumWallet::from(signer);
541            let provider = AlloyProviderBuilder::<_, _, N>::default()
542                .wallet(wallet)
543                .connect_provider(&provider);
544
545            cast_send(
546                provider,
547                tx_request,
548                tempo_sponsor.is_none().then_some(chain),
549                None,
550                send_tx.cast_async,
551                send_tx.sync,
552                send_tx.confirmations,
553                timeout,
554                tempo_sponsor.is_none() && !config.eth_rpc_curl,
555            )
556            .await?;
557        }
558
559        Ok(())
560    }
561}
562
563#[allow(clippy::too_many_arguments)]
564pub(crate) async fn cast_send<N: Network, P: Provider<N>>(
565    provider: P,
566    mut tx: N::TransactionRequest,
567    chain: Option<Chain>,
568    fee_payer: Option<Address>,
569    cast_async: bool,
570    sync: bool,
571    confs: u64,
572    timeout: u64,
573    resolve_unknown_fee_token_symbol: bool,
574) -> Result<B256>
575where
576    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
577    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
578{
579    let fee_token = resolve_and_set_fee_token(
580        resolve_unknown_fee_token_symbol.then_some(&provider),
581        chain,
582        &mut tx,
583        fee_payer,
584    )
585    .await?;
586    maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(&provider), fee_token).await?;
587    let cast = CastTxSender::new(provider);
588
589    if sync {
590        // JSON envelope not supported: N::ReceiptResponse is generic over Display but not
591        // Serialize; adding Serialize would ripple across all network-generic callers.
592        let (tx_hash, receipt) = cast.send_sync(tx).await?;
593        sh_println!("{receipt}")?;
594        Ok(tx_hash)
595    } else {
596        let pending_tx = cast.send(tx).await?;
597        let tx_hash = *pending_tx.inner().tx_hash();
598        cast.print_tx_result(tx_hash, cast_async, confs, timeout).await?;
599        Ok(tx_hash)
600    }
601}
602
603/// Sends a raw transaction using the RPC method selected by `sync`.
604pub(crate) async fn cast_send_raw<N: Network, P: Provider<N>>(
605    provider: &P,
606    raw_tx: &[u8],
607    sync: bool,
608) -> Result<(B256, Option<String>)>
609where
610    N::TransactionRequest: FoundryTransactionBuilder<N>,
611    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
612{
613    if sync {
614        let (tx_hash, receipt) = CastTxSender::new(provider).send_raw_sync(raw_tx).await?;
615        Ok((tx_hash, Some(receipt)))
616    } else {
617        let tx_hash = *provider.send_raw_transaction(raw_tx).await?.tx_hash();
618        Ok((tx_hash, None))
619    }
620}
621
622/// Signs a prepared transaction with a Tempo wallet and sends it as a raw transaction.
623#[allow(clippy::too_many_arguments)]
624pub(crate) async fn cast_send_with_tempo_wallet<N: Network, P: Provider<N>>(
625    provider: &P,
626    mut tx: N::TransactionRequest,
627    wallet: &TempoAccountsWallet,
628    chain: Option<Chain>,
629    fee_payer: Option<Address>,
630    cast_async: bool,
631    sync: bool,
632    confirmations: u64,
633    timeout: u64,
634    resolve_unknown_fee_token_symbol: bool,
635) -> Result<B256>
636where
637    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
638    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
639{
640    let fee_token = resolve_and_set_fee_token(
641        resolve_unknown_fee_token_symbol.then_some(provider),
642        chain,
643        &mut tx,
644        fee_payer,
645    )
646    .await?;
647    maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(provider), fee_token).await?;
648    let raw_tx = tx.sign_with_tempo_wallet(wallet).await?;
649    let cast = CastTxSender::new(provider);
650    let (tx_hash, receipt) = cast_send_raw(provider, &raw_tx, sync).await?;
651
652    if let Some(receipt) = receipt {
653        sh_println!("{receipt}")?;
654    } else {
655        cast.print_tx_result(tx_hash, cast_async, confirmations, timeout).await?;
656    }
657    Ok(tx_hash)
658}
659
660/// Signs a prepared transaction with a Tempo wallet, obtains a remote fee-payer signature, and
661/// broadcasts the sponsored transaction through the original provider transport.
662#[allow(clippy::too_many_arguments)]
663pub(crate) async fn cast_send_with_tempo_wallet_via_sponsor<N: Network, P: Provider<N>>(
664    provider: &P,
665    mut tx: N::TransactionRequest,
666    wallet: &TempoAccountsWallet,
667    sponsor_url: &str,
668    cast_async: bool,
669    sync: bool,
670    confirmations: u64,
671    timeout: u64,
672) -> Result<B256>
673where
674    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
675    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
676{
677    tx.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
678    let connector = tempo::sponsor_relay_connector(provider, sponsor_url)?;
679    let sponsor_provider =
680        AlloyProviderBuilder::<_, _, N>::default().connect_with(&connector).await?;
681    cast_send_with_tempo_wallet(
682        &sponsor_provider,
683        tx,
684        wallet,
685        None,
686        None,
687        cast_async,
688        sync,
689        confirmations,
690        timeout,
691        false,
692    )
693    .await
694}
695
696/// Validates that a sponsor URL uses https:// (localhost/127.0.0.1 may use http://).
697pub(crate) fn validate_sponsor_url(raw: &str) -> Result<()> {
698    let url = Url::parse(raw)
699        .map_err(|e| eyre::eyre!("--sponsor-url is not a valid URL ({raw}): {e}"))?;
700
701    match url.scheme() {
702        "https" => Ok(()),
703        "http" => {
704            let host = url.host_str().unwrap_or("");
705            if host == "localhost" || host == "127.0.0.1" {
706                return Ok(());
707            }
708            eyre::bail!(
709                "--sponsor-url must use https:// for non-local endpoints (got {raw}). \
710                 The sponsor relay is a trusted third party; use an encrypted channel."
711            );
712        }
713        _ => {
714            eyre::bail!(
715                "--sponsor-url must start with https:// (got {raw}). \
716             The sponsor relay is a trusted third party; use an encrypted channel."
717            );
718        }
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use alloy_json_rpc::{RequestPacket, ResponsePacket};
726    use alloy_provider::mock::Asserter;
727    use alloy_rpc_client::RpcClient;
728    use alloy_rpc_types::TransactionRequest;
729    use alloy_transport::{TransportError, TransportFut, mock::MockTransport};
730    use foundry_wallets::utils::create_local_signer;
731    use std::{
732        sync::{Arc, Mutex},
733        task::{Context, Poll},
734    };
735    use tempo_alloy::rpc::TempoTransactionRequest;
736    use tower::Service;
737
738    #[derive(Clone)]
739    struct RecordingTransport {
740        inner: MockTransport,
741        methods: Arc<Mutex<Vec<String>>>,
742    }
743
744    impl Service<RequestPacket> for RecordingTransport {
745        type Response = ResponsePacket;
746        type Error = TransportError;
747        type Future = TransportFut<'static>;
748
749        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
750            self.inner.poll_ready(cx)
751        }
752
753        fn call(&mut self, req: RequestPacket) -> Self::Future {
754            if let RequestPacket::Single(req) = &req {
755                self.methods.lock().unwrap().push(req.method().to_string());
756            }
757            self.inner.call(req)
758        }
759    }
760
761    #[test]
762    fn test_validate_sponsor_url() {
763        // accepted
764        assert!(validate_sponsor_url("https://sponsor.tempo.xyz/tp_abc").is_ok());
765        assert!(validate_sponsor_url("http://localhost:8545").is_ok());
766        assert!(validate_sponsor_url("http://127.0.0.1:8545").is_ok());
767
768        // rejected
769        assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err());
770        assert!(validate_sponsor_url("not-a-url").is_err());
771        // bypass attempts that fooled the old starts_with check
772        assert!(validate_sponsor_url("http://localhost.evil.com").is_err());
773        assert!(validate_sponsor_url("http://127.0.0.1.evil.com").is_err());
774    }
775
776    #[test]
777    fn parses_gas_estimate_multiplier() {
778        let args = SendTxArgs::parse_from([
779            "cast-send",
780            "0x0000000000000000000000000000000000000000",
781            "--gas-estimate-multiplier",
782            "125",
783        ]);
784        assert_eq!(args.gas_estimate_multiplier, Some(125));
785    }
786
787    #[tokio::test]
788    async fn tempo_wallet_sync_send_uses_sync_rpc_method() {
789        let asserter = Asserter::new();
790        let tx_hash = B256::repeat_byte(0x11);
791        asserter.push_success(&serde_json::json!({
792            "type": "0x76",
793            "status": "0x1",
794            "cumulativeGasUsed": "0x5208",
795            "logs": [],
796            "logsBloom": format!("0x{}", "00".repeat(256)),
797            "transactionHash": tx_hash,
798            "transactionIndex": "0x0",
799            "blockHash": B256::repeat_byte(0x22),
800            "blockNumber": "0x1",
801            "gasUsed": "0x5208",
802            "effectiveGasPrice": "0x1",
803            "from": Address::ZERO,
804            "to": Address::ZERO,
805            "contractAddress": null,
806            "feeToken": Address::repeat_byte(0x55),
807            "feePayer": Address::ZERO,
808        }));
809        let methods = Arc::new(Mutex::new(Vec::new()));
810        let transport =
811            RecordingTransport { inner: MockTransport::new(asserter), methods: methods.clone() };
812        let provider = AlloyProviderBuilder::new_with_network::<TempoNetwork>()
813            .connect_client(RpcClient::new(transport, true));
814        let root = Address::repeat_byte(0x33);
815        let access_key = create_local_signer(
816            "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
817        )
818        .unwrap();
819        let wallet =
820            TempoAccountsWallet::from_secp256k1(root, access_key, None).with_chain_id(4217);
821        let tx = TempoTransactionRequest {
822            inner: TransactionRequest {
823                to: Some(Address::repeat_byte(0x44).into()),
824                nonce: Some(0),
825                gas: Some(100_000),
826                max_fee_per_gas: Some(1),
827                max_priority_fee_per_gas: Some(1),
828                chain_id: Some(4217),
829                ..Default::default()
830            },
831            ..Default::default()
832        };
833
834        let actual_hash = cast_send_with_tempo_wallet(
835            &provider, tx, &wallet, None, None, false, true, 1, 1, false,
836        )
837        .await
838        .unwrap();
839
840        assert_eq!(actual_hash, tx_hash);
841        assert_eq!(methods.lock().unwrap().as_slice(), ["eth_sendRawTransactionSync"]);
842    }
843}