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