Skip to main content

cast/cmd/
send.rs

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