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#[derive(Debug, Parser)]
39pub struct SendTxArgs {
40 #[arg(value_parser = NameOrAddress::from_str)]
44 to: Option<NameOrAddress>,
45
46 sig: Option<String>,
48
49 #[arg(allow_negative_numbers = true)]
51 args: Vec<String>,
52
53 #[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 #[arg(long, requires = "from")]
68 unlocked: bool,
69
70 #[arg(long)]
72 force: bool,
73
74 #[command(flatten)]
75 tx: TransactionOpts,
76
77 #[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 #[command(name = "--create")]
92 Create {
93 code: String,
95
96 sig: Option<String>,
98
99 #[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 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 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 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 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 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 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 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 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 let browser = send_tx.browser.run::<N>().await?;
299
300 if unlocked && browser.is_none() {
305 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 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 } 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 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 } 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 } 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 } 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 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#[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
626pub(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 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 assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err());
665 assert!(validate_sponsor_url("not-a-url").is_err());
666 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}