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#[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 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 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 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 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 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 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 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 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 let browser = send_tx.browser.run::<N>().await?;
288
289 if unlocked && browser.is_none() {
294 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 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 } 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 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 } 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 } 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 } 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 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#[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
547pub(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 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 assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err());
584 assert!(validate_sponsor_url("not-a-url").is_err());
585 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}