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_rpc_client::BuiltInConnectionString;
10use alloy_signer::{Signature, Signer};
11use clap::Parser;
12use eyre::{Result, eyre};
13use foundry_cli::{
14    opts::TransactionOpts,
15    utils::{LoadConfig, get_chain, maybe_print_resolved_lane, resolve_lane},
16};
17use foundry_common::{
18    FoundryTransactionBuilder,
19    fmt::{UIfmt, UIfmtReceiptExt},
20    provider::ProviderBuilder,
21    tempo::{TEMPO_BROWSER_GAS_BUFFER, print_resolved_fee_token_selection},
22};
23use foundry_config::Chain;
24use foundry_wallets::{TempoAccessKeyConfig, WalletSigner};
25use tempo_alloy::{
26    TempoNetwork,
27    transport::{RelayConnector, SponsorshipMode},
28};
29use tempo_primitives::transaction::FEE_PAYER_SIGNATURE_MARKER;
30
31use crate::{
32    cmd::tip20::iso4217_warning_message,
33    tx::{self, CastTxBuilder, CastTxSender, SendTxOpts},
34};
35use tempo_contracts::precompiles::{TIP20_FACTORY_ADDRESS, is_iso4217_currency};
36
37/// CLI arguments for `cast send`.
38#[derive(Debug, Parser)]
39pub struct SendTxArgs {
40    /// The destination of the transaction.
41    ///
42    /// If not provided, you must use cast send --create.
43    #[arg(value_parser = NameOrAddress::from_str)]
44    to: Option<NameOrAddress>,
45
46    /// The signature of the function to call.
47    sig: Option<String>,
48
49    /// The arguments of the function to call.
50    #[arg(allow_negative_numbers = true)]
51    args: Vec<String>,
52
53    /// Raw hex-encoded data for the transaction. Used instead of \[SIG\] and \[ARGS\].
54    #[arg(
55        long,
56        conflicts_with_all = &["sig", "args"]
57    )]
58    data: Option<String>,
59
60    #[command(flatten)]
61    send_tx: SendTxOpts,
62
63    #[command(subcommand)]
64    command: Option<SendTxSubcommands>,
65
66    /// Send via `eth_sendTransaction` using the `--from` argument or $ETH_FROM as sender
67    #[arg(long, requires = "from")]
68    unlocked: bool,
69
70    /// Skip confirmation prompts (e.g. non-ISO 4217 currency warnings).
71    #[arg(long)]
72    force: bool,
73
74    #[command(flatten)]
75    tx: TransactionOpts,
76
77    /// The path of blob data to be sent.
78    #[arg(
79        long,
80        value_name = "BLOB_DATA_PATH",
81        conflicts_with = "legacy",
82        requires = "blob",
83        help_heading = "Transaction options"
84    )]
85    path: Option<PathBuf>,
86}
87
88#[derive(Debug, Parser)]
89pub enum SendTxSubcommands {
90    /// Use to deploy raw contract bytecode.
91    #[command(name = "--create")]
92    Create {
93        /// The bytecode of the contract to deploy.
94        code: String,
95
96        /// The signature of the function to call.
97        sig: Option<String>,
98
99        /// The arguments of the function to call.
100        #[arg(allow_negative_numbers = true)]
101        args: Vec<String>,
102    },
103}
104
105impl SendTxArgs {
106    pub async fn run(self) -> Result<()> {
107        if self.tx.tempo.session_id()?.is_some() {
108            return self.run_generic::<TempoNetwork>(None, None).await;
109        }
110
111        // Resolve the signer early so we know if it's a Tempo access key.
112        let (signer, tempo_access_key) = self.send_tx.eth.wallet.maybe_signer().await?;
113
114        if tempo_access_key.is_some() || self.tx.tempo.is_tempo() {
115            self.run_generic::<TempoNetwork>(signer, tempo_access_key).await
116        } else {
117            self.run_generic::<Ethereum>(signer, None).await
118        }
119    }
120
121    pub async fn run_generic<N: Network>(
122        self,
123        mut pre_resolved_signer: Option<WalletSigner>,
124        mut access_key: Option<TempoAccessKeyConfig>,
125    ) -> Result<()>
126    where
127        N::TxEnvelope: From<Signed<N::UnsignedTx>>,
128        N::UnsignedTx: SignableTransaction<Signature>,
129        N::TransactionRequest: FoundryTransactionBuilder<N>,
130        N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
131    {
132        let Self { to, mut sig, mut args, data, send_tx, mut tx, command, unlocked, force, path } =
133            self;
134
135        let has_session = tx.tempo.session_id()?.is_some();
136        if has_session && unlocked {
137            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked");
138        }
139        if has_session && send_tx.browser.browser {
140            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
141        }
142
143        let print_sponsor_hash = tx.tempo.print_sponsor_hash;
144        let sponsor_url = tx.tempo.sponsor_url.clone();
145        let expires_at = tx.tempo.resolve_expires();
146        let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() {
147            None
148        } else {
149            tx.tempo.sponsor_config().await?
150        };
151
152        let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None };
153
154        if let Some(data) = data {
155            sig = Some(data);
156        }
157
158        let code = if let Some(SendTxSubcommands::Create {
159            code,
160            sig: constructor_sig,
161            args: constructor_args,
162        }) = command
163        {
164            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
165            // which require mandatory target
166            if to.is_none() && !tx.auth.is_empty() {
167                return Err(eyre!(
168                    "EIP-7702 transactions can't be CREATE transactions and require a destination address"
169                ));
170            }
171            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
172            // which require mandatory target
173            if to.is_none() && blob_data.is_some() {
174                return Err(eyre!(
175                    "EIP-4844 transactions can't be CREATE transactions and require a destination address"
176                ));
177            }
178
179            sig = constructor_sig;
180            args = constructor_args;
181            Some(code)
182        } else {
183            None
184        };
185
186        // Validate ISO 4217 currency code for TIP20Factory createToken calls.
187        if let Some(ref to_addr) = to {
188            let is_factory = match to_addr {
189                NameOrAddress::Address(addr) => *addr == TIP20_FACTORY_ADDRESS,
190                NameOrAddress::Name(name) => {
191                    Address::from_str(name).ok() == Some(TIP20_FACTORY_ADDRESS)
192                }
193            };
194
195            if !force
196                && is_factory
197                && let Some(ref sig_str) = sig
198                && sig_str.starts_with("createToken")
199                && let Some(currency) = args.get(2)
200                && !is_iso4217_currency(currency)
201            {
202                sh_warn!("{}", iso4217_warning_message(currency))?;
203                let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?;
204                if !matches!(response.trim(), "y" | "Y") {
205                    sh_status!("Aborted.")?;
206                    return Ok(());
207                }
208            }
209        }
210
211        let config = send_tx.eth.load_config()?;
212        let provider = ProviderBuilder::<N>::from_config(&config)?.build()?;
213
214        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
215
216        if let Some(interval) = send_tx.poll_interval {
217            provider.client().set_poll_interval(Duration::from_secs(interval))
218        }
219
220        if has_session
221            && let Some(session) = tx.tempo.session_signer_for_wallet(
222                &send_tx.eth.wallet,
223                get_chain(config.chain, &provider).await?.id(),
224            )?
225        {
226            pre_resolved_signer = Some(session.signer);
227            access_key = Some(session.access_key);
228        }
229
230        // Inject access key ID into TempoOpts so it's set before gas estimation.
231        if let Some(ref ak) = access_key {
232            tx.tempo.key_id = Some(ak.key_address);
233        }
234
235        let builder = CastTxBuilder::new(&provider, tx, &config)
236            .await?
237            .with_to(to)
238            .await?
239            .with_code_sig_and_args(code, sig, args)
240            .await?
241            .with_blob_data(blob_data)?;
242
243        // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit.
244        if print_sponsor_hash {
245            let (tx, from) = if let Some(ref ak) = access_key {
246                let (tx, _) = builder.build_with_access_key(ak.wallet_address, ak).await?;
247                (tx, ak.wallet_address)
248            } else {
249                // Use the pre-resolved signer to derive the actual sender address, since the
250                // sponsor hash commits to the sender.
251                let signer = pre_resolved_signer.as_ref().ok_or_else(|| {
252                    eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)")
253                })?;
254                let from = signer.address();
255                let (tx, _) = builder.build(from).await?;
256                (tx, from)
257            };
258            let hash = tx
259                .compute_sponsor_hash(from)
260                .ok_or_else(|| eyre!("This network does not support sponsored transactions"))?;
261            sh_println!("{hash:?}")?;
262            return Ok(());
263        }
264
265        if let Some(ts) = expires_at {
266            sh_status!("Transaction expires at unix timestamp {ts}")?;
267        }
268
269        let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
270
271        // --sponsor-url is only valid with a local signer (Case 4). Bail early with a clear
272        // error rather than silently ignoring it in the other signing paths.
273        if let Some(ref url) = sponsor_url {
274            validate_sponsor_url(url)?;
275            if unlocked {
276                eyre::bail!("--sponsor-url cannot be combined with --unlocked");
277            }
278            if send_tx.browser.browser {
279                eyre::bail!("--sponsor-url cannot be combined with --browser");
280            }
281            if access_key.is_some() {
282                eyre::bail!("--sponsor-url cannot be combined with a Tempo access key");
283            }
284        }
285
286        // Launch browser signer if `--browser` flag is set
287        let browser = send_tx.browser.run::<N>().await?;
288
289        // Case 1:
290        // Default to sending via eth_sendTransaction if the --unlocked flag is passed.
291        // This should be the only way this RPC method is used as it requires a local node
292        // or remote RPC with unlocked accounts.
293        if unlocked && browser.is_none() {
294            // only check current chain id if it was specified in the config
295            if let Some(config_chain) = config.chain {
296                let current_chain_id = provider.get_chain_id().await?;
297                let config_chain_id = config_chain.id();
298                // switch chain if current chain id is not the same as the one specified in the
299                // config
300                if config_chain_id != current_chain_id {
301                    sh_warn!("Switching to chain {}", config_chain)?;
302                    provider
303                        .raw_request::<_, ()>(
304                            "wallet_switchEthereumChain".into(),
305                            [serde_json::json!({
306                                "chainId": format!("0x{:x}", config_chain_id),
307                            })],
308                        )
309                        .await?;
310                }
311            }
312
313            let chain = builder.chain();
314            let (mut tx_request, _) = builder.build(config.sender).await?;
315            maybe_print_resolved_lane(
316                resolved_lane.as_ref(),
317                tx_request.nonce().unwrap_or_default(),
318            )?;
319            if let Some(sponsor) = &tempo_sponsor {
320                sponsor.attach_and_print::<N>(&mut tx_request, config.sender).await?;
321            }
322
323            cast_send(
324                provider,
325                tx_request,
326                Some(chain),
327                send_tx.cast_async,
328                send_tx.sync,
329                send_tx.confirmations,
330                timeout,
331            )
332            .await?;
333        // Case 2:
334        // Browser wallet signs and sends the transaction in one step.
335        } else if let Some(browser) = browser {
336            let chain = builder.chain();
337            let (mut tx_request, _) =
338                builder.with_browser_wallet().build(browser.address()).await?;
339            maybe_print_resolved_lane(
340                resolved_lane.as_ref(),
341                tx_request.nonce().unwrap_or_default(),
342            )?;
343
344            // Browser wallets may sign with P256/WebAuthn instead of secp256k1, which
345            // costs more gas for signature verification on Tempo chains. Add a
346            // conservative buffer since we can't determine the signature type beforehand.
347            if chain.is_tempo()
348                && let Some(gas) = tx_request.gas_limit()
349            {
350                tx_request.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER);
351            }
352            if let Some(sponsor) = &tempo_sponsor {
353                sponsor.attach_and_print::<N>(&mut tx_request, browser.address()).await?;
354            }
355            print_resolved_fee_token_selection(Some(chain), tx_request.fee_token())?;
356
357            let tx_hash = browser.send_transaction_via_browser(tx_request).await?;
358
359            let cast = CastTxSender::new(&provider);
360            cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
361                .await?;
362        // Case 3:
363        // Tempo access key (keychain) signing. Uses `sign_with_access_key` which
364        // handles the provisioning check and embeds `key_authorization` when needed.
365        } else if let Some(ak) = access_key {
366            let signer = match pre_resolved_signer {
367                Some(s) => s,
368                None => send_tx.eth.wallet.signer().await?,
369            };
370            let chain = builder.chain();
371            let (mut tx_request, _) = builder.build_with_access_key(ak.wallet_address, &ak).await?;
372            maybe_print_resolved_lane(
373                resolved_lane.as_ref(),
374                tx_request.nonce().unwrap_or_default(),
375            )?;
376            if let Some(sponsor) = &tempo_sponsor {
377                sponsor.attach_and_print::<N>(&mut tx_request, ak.wallet_address).await?;
378            }
379            cast_send_with_access_key(
380                &provider,
381                tx_request,
382                &signer,
383                &ak,
384                Some(chain),
385                send_tx.cast_async,
386                send_tx.confirmations,
387                timeout,
388            )
389            .await?;
390        // Case 4:
391        // Remote sponsor URL: sign locally, ask the sponsor service for a fee-payer signature,
392        // then submit the fully-sponsored tx to the regular RPC.
393        } else if let Some(sponsor_url) = sponsor_url {
394            let signer = match pre_resolved_signer {
395                Some(s) => s,
396                None => send_tx.eth.wallet.signer().await?,
397            };
398            let from = signer.address();
399
400            tx::validate_from_address(send_tx.eth.wallet.from, from)?;
401
402            let chain = builder.chain();
403            let (mut tx_request, _) = builder.build(&signer).await?;
404            maybe_print_resolved_lane(
405                resolved_lane.as_ref(),
406                tx_request.nonce().unwrap_or_default(),
407            )?;
408
409            tx_request.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
410
411            let wallet = EthereumWallet::from(signer);
412            let default_rpc = config.get_rpc_url_or_localhost_http()?.into_owned();
413            let default = BuiltInConnectionString::from_str(&default_rpc)?;
414            let relay = BuiltInConnectionString::from_str(&sponsor_url)?;
415            let connector =
416                RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false);
417            let provider = AlloyProviderBuilder::<_, _, N>::default()
418                .wallet(wallet)
419                .connect_with(&connector)
420                .await?;
421
422            cast_send(
423                provider,
424                tx_request,
425                Some(chain),
426                send_tx.cast_async,
427                send_tx.sync,
428                send_tx.confirmations,
429                timeout,
430            )
431            .await?;
432        // Case 5:
433        // An option to use a local signer was provided.
434        // If we cannot successfully instantiate a local signer, then we will assume we don't have
435        // enough information to sign and we must bail.
436        } else {
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 chain = builder.chain();
446            let (mut tx_request, _) = builder.build(&signer).await?;
447            maybe_print_resolved_lane(
448                resolved_lane.as_ref(),
449                tx_request.nonce().unwrap_or_default(),
450            )?;
451
452            if let Some(sponsor) = &tempo_sponsor {
453                sponsor.attach_and_print::<N>(&mut tx_request, from).await?;
454            }
455
456            let wallet = EthereumWallet::from(signer);
457            let provider = AlloyProviderBuilder::<_, _, N>::default()
458                .wallet(wallet)
459                .connect_provider(&provider);
460
461            cast_send(
462                provider,
463                tx_request,
464                Some(chain),
465                send_tx.cast_async,
466                send_tx.sync,
467                send_tx.confirmations,
468                timeout,
469            )
470            .await?;
471        }
472
473        Ok(())
474    }
475}
476
477pub(crate) async fn cast_send<N: Network, P: Provider<N>>(
478    provider: P,
479    tx: N::TransactionRequest,
480    chain: Option<Chain>,
481    cast_async: bool,
482    sync: bool,
483    confs: u64,
484    timeout: u64,
485) -> Result<B256>
486where
487    N::TransactionRequest: FoundryTransactionBuilder<N>,
488    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
489{
490    let cast = CastTxSender::new(provider);
491    print_resolved_fee_token_selection(chain, tx.fee_token())?;
492
493    if sync {
494        // JSON envelope not supported: N::ReceiptResponse is generic over Display but not
495        // Serialize; adding Serialize would ripple across all network-generic callers.
496        let (tx_hash, receipt) = cast.send_sync(tx).await?;
497        sh_println!("{receipt}")?;
498        Ok(tx_hash)
499    } else {
500        let pending_tx = cast.send(tx).await?;
501        let tx_hash = *pending_tx.inner().tx_hash();
502        cast.print_tx_result(tx_hash, cast_async, confs, timeout).await?;
503        Ok(tx_hash)
504    }
505}
506
507/// Signs a transaction with a Tempo access key and sends it via `send_raw_transaction`.
508///
509/// Sets `from` and `key_id` on the transaction before signing, making it idempotent for txs built
510/// with [`CastTxBuilder`] (fields already set) and also with sol!-bindings (fields not yet set).
511///
512/// NOTE: The default implementation returns an error. Only `TempoNetwork` supports this.
513#[allow(clippy::too_many_arguments)]
514pub(crate) async fn cast_send_with_access_key<N: Network, P: Provider<N>>(
515    provider: &P,
516    mut tx: N::TransactionRequest,
517    signer: &WalletSigner,
518    access_key: &TempoAccessKeyConfig,
519    chain: Option<Chain>,
520    cast_async: bool,
521    confirmations: u64,
522    timeout: u64,
523) -> Result<B256>
524where
525    N::TransactionRequest: FoundryTransactionBuilder<N>,
526    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
527{
528    tx.set_from(access_key.wallet_address);
529    tx.set_key_id(access_key.key_address);
530    print_resolved_fee_token_selection(chain, tx.fee_token())?;
531    let raw_tx = tx
532        .sign_with_access_key(
533            provider,
534            signer,
535            access_key.wallet_address,
536            access_key.key_address,
537            access_key.key_authorization.as_ref(),
538        )
539        .await?;
540    let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash();
541    CastTxSender::new(provider)
542        .print_tx_result(tx_hash, cast_async, confirmations, timeout)
543        .await?;
544    Ok(tx_hash)
545}
546
547/// Validates that a sponsor URL uses https:// (localhost/127.0.0.1 may use http://).
548pub(crate) fn validate_sponsor_url(raw: &str) -> Result<()> {
549    let url = Url::parse(raw)
550        .map_err(|e| eyre::eyre!("--sponsor-url is not a valid URL ({raw}): {e}"))?;
551
552    match url.scheme() {
553        "https" => Ok(()),
554        "http" => {
555            let host = url.host_str().unwrap_or("");
556            if host == "localhost" || host == "127.0.0.1" {
557                return Ok(());
558            }
559            eyre::bail!(
560                "--sponsor-url must use https:// for non-local endpoints (got {raw}). \
561                 The sponsor relay is a trusted third party; use an encrypted channel."
562            );
563        }
564        _ => eyre::bail!(
565            "--sponsor-url must start with https:// (got {raw}). \
566             The sponsor relay is a trusted third party; use an encrypted channel."
567        ),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_validate_sponsor_url() {
577        // accepted
578        assert!(validate_sponsor_url("https://sponsor.tempo.xyz/tp_abc").is_ok());
579        assert!(validate_sponsor_url("http://localhost:8545").is_ok());
580        assert!(validate_sponsor_url("http://127.0.0.1:8545").is_ok());
581
582        // rejected
583        assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err());
584        assert!(validate_sponsor_url("not-a-url").is_err());
585        // bypass attempts that fooled the old starts_with check
586        assert!(validate_sponsor_url("http://localhost.evil.com").is_err());
587        assert!(validate_sponsor_url("http://127.0.0.1.evil.com").is_err());
588    }
589}