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, maybe_print_fee_token, resolve_and_set_fee_token},
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 sponsor_fee_payer = tx.tempo.sponsor;
146        let expires_at = tx.tempo.resolve_expires();
147        let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() {
148            None
149        } else {
150            tx.tempo.sponsor_config().await?
151        };
152
153        let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None };
154
155        if let Some(data) = data {
156            sig = Some(data);
157        }
158
159        let code = if let Some(SendTxSubcommands::Create {
160            code,
161            sig: constructor_sig,
162            args: constructor_args,
163        }) = command
164        {
165            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
166            // which require mandatory target
167            if to.is_none() && !tx.auth.is_empty() {
168                return Err(eyre!(
169                    "EIP-7702 transactions can't be CREATE transactions and require a destination address"
170                ));
171            }
172            // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844
173            // which require mandatory target
174            if to.is_none() && blob_data.is_some() {
175                return Err(eyre!(
176                    "EIP-4844 transactions can't be CREATE transactions and require a destination address"
177                ));
178            }
179
180            sig = constructor_sig;
181            args = constructor_args;
182            Some(code)
183        } else {
184            None
185        };
186
187        // Validate ISO 4217 currency code for TIP20Factory createToken calls.
188        if let Some(ref to_addr) = to {
189            let is_factory = match to_addr {
190                NameOrAddress::Address(addr) => *addr == TIP20_FACTORY_ADDRESS,
191                NameOrAddress::Name(name) => {
192                    Address::from_str(name).ok() == Some(TIP20_FACTORY_ADDRESS)
193                }
194            };
195
196            if !force
197                && is_factory
198                && let Some(ref sig_str) = sig
199                && sig_str.starts_with("createToken")
200                && let Some(currency) = args.get(2)
201                && !is_iso4217_currency(currency)
202            {
203                sh_warn!("{}", iso4217_warning_message(currency))?;
204                let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?;
205                if !matches!(response.trim(), "y" | "Y") {
206                    sh_status!("Aborted.")?;
207                    return Ok(());
208                }
209            }
210        }
211
212        let config = send_tx.eth.load_config()?;
213        let provider = ProviderBuilder::<N>::from_config(&config)?.build()?;
214
215        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
216
217        if let Some(interval) = send_tx.poll_interval {
218            provider.client().set_poll_interval(Duration::from_secs(interval))
219        }
220
221        if has_session
222            && let Some(session) = tx.tempo.session_signer_for_wallet(
223                &send_tx.eth.wallet,
224                get_chain(config.chain, &provider).await?.id(),
225            )?
226        {
227            pre_resolved_signer = Some(session.signer);
228            access_key = Some(session.access_key);
229        }
230
231        // Inject access key ID into TempoOpts so it's set before gas estimation.
232        if let Some(ref ak) = access_key {
233            tx.tempo.key_id = Some(ak.key_address);
234        }
235
236        let builder = CastTxBuilder::new(&provider, tx, &config)
237            .await?
238            .with_to(to)
239            .await?
240            .with_code_sig_and_args(code, sig, args)
241            .await?
242            .with_blob_data(blob_data)?;
243
244        // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit.
245        if print_sponsor_hash {
246            let chain = builder.chain();
247            let (mut tx, from) = if let Some(ref ak) = access_key {
248                let (tx, _) = builder.build_with_access_key(ak.wallet_address, ak).await?;
249                (tx, ak.wallet_address)
250            } else {
251                // Use the pre-resolved signer to derive the actual sender address, since the
252                // sponsor hash commits to the sender.
253                let signer = pre_resolved_signer.as_ref().ok_or_else(|| {
254                    eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)")
255                })?;
256                let from = signer.address();
257                let (tx, _) = builder.build(from).await?;
258                (tx, from)
259            };
260            if let Some(fee_payer) = sponsor_fee_payer {
261                resolve_and_set_fee_token(
262                    (!config.eth_rpc_curl).then_some(&provider),
263                    Some(chain),
264                    &mut tx,
265                    Some(fee_payer),
266                )
267                .await?;
268            }
269            let hash = tx
270                .compute_sponsor_hash(from)
271                .ok_or_else(|| eyre!("This network does not support sponsored transactions"))?;
272            sh_println!("{hash:?}")?;
273            return Ok(());
274        }
275
276        if let Some(ts) = expires_at {
277            sh_status!("Transaction expires at unix timestamp {ts}")?;
278        }
279
280        let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
281
282        // --sponsor-url is only valid with a local signer (Case 4). Bail early with a clear
283        // error rather than silently ignoring it in the other signing paths.
284        if let Some(ref url) = sponsor_url {
285            validate_sponsor_url(url)?;
286            if unlocked {
287                eyre::bail!("--sponsor-url cannot be combined with --unlocked");
288            }
289            if send_tx.browser.browser {
290                eyre::bail!("--sponsor-url cannot be combined with --browser");
291            }
292            if access_key.is_some() {
293                eyre::bail!("--sponsor-url cannot be combined with a Tempo access key");
294            }
295        }
296
297        // Launch browser signer if `--browser` flag is set
298        let browser = send_tx.browser.run::<N>().await?;
299
300        // Case 1:
301        // Default to sending via eth_sendTransaction if the --unlocked flag is passed.
302        // This should be the only way this RPC method is used as it requires a local node
303        // or remote RPC with unlocked accounts.
304        if unlocked && browser.is_none() {
305            // only check current chain id if it was specified in the config
306            if let Some(config_chain) = config.chain {
307                let current_chain_id = provider.get_chain_id().await?;
308                let config_chain_id = config_chain.id();
309                // switch chain if current chain id is not the same as the one specified in the
310                // config
311                if config_chain_id != current_chain_id {
312                    sh_warn!("Switching to chain {}", config_chain)?;
313                    provider
314                        .raw_request::<_, ()>(
315                            "wallet_switchEthereumChain".into(),
316                            [serde_json::json!({
317                                "chainId": format!("0x{:x}", config_chain_id),
318                            })],
319                        )
320                        .await?;
321                }
322            }
323
324            let chain = builder.chain();
325            let (mut tx_request, _) = builder.build(config.sender).await?;
326            maybe_print_resolved_lane(
327                resolved_lane.as_ref(),
328                tx_request.nonce().unwrap_or_default(),
329            )?;
330            if let Some(sponsor) = &tempo_sponsor {
331                sponsor
332                    .resolve_and_set_fee_token(
333                        (!config.eth_rpc_curl).then_some(&provider),
334                        Some(chain),
335                        &mut tx_request,
336                    )
337                    .await?;
338                sponsor.attach_and_print::<N>(&mut tx_request, config.sender).await?;
339            }
340
341            cast_send(
342                provider,
343                tx_request,
344                tempo_sponsor.is_none().then_some(chain),
345                None,
346                send_tx.cast_async,
347                send_tx.sync,
348                send_tx.confirmations,
349                timeout,
350                tempo_sponsor.is_none() && !config.eth_rpc_curl,
351            )
352            .await?;
353        // Case 2:
354        // Browser wallet signs and sends the transaction in one step.
355        } else if let Some(browser) = browser {
356            let chain = builder.chain();
357            let (mut tx_request, _) =
358                builder.with_browser_wallet().build(browser.address()).await?;
359            maybe_print_resolved_lane(
360                resolved_lane.as_ref(),
361                tx_request.nonce().unwrap_or_default(),
362            )?;
363
364            // Browser wallets may sign with P256/WebAuthn instead of secp256k1, which
365            // costs more gas for signature verification on Tempo chains. Add a
366            // conservative buffer since we can't determine the signature type beforehand.
367            if chain.is_tempo()
368                && let Some(gas) = tx_request.gas_limit()
369            {
370                tx_request.set_gas_limit(gas + TEMPO_BROWSER_GAS_BUFFER);
371            }
372            if let Some(sponsor) = &tempo_sponsor {
373                sponsor
374                    .resolve_and_set_fee_token(
375                        (!config.eth_rpc_curl).then_some(&provider),
376                        Some(chain),
377                        &mut tx_request,
378                    )
379                    .await?;
380                sponsor.attach_and_print::<N>(&mut tx_request, browser.address()).await?;
381            } else {
382                let fee_token = resolve_and_set_fee_token(
383                    (!config.eth_rpc_curl).then_some(&provider),
384                    Some(chain),
385                    &mut tx_request,
386                    Some(browser.address()),
387                )
388                .await?;
389                maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token)
390                    .await?;
391            }
392
393            if chain.id() != browser.chain_id() {
394                sh_warn!("Switching browser wallet to chain {}", chain)?;
395                browser.switch_chain(chain.id()).await?;
396            }
397
398            let tx_hash = browser.send_transaction_via_browser(tx_request).await?;
399
400            let cast = CastTxSender::new(&provider);
401            cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout)
402                .await?;
403        // Case 3:
404        // Tempo access key (keychain) signing. Uses `sign_with_access_key` which
405        // handles the provisioning check and embeds `key_authorization` when needed.
406        } else if let Some(ak) = access_key {
407            let signer = match pre_resolved_signer {
408                Some(s) => s,
409                None => send_tx.eth.wallet.signer().await?,
410            };
411            let chain = builder.chain();
412            let (mut tx_request, _) = builder.build_with_access_key(ak.wallet_address, &ak).await?;
413            maybe_print_resolved_lane(
414                resolved_lane.as_ref(),
415                tx_request.nonce().unwrap_or_default(),
416            )?;
417            if let Some(sponsor) = &tempo_sponsor {
418                sponsor
419                    .resolve_and_set_fee_token(
420                        (!config.eth_rpc_curl).then_some(&provider),
421                        Some(chain),
422                        &mut tx_request,
423                    )
424                    .await?;
425                sponsor.attach_and_print::<N>(&mut tx_request, ak.wallet_address).await?;
426            }
427            cast_send_with_access_key(
428                &provider,
429                tx_request,
430                &signer,
431                &ak,
432                tempo_sponsor.is_none().then_some(chain),
433                None,
434                send_tx.cast_async,
435                send_tx.confirmations,
436                timeout,
437                tempo_sponsor.is_none() && !config.eth_rpc_curl,
438            )
439            .await?;
440        // Case 4:
441        // Remote sponsor URL: sign locally, ask the sponsor service for a fee-payer signature,
442        // then submit the fully-sponsored tx to the regular RPC.
443        } else if let Some(sponsor_url) = sponsor_url {
444            let signer = match pre_resolved_signer {
445                Some(s) => s,
446                None => send_tx.eth.wallet.signer().await?,
447            };
448            let from = signer.address();
449
450            tx::validate_from_address(send_tx.eth.wallet.from, from)?;
451
452            let (mut tx_request, _) = builder.build(&signer).await?;
453            maybe_print_resolved_lane(
454                resolved_lane.as_ref(),
455                tx_request.nonce().unwrap_or_default(),
456            )?;
457
458            tx_request.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER);
459
460            let wallet = EthereumWallet::from(signer);
461            let default_rpc = config.get_rpc_url_or_localhost_http()?.into_owned();
462            let default = BuiltInConnectionString::from_str(&default_rpc)?;
463            let relay = BuiltInConnectionString::from_str(&sponsor_url)?;
464            let connector =
465                RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false);
466            let provider = AlloyProviderBuilder::<_, _, N>::default()
467                .wallet(wallet)
468                .connect_with(&connector)
469                .await?;
470
471            cast_send(
472                provider,
473                tx_request,
474                None,
475                None,
476                send_tx.cast_async,
477                send_tx.sync,
478                send_tx.confirmations,
479                timeout,
480                false,
481            )
482            .await?;
483        // Case 5:
484        // An option to use a local signer was provided.
485        // If we cannot successfully instantiate a local signer, then we will assume we don't have
486        // enough information to sign and we must bail.
487        } else {
488            let signer = match pre_resolved_signer {
489                Some(s) => s,
490                None => send_tx.eth.wallet.signer().await?,
491            };
492            let from = signer.address();
493
494            tx::validate_from_address(send_tx.eth.wallet.from, from)?;
495
496            let chain = builder.chain();
497            let (mut tx_request, _) = builder.build(&signer).await?;
498            maybe_print_resolved_lane(
499                resolved_lane.as_ref(),
500                tx_request.nonce().unwrap_or_default(),
501            )?;
502
503            if let Some(sponsor) = &tempo_sponsor {
504                sponsor
505                    .resolve_and_set_fee_token(
506                        (!config.eth_rpc_curl).then_some(&provider),
507                        Some(chain),
508                        &mut tx_request,
509                    )
510                    .await?;
511                sponsor.attach_and_print::<N>(&mut tx_request, from).await?;
512            }
513
514            let wallet = EthereumWallet::from(signer);
515            let provider = AlloyProviderBuilder::<_, _, N>::default()
516                .wallet(wallet)
517                .connect_provider(&provider);
518
519            cast_send(
520                provider,
521                tx_request,
522                tempo_sponsor.is_none().then_some(chain),
523                None,
524                send_tx.cast_async,
525                send_tx.sync,
526                send_tx.confirmations,
527                timeout,
528                tempo_sponsor.is_none() && !config.eth_rpc_curl,
529            )
530            .await?;
531        }
532
533        Ok(())
534    }
535}
536
537#[allow(clippy::too_many_arguments)]
538pub(crate) async fn cast_send<N: Network, P: Provider<N>>(
539    provider: P,
540    mut tx: N::TransactionRequest,
541    chain: Option<Chain>,
542    fee_payer: Option<Address>,
543    cast_async: bool,
544    sync: bool,
545    confs: u64,
546    timeout: u64,
547    resolve_unknown_fee_token_symbol: bool,
548) -> Result<B256>
549where
550    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
551    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
552{
553    let fee_token = resolve_and_set_fee_token(
554        resolve_unknown_fee_token_symbol.then_some(&provider),
555        chain,
556        &mut tx,
557        fee_payer,
558    )
559    .await?;
560    maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(&provider), fee_token).await?;
561    let cast = CastTxSender::new(provider);
562
563    if sync {
564        // JSON envelope not supported: N::ReceiptResponse is generic over Display but not
565        // Serialize; adding Serialize would ripple across all network-generic callers.
566        let (tx_hash, receipt) = cast.send_sync(tx).await?;
567        sh_println!("{receipt}")?;
568        Ok(tx_hash)
569    } else {
570        let pending_tx = cast.send(tx).await?;
571        let tx_hash = *pending_tx.inner().tx_hash();
572        cast.print_tx_result(tx_hash, cast_async, confs, timeout).await?;
573        Ok(tx_hash)
574    }
575}
576
577/// Signs a transaction with a Tempo access key and sends it via `send_raw_transaction`.
578///
579/// Sets `from` and `key_id` on the transaction before signing, making it idempotent for txs built
580/// with [`CastTxBuilder`] (fields already set) and also with sol!-bindings (fields not yet set).
581///
582/// NOTE: The default implementation returns an error. Only `TempoNetwork` supports this.
583#[allow(clippy::too_many_arguments)]
584pub(crate) async fn cast_send_with_access_key<N: Network, P: Provider<N>>(
585    provider: &P,
586    mut tx: N::TransactionRequest,
587    signer: &WalletSigner,
588    access_key: &TempoAccessKeyConfig,
589    chain: Option<Chain>,
590    fee_payer: Option<Address>,
591    cast_async: bool,
592    confirmations: u64,
593    timeout: u64,
594    resolve_unknown_fee_token_symbol: bool,
595) -> Result<B256>
596where
597    N::TransactionRequest: Default + FoundryTransactionBuilder<N>,
598    N::ReceiptResponse: UIfmt + UIfmtReceiptExt,
599{
600    tx.set_from(access_key.wallet_address);
601    tx.set_key_id(access_key.key_address);
602    let fee_token = resolve_and_set_fee_token(
603        resolve_unknown_fee_token_symbol.then_some(provider),
604        chain,
605        &mut tx,
606        fee_payer,
607    )
608    .await?;
609    maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(provider), fee_token).await?;
610    let raw_tx = tx
611        .sign_with_access_key(
612            provider,
613            signer,
614            access_key.wallet_address,
615            access_key.key_address,
616            access_key.key_authorization.as_ref(),
617        )
618        .await?;
619    let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash();
620    CastTxSender::new(provider)
621        .print_tx_result(tx_hash, cast_async, confirmations, timeout)
622        .await?;
623    Ok(tx_hash)
624}
625
626/// Validates that a sponsor URL uses https:// (localhost/127.0.0.1 may use http://).
627pub(crate) fn validate_sponsor_url(raw: &str) -> Result<()> {
628    let url = Url::parse(raw)
629        .map_err(|e| eyre::eyre!("--sponsor-url is not a valid URL ({raw}): {e}"))?;
630
631    match url.scheme() {
632        "https" => Ok(()),
633        "http" => {
634            let host = url.host_str().unwrap_or("");
635            if host == "localhost" || host == "127.0.0.1" {
636                return Ok(());
637            }
638            eyre::bail!(
639                "--sponsor-url must use https:// for non-local endpoints (got {raw}). \
640                 The sponsor relay is a trusted third party; use an encrypted channel."
641            );
642        }
643        _ => {
644            eyre::bail!(
645                "--sponsor-url must start with https:// (got {raw}). \
646             The sponsor relay is a trusted third party; use an encrypted channel."
647            );
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn test_validate_sponsor_url() {
658        // accepted
659        assert!(validate_sponsor_url("https://sponsor.tempo.xyz/tp_abc").is_ok());
660        assert!(validate_sponsor_url("http://localhost:8545").is_ok());
661        assert!(validate_sponsor_url("http://127.0.0.1:8545").is_ok());
662
663        // rejected
664        assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err());
665        assert!(validate_sponsor_url("not-a-url").is_err());
666        // bypass attempts that fooled the old starts_with check
667        assert!(validate_sponsor_url("http://localhost.evil.com").is_err());
668        assert!(validate_sponsor_url("http://127.0.0.1.evil.com").is_err());
669    }
670}