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