1use std::{cmp::Ordering, num::NonZeroU64, sync::Arc, time::Duration};
2
3use crate::{
4 ScriptArgs, ScriptConfig,
5 build::LinkedBuildData,
6 progress::ScriptProgress,
7 receipts::is_mined_receipt_for,
8 recovery::{AttemptKind, DelegatedStatus},
9 sequence::{ScriptSequenceKind, completed_transaction_prefix},
10 session::{
11 SignerScope, insert_session_access_key_for_remaining_transactions,
12 script_session_expected_sender_if_configured,
13 },
14 verify::BroadcastedState,
15};
16use alloy_chains::{Chain, NamedChain};
17use alloy_consensus::{SignableTransaction, Signed, transaction::SignerRecoverable};
18use alloy_eips::eip2718::{Decodable2718, Encodable2718};
19use alloy_network::{
20 EthereumWallet, Network, NetworkTransactionBuilder, ReceiptResponse, TransactionBuilder,
21 TransactionResponse,
22};
23use alloy_primitives::{
24 Address, Bytes, TxHash, TxKind, U256, keccak256,
25 map::{AddressHashMap, AddressHashSet, HashMap},
26 utils::format_units,
27};
28use alloy_provider::{
29 Provider, RootProvider,
30 transport::{RpcError, TransportError},
31 utils::Eip1559Estimation,
32};
33use alloy_rpc_types::TransactionRequest;
34use alloy_signer::Signature;
35use eyre::{Context, ContextCompat, Result, bail};
36use forge_script_sequence::ScriptSequence;
37use foundry_cheatcodes::Wallets;
38use foundry_cli::utils::{has_batch_support, has_different_gas_calc};
39use foundry_common::{
40 FoundryTransactionBuilder, TransactionMaybeSigned,
41 provider::{
42 ProviderBuilder,
43 fee::{estimate_eip1559_fees, resolve_broadcast_eip1559_fees},
44 },
45 shell,
46 tempo::{TempoSponsor, maybe_print_fee_token, resolve_and_set_fee_token},
47};
48use foundry_config::Config;
49use foundry_evm::{
50 core::{
51 constants::DEFAULT_CREATE2_DEPLOYER_CODEHASH,
52 evm::{FoundryEvmNetwork, TempoEvmNetwork},
53 },
54 traces::CallKind,
55};
56use foundry_wallets::{
57 TempoAccountsWallet,
58 wallet_browser::{error::BrowserWalletError, signer::BrowserSigner},
59};
60use futures::{FutureExt, StreamExt, future::join_all, stream::FuturesUnordered};
61use itertools::Itertools;
62use tempo_alloy::{
63 TempoNetwork,
64 rpc::{TempoTransactionReceipt, TempoTransactionRequest},
65};
66use tempo_primitives::transaction::{Call, TempoTxEnvelope};
67
68#[derive(Clone)]
70pub enum SendTransactionKind<'a, N: Network> {
71 Unlocked(N::TransactionRequest),
72 Raw(N::TransactionRequest, &'a EthereumWallet),
73 Browser(N::TransactionRequest, &'a BrowserSigner<N>),
74 Signed(N::TxEnvelope),
75 AccessKey(N::TransactionRequest, Box<TempoAccountsWallet>),
76 PreparedRaw(Bytes, TxHash),
77}
78
79impl<'a, N: Network> SendTransactionKind<'a, N>
80where
81 N::TxEnvelope: From<Signed<N::UnsignedTx>>,
82 N::UnsignedTx: SignableTransaction<Signature>,
83 N::TransactionRequest: FoundryTransactionBuilder<N>,
84{
85 #[allow(clippy::too_many_arguments)]
92 pub async fn prepare(
93 &mut self,
94 provider: &RootProvider<N>,
95 sequential_broadcast: bool,
96 is_fixed_gas_limit: bool,
97 estimate_via_rpc: bool,
98 estimate_multiplier: u64,
99 tempo_sponsor: Option<&TempoSponsor>,
100 ) -> Result<()> {
101 let browser = matches!(self, Self::Browser(..));
102 let (tx, tempo_wallet) = match self {
103 Self::Raw(tx, _) | Self::Unlocked(tx) | Self::Browser(tx, _) => (tx, None),
104 Self::AccessKey(tx, wallet) => (tx, Some(wallet)),
105 Self::Signed(_) | Self::PreparedRaw(..) => return Ok(()),
106 };
107
108 reject_access_key_create::<N>(tx, tempo_wallet.is_some())?;
109
110 if sequential_broadcast {
111 let from = tx.from().expect("no sender");
112
113 let tx_nonce = tx.nonce().expect("no nonce");
114 for attempt in 0..5 {
115 let nonce = provider.get_transaction_count(from).await?;
116 match nonce.cmp(&tx_nonce) {
117 Ordering::Greater => {
118 bail!(
119 "EOA nonce changed unexpectedly while sending transactions. Expected {tx_nonce} got {nonce} from provider."
120 );
121 }
122 Ordering::Less => {
123 if attempt == 4 {
124 bail!(
125 "After 5 attempts, provider nonce ({nonce}) is still behind expected nonce ({tx_nonce})."
126 );
127 }
128 warn!(
129 "Expected nonce ({tx_nonce}) is ahead of provider nonce ({nonce}). Retrying in 1 second..."
130 );
131 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
132 }
133 Ordering::Equal => {
134 break;
136 }
137 }
138 }
139 }
140
141 if let Some(wallet) = tempo_wallet {
142 **wallet = tx.prepare_with_tempo_wallet(provider, wallet).await?;
143 }
144
145 let fee_token = if let Some(sponsor) = tempo_sponsor {
146 sponsor.resolve_and_set_fee_token(Some(provider), tx).await?;
147 None
148 } else {
149 resolve_and_set_fee_token(Some(provider), tx, tx.from()).await?
150 };
151
152 convert_tempo_aa_create::<N>(tx);
156
157 if !is_fixed_gas_limit && estimate_via_rpc {
160 estimate_gas(tx, provider, estimate_multiplier, browser).await?;
161 }
162
163 if let Some(sponsor) = tempo_sponsor {
164 let from = tx.from().expect("no sender");
165 sponsor.attach_and_print::<N>(tx, from).await?;
166 } else {
167 maybe_print_fee_token(Some(provider), fee_token).await?;
168 }
169
170 Ok(())
171 }
172
173 #[allow(clippy::too_many_arguments)]
174 async fn prepare_for_durable_send(
175 mut self,
176 provider: &RootProvider<N>,
177 sequential_broadcast: bool,
178 is_fixed_gas_limit: bool,
179 estimate_via_rpc: bool,
180 estimate_multiplier: u64,
181 tempo_sponsor: Option<&TempoSponsor>,
182 ) -> Result<PreparedTransaction<'a, N>> {
183 self.prepare(
184 provider,
185 sequential_broadcast,
186 is_fixed_gas_limit,
187 estimate_via_rpc,
188 estimate_multiplier,
189 tempo_sponsor,
190 )
191 .await?;
192 if let Self::Unlocked(tx) = &mut self {
193 tx.prep_for_submission();
194 }
195 Ok(match self {
196 Self::Raw(tx, signer) => {
197 let signed = tx.build(signer).await?;
198 PreparedTransaction::Raw(Bytes::from(signed.encoded_2718()), signed.trie_hash())
199 }
200 Self::Signed(tx) => {
201 let hash = tx.trie_hash();
202 PreparedTransaction::Raw(Bytes::from(tx.encoded_2718()), hash)
203 }
204 Self::AccessKey(tx, wallet) => {
205 let payload = Bytes::from(tx.sign_with_tempo_wallet(&wallet).await?);
206 let envelope = N::TxEnvelope::decode_2718_exact(&payload)?;
207 PreparedTransaction::Raw(payload, envelope.trie_hash())
208 }
209 Self::PreparedRaw(payload, hash) => PreparedTransaction::Raw(payload, hash),
210 Self::Unlocked(request) => PreparedTransaction::Unlocked(request),
211 Self::Browser(request, signer) => PreparedTransaction::Browser(request, signer),
212 })
213 }
214}
215
216#[derive(Clone)]
218enum PreparedTransaction<'a, N: Network> {
219 Raw(Bytes, TxHash),
220 Unlocked(N::TransactionRequest),
221 Browser(N::TransactionRequest, &'a BrowserSigner<N>),
222}
223
224impl<N: Network> PreparedTransaction<'_, N>
225where
226 N::TransactionRequest: FoundryTransactionBuilder<N>,
227{
228 const fn delegated_request(&self) -> Option<&N::TransactionRequest> {
229 match self {
230 Self::Unlocked(request) | Self::Browser(request, _) => Some(request),
231 _ => None,
232 }
233 }
234
235 fn validate_delegated_submission(&self) -> Result<()> {
236 let Self::Browser(request, signer) = self else { return Ok(()) };
237 if request.from().is_some_and(|from| from != signer.address()) {
238 bail!("Transaction `from` address does not match connected wallet address");
239 }
240 if request.chain_id().is_some_and(|chain_id| chain_id != signer.chain_id()) {
241 bail!("Transaction `chainId` does not match connected wallet chain ID");
242 }
243 Ok(())
244 }
245
246 fn is_definite_non_submission(&self, error: &eyre::Report) -> bool {
247 match self {
248 Self::Browser(..) => is_definite_browser_non_submission(error),
249 Self::Unlocked(_) => is_definite_unlocked_non_submission(error),
250 _ => false,
251 }
252 }
253
254 async fn send(self, provider: Arc<RootProvider<N>>) -> Result<TxHash> {
255 match self {
256 Self::Raw(payload, _) => {
257 let pending = provider.send_raw_transaction(&payload).await?;
258 Ok(*pending.tx_hash())
259 }
260 Self::Unlocked(tx) => {
261 debug!("sending transaction from unlocked account {:?}", tx);
262 let pending = provider.send_transaction(tx).await?;
263 Ok(*pending.tx_hash())
264 }
265 Self::Browser(tx, signer) => {
266 debug!("sending transaction: {:?}", tx);
267 Ok(signer.send_transaction_via_browser(tx).await?)
268 }
269 }
270 }
271}
272
273fn is_definite_browser_non_submission(error: &eyre::Report) -> bool {
274 let Some(alloy_signer::Error::Other(error)) = error.downcast_ref::<alloy_signer::Error>()
275 else {
276 return false;
277 };
278 matches!(
279 error.downcast_ref::<BrowserWalletError>(),
280 Some(BrowserWalletError::Rejected { .. } | BrowserWalletError::NotConnected)
281 )
282}
283
284fn is_definite_unlocked_non_submission(error: &eyre::Report) -> bool {
285 matches!(
286 error.downcast_ref::<TransportError>(),
287 Some(RpcError::ErrorResp(error))
288 if is_unlocked_non_submission(error.code, &error.message)
289 )
290}
291
292fn is_unlocked_non_submission(code: i64, message: &str) -> bool {
293 code == -32602 && message == "No Signer available"
294}
295
296fn tempo_batch_calls(
297 sequence: &ScriptSequence<TempoNetwork>,
298 first_operation: usize,
299) -> Result<Vec<Call>> {
300 sequence
301 .transactions()
302 .skip(first_operation)
303 .enumerate()
304 .map(|(call_index, tx)| {
305 if tx.authorization_list().is_some_and(|list| !list.is_empty()) {
306 bail!(
307 "--batch does not support EIP-7702 authorization lists \
308 (found at transaction {}); use regular broadcast instead.",
309 call_index + first_operation + 1
310 );
311 }
312 if let TransactionMaybeSigned::Unsigned(inner) = tx
313 && inner.blob_sidecar().is_some()
314 {
315 bail!(
316 "--batch does not support blob (EIP-4844) transactions \
317 (found at transaction {}); use regular broadcast instead.",
318 call_index + first_operation + 1
319 );
320 }
321 let to = tx.to().map(TxKind::Call).ok_or_else(|| {
322 eyre::eyre!(
323 "Unexpected raw CREATE in --batch mode at position {} — \
324 this is a bug; CREATEs should have been rewritten by the inspector.",
325 call_index + first_operation + 1
326 )
327 })?;
328 Ok(Call {
329 to,
330 value: tx.value().unwrap_or(U256::ZERO),
331 input: tx.input().cloned().unwrap_or_default(),
332 })
333 })
334 .collect()
335}
336
337fn validate_tempo_batch_request(
338 request: &TempoTransactionRequest,
339 sender: Address,
340 chain: u64,
341 calls: &[Call],
342) -> Result<()> {
343 if request.inner.from != Some(sender) {
344 bail!("batch request does not match its planned sender");
345 }
346 let expected = request
347 .clone()
348 .build_aa()
349 .map_err(|error| eyre::eyre!("invalid persisted batch request: {error}"))?;
350 if expected.chain_id != chain || expected.calls != calls {
351 bail!("batch request does not match its planned operations");
352 }
353 Ok(())
354}
355
356fn validate_tempo_batch_payload(
357 request: &TempoTransactionRequest,
358 payload: &[u8],
359 sender: Address,
360 chain: u64,
361 calls: &[Call],
362) -> Result<TxHash> {
363 validate_tempo_batch_request(request, sender, chain, calls)?;
364 let expected = request
365 .clone()
366 .build_aa()
367 .map_err(|error| eyre::eyre!("invalid persisted batch request: {error}"))?;
368 let envelope = TempoTxEnvelope::decode_2718_exact(payload)
369 .wrap_err("recovery plan contains an invalid signed batch payload")?;
370 validate_tempo_batch_envelope(&envelope, sender, chain, calls)?;
371 let TempoTxEnvelope::AA(signed) = &envelope else {
372 unreachable!();
373 };
374 if signed.tx() != &expected {
375 bail!("signed batch payload does not match its persisted request");
376 }
377 Ok(envelope.trie_hash())
378}
379
380fn validate_tempo_batch_envelope(
381 envelope: &TempoTxEnvelope,
382 sender: Address,
383 chain: u64,
384 calls: &[Call],
385) -> Result<()> {
386 let TempoTxEnvelope::AA(signed) = envelope else {
387 bail!("recovered batch transaction is not a Tempo transaction");
388 };
389 if signed.recover_signer().wrap_err("recovered batch transaction has an invalid signature")?
390 != sender
391 {
392 bail!("recovered batch transaction does not match its planned sender");
393 }
394 if signed.tx().chain_id != chain || signed.tx().calls != calls {
395 bail!("recovered batch transaction does not match its planned operations");
396 }
397 Ok(())
398}
399
400pub(crate) fn remaining_unsigned_transactions_for_recovery<N: Network>(
401 sequence: &ScriptSequenceKind<N>,
402) -> Vec<SignerScope>
403where
404 N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize,
405{
406 sequence
407 .sequences()
408 .iter()
409 .enumerate()
410 .filter(|(sequence_index, _)| sequence.batch_attempt(*sequence_index).is_none())
411 .flat_map(|(sequence_index, deployment)| {
412 remaining_operation_indices(sequence, sequence_index).into_iter().filter_map(
413 move |index| {
414 let tx = deployment.transactions[index].tx();
415 (tx.is_unsigned() && sequence.signed_payload(sequence_index, index).is_none())
416 .then(|| SignerScope {
417 chain: deployment.chain,
418 sender: tx.from().expect("missing from"),
419 })
420 },
421 )
422 })
423 .collect()
424}
425
426fn remaining_operation_indices<N: Network>(
427 sequence: &ScriptSequenceKind<N>,
428 sequence_index: usize,
429) -> Vec<usize>
430where
431 N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize,
432{
433 let deployment = &sequence.sequences()[sequence_index];
434 deployment
435 .transactions
436 .iter()
437 .enumerate()
438 .filter_map(|(index, transaction)| {
439 let hash = sequence
440 .signed_payload(sequence_index, index)
441 .map(|signed| signed.hash)
442 .or_else(|| match sequence.delegated_status(sequence_index, index) {
443 Some(DelegatedStatus::Pending { hash }) => Some(hash),
444 _ => None,
445 })
446 .or(transaction.hash)
447 .or_else(|| match transaction.tx() {
448 TransactionMaybeSigned::Signed { tx, .. } => Some(tx.trie_hash()),
449 TransactionMaybeSigned::Unsigned(_) => None,
450 });
451 let completed = hash.is_some_and(|hash| {
452 deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash)
453 });
454 (!completed).then_some(index)
455 })
456 .collect()
457}
458
459fn remaining_sender_addresses<N: Network>(sequence: &ScriptSequenceKind<N>) -> AddressHashSet
460where
461 N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize,
462{
463 sequence
464 .sequences()
465 .iter()
466 .enumerate()
467 .flat_map(|(sequence_index, deployment)| {
468 remaining_operation_indices(sequence, sequence_index)
469 .into_iter()
470 .filter_map(|index| deployment.transactions[index].tx().from())
471 })
472 .collect()
473}
474
475const fn should_broadcast_sequentially(
476 estimate_via_rpc: bool,
477 slow: bool,
478 required_signers: usize,
479 ordering_senders: usize,
480 batch_supported: bool,
481) -> bool {
482 estimate_via_rpc || slow || required_signers == 0 || ordering_senders != 1 || !batch_supported
483}
484
485pub enum SendTransactionsKind<N: Network> {
487 Unlocked(AddressHashSet),
489 Raw {
491 eth_wallets: AddressHashMap<EthereumWallet>,
492 browser: Option<BrowserSigner<N>>,
493 access_keys: HashMap<SignerScope, TempoAccountsWallet>,
494 },
495}
496
497impl<N: Network> SendTransactionsKind<N> {
498 pub fn for_sender(
502 &self,
503 chain: u64,
504 addr: &Address,
505 tx: N::TransactionRequest,
506 ) -> Result<SendTransactionKind<'_, N>> {
507 match self {
508 Self::Unlocked(unlocked) => {
509 if !unlocked.contains(addr) {
510 bail!("Sender address {:?} is not unlocked", addr);
511 }
512 Ok(SendTransactionKind::Unlocked(tx))
513 }
514 Self::Raw { eth_wallets, browser, access_keys } => {
515 if let Some(wallet) = access_keys.get(&SignerScope::new(chain, *addr)) {
516 Ok(SendTransactionKind::AccessKey(tx, Box::new(wallet.clone())))
517 } else if let Some(wallet) = eth_wallets.get(addr) {
518 Ok(SendTransactionKind::Raw(tx, wallet))
519 } else if let Some(b) = browser
520 && b.address() == *addr
521 {
522 Ok(SendTransactionKind::Browser(tx, b))
523 } else {
524 bail!("No matching signer for {:?} found", addr);
525 }
526 }
527 }
528 }
529}
530
531pub struct BundledState<FEN: FoundryEvmNetwork> {
535 pub args: ScriptArgs,
536 pub script_config: ScriptConfig<FEN>,
537 pub script_wallets: Wallets,
538 pub browser_wallet: Option<BrowserSigner<FEN::Network>>,
539 pub build_data: LinkedBuildData,
540 pub sequence: ScriptSequenceKind<FEN::Network>,
541}
542
543impl<FEN: FoundryEvmNetwork> BundledState<FEN> {
544 pub async fn wait_for_pending(mut self) -> Result<Self> {
545 let progress = ScriptProgress::default();
546 let progress_ref = &progress;
547 let config = &self.script_config.config;
548 let submission_hashes = (0..self.sequence.sequences().len())
549 .map(|sequence| self.sequence.submission_hashes(sequence))
550 .collect::<Vec<_>>();
551 let futs = self
552 .sequence
553 .sequences_mut()
554 .iter_mut()
555 .zip(submission_hashes)
556 .enumerate()
557 .map(|(sequence_idx, (sequence, (durable_hashes, replayable_hashes)))| async move {
558 let rpc_url = sequence.rpc_url();
559 let provider =
560 Arc::new(ProviderBuilder::from_config_with_url(config, rpc_url)?.build()?);
561 progress_ref
562 .wait_for_pending(
563 sequence_idx,
564 sequence,
565 &provider,
566 self.script_config.config.transaction_timeout,
567 self.args.confirmations,
568 (&durable_hashes, &replayable_hashes),
569 )
570 .await
571 })
572 .collect::<Vec<_>>();
573
574 let errors = join_all(futs).await.into_iter().filter_map(Result::err).collect::<Vec<_>>();
575
576 self.sequence.save(true, false)?;
577
578 if !errors.is_empty() {
579 return Err(eyre::eyre!("{}", errors.iter().format("\n")));
580 }
581
582 Ok(self)
583 }
584
585 pub async fn broadcast(mut self) -> Result<BroadcastedState<FEN>> {
587 let remaining_transactions = remaining_unsigned_transactions_for_recovery(&self.sequence);
588 let ordering_addresses = remaining_sender_addresses(&self.sequence);
589 let has_unprepared_transactions =
590 self.sequence.sequences().iter().enumerate().any(|(sequence_index, _)| {
591 remaining_operation_indices(&self.sequence, sequence_index)
592 .into_iter()
593 .any(|index| self.sequence.signed_payload(sequence_index, index).is_none())
594 });
595 let required_addresses =
596 remaining_transactions.iter().map(|tx| tx.sender).collect::<AddressHashSet>();
597
598 if required_addresses.contains(&Config::DEFAULT_SENDER) {
599 eyre::bail!(
600 "You seem to be using Foundry's default sender. Be sure to set your own --sender."
601 );
602 }
603
604 let send_kind = if required_addresses.is_empty() {
605 SendTransactionsKind::Raw {
606 eth_wallets: AddressHashMap::default(),
607 browser: None,
608 access_keys: HashMap::default(),
609 }
610 } else if self.args.unlocked {
611 SendTransactionsKind::Unlocked(required_addresses.clone())
612 } else {
613 let expected_session_sender = script_session_expected_sender_if_configured(
614 &self.script_config.tempo,
615 &required_addresses,
616 )?;
617
618 let mut access_keys: HashMap<SignerScope, TempoAccountsWallet> = HashMap::default();
620 if let Some(expected_session_sender) = expected_session_sender
621 && let Some(session) =
622 self.script_config.tempo.session_signer_for_multi_wallet_any_chain(
623 &self.args.wallets,
624 Some(expected_session_sender),
625 )?
626 {
627 insert_session_access_key_for_remaining_transactions(
628 &mut access_keys,
629 session,
630 &remaining_transactions,
631 )?;
632 }
633
634 let signers: Vec<Address> = self
635 .script_wallets
636 .signers()
637 .map_err(|e| eyre::eyre!("{e}"))?
638 .into_iter()
639 .chain(self.browser_wallet.as_ref().map(|b| b.address()))
640 .collect();
641
642 let mut missing_addresses = Vec::new();
643 let accounts_wallet = self
644 .script_config
645 .evm_opts
646 .networks
647 .is_tempo()
648 .then(TempoAccountsWallet::try_from_default_store)
649 .transpose()?
650 .flatten();
651
652 for tx in &remaining_transactions {
653 let scope = *tx;
654 if !signers.contains(&tx.sender) && !access_keys.contains_key(&scope) {
655 if let Some(wallet) = accounts_wallet.as_ref()
656 && wallet.has_account(tx.sender)?
657 {
658 access_keys.insert(scope, wallet.clone().with_chain_id(tx.chain));
659 } else {
660 missing_addresses.push(tx.sender);
661 }
662 }
663 }
664
665 missing_addresses.sort_unstable();
666 missing_addresses.dedup();
667
668 if !missing_addresses.is_empty() {
669 eyre::bail!(
670 "No associated wallet for addresses: {:?}. Unlocked wallets: {:?}",
671 missing_addresses,
672 signers
673 );
674 }
675
676 let signers = self.script_wallets.into_multi_wallet().into_signers()?;
677 let eth_wallets: AddressHashMap<EthereumWallet> =
678 signers.into_iter().map(|(addr, signer)| (addr, signer.into())).collect();
679
680 SendTransactionsKind::Raw { eth_wallets, browser: self.browser_wallet, access_keys }
681 };
682
683 let tempo_sponsor = if has_unprepared_transactions {
684 self.script_config.tempo.sponsor_config().await?.map(Arc::new)
685 } else {
686 None
687 };
688 if tempo_sponsor.is_some()
689 && self.script_config.tempo.sponsor_sig.is_some()
690 && remaining_transactions.len() > 1
691 {
692 eyre::bail!(
693 "--tempo.sponsor-sig can only sponsor one remaining script transaction; use --tempo.sponsor-signer for multi-transaction scripts"
694 );
695 }
696
697 let progress = ScriptProgress::default();
698
699 for i in 0..self.sequence.sequences().len() {
700 let remaining_indices = remaining_operation_indices(&self.sequence, i);
701 let signed_payloads = (0..self.sequence.sequences()[i].transactions.len())
702 .map(|index| {
703 self.sequence
704 .signed_payload(i, index)
705 .map(|signed| (signed.payload.clone(), signed.hash))
706 })
707 .collect::<Vec<_>>();
708 let mut sequence = self.sequence.sequences_mut().get_mut(i).unwrap();
709
710 let provider = Arc::new(
711 ProviderBuilder::from_config_with_url(
712 &self.script_config.config,
713 sequence.rpc_url(),
714 )?
715 .build()?,
716 );
717 let unlocked_submission_provider = Arc::new(
718 ProviderBuilder::from_config_with_url(
719 &self.script_config.config,
720 sequence.rpc_url(),
721 )?
722 .max_retry(0)
723 .build()?,
724 );
725
726 let seq_progress = progress.get_sequence_progress(i, sequence);
727
728 if !remaining_indices.is_empty() {
729 let is_legacy = Chain::from(sequence.chain).is_legacy() || self.args.legacy;
730 let all_signed =
732 remaining_indices.iter().all(|index| signed_payloads[*index].is_some());
733 let (gas_price, eip1559_fees) = match (
734 all_signed,
735 is_legacy,
736 self.args.with_gas_price,
737 self.args.priority_gas_price,
738 ) {
739 (true, _, _, _) => (None, None),
740 (false, true, Some(gas_price), _) => (Some(gas_price.to()), None),
741 (false, true, None, _) => (Some(provider.get_gas_price().await?), None),
742 (false, false, Some(max_fee_per_gas), Some(max_priority_fee_per_gas)) => {
743 let max_fee: u128 = max_fee_per_gas.to();
744 let max_priority: u128 = max_priority_fee_per_gas.to();
745 if max_priority > max_fee {
746 eyre::bail!(
747 "--priority-gas-price ({max_priority}) cannot be higher than --with-gas-price ({max_fee})"
748 );
749 }
750 (
751 None,
752 Some(Eip1559Estimation {
753 max_fee_per_gas: max_fee,
754 max_priority_fee_per_gas: max_priority,
755 }),
756 )
757 }
758 (false, false, _, _) => {
759 let fees = estimate_eip1559_fees(
760 &provider,
761 self.script_config.config.eip1559_fee_estimate,
762 )
763 .await
764 .wrap_err("Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.")?;
765
766 let browser_suggested_tip = if matches!(
768 &send_kind,
769 SendTransactionsKind::Raw { browser: Some(_), .. }
770 ) {
771 provider.get_max_priority_fee_per_gas().await.ok()
772 } else {
773 None
774 };
775
776 let fees = resolve_broadcast_eip1559_fees(
777 fees,
778 self.args.with_gas_price.map(|p| p.to()),
779 self.args.priority_gas_price.map(|p| p.to()),
780 browser_suggested_tip,
781 )?;
782
783 (None, Some(fees.estimation()))
784 }
785 };
786
787 let sequence_chain = sequence.chain;
790 let mut transactions = Vec::with_capacity(remaining_indices.len());
791 for index in remaining_indices {
792 let tx_with_metadata = &sequence.transactions[index];
793 let is_fixed_gas_limit = tx_with_metadata.is_fixed_gas_limit;
794
795 let kind = match (&signed_payloads[index], tx_with_metadata.tx().clone()) {
796 (Some((payload, hash)), _) => {
797 SendTransactionKind::PreparedRaw(payload.clone(), *hash)
798 }
799 (None, TransactionMaybeSigned::Signed { tx, .. }) => {
800 if tempo_sponsor.is_some() {
801 eyre::bail!(
802 "cannot attach Tempo sponsor signature to an already signed script transaction"
803 );
804 }
805 SendTransactionKind::Signed(tx)
806 }
807 (None, TransactionMaybeSigned::Unsigned(mut tx)) => {
808 let from = tx.from().expect("No sender for onchain transaction!");
809
810 tx.set_chain_id(sequence_chain);
811
812 if tx.kind().is_none() {
815 tx.set_create();
816 }
817
818 if let Some(gas_price) = gas_price {
819 tx.set_gas_price(gas_price);
820 } else {
821 let eip1559_fees = eip1559_fees.expect("was set above");
822 tx.set_max_priority_fee_per_gas(
823 eip1559_fees.max_priority_fee_per_gas,
824 );
825 tx.set_max_fee_per_gas(eip1559_fees.max_fee_per_gas);
826 }
827
828 self.script_config.tempo.apply::<FEN::Network>(&mut tx, None);
829
830 send_kind.for_sender(sequence_chain, &from, tx)?
831 }
832 };
833 transactions.push((kind, is_fixed_gas_limit, index));
834 }
835
836 let estimate_via_rpc = has_different_gas_calc(sequence.chain)
837 || self.script_config.evm_opts.networks.is_tempo()
838 || self.args.skip_simulation;
839
840 let sequential_broadcast = should_broadcast_sequentially(
846 estimate_via_rpc,
847 self.args.slow,
848 required_addresses.len(),
849 ordering_addresses.len(),
850 has_batch_support(sequence.chain),
851 ) || transactions.iter().any(|(kind, _, _)| {
852 matches!(
853 kind,
854 SendTransactionKind::Browser(..) | SendTransactionKind::Unlocked(..)
855 )
856 });
857
858 let batch_size = if sequential_broadcast { 1 } else { 100 };
861
862 for (batch_number, batch) in transactions.chunks(batch_size).enumerate() {
863 seq_progress.inner.write().set_status(&format!(
864 "Sending transactions [{} - {}]",
865 batch_number * batch_size,
866 batch_number * batch_size + std::cmp::min(batch_size, batch.len()) - 1
867 ));
868
869 let mut prepared = Vec::with_capacity(batch.len());
870 for (kind, is_fixed_gas_limit, index) in batch {
871 let mut kind = kind
872 .clone()
873 .prepare_for_durable_send(
874 &provider,
875 sequential_broadcast,
876 *is_fixed_gas_limit,
877 estimate_via_rpc,
878 self.args.gas_estimate_multiplier,
879 tempo_sponsor.as_deref(),
880 )
881 .await?;
882 if let PreparedTransaction::Raw(payload, hash) = &mut kind {
883 *hash =
884 self.sequence.persist_signed_payload(i, *index, payload.clone())?;
885 }
886 prepared.push((kind, *index));
887 }
888
889 let mut buffer = FuturesUnordered::new();
890 for (kind, index) in prepared {
891 if let Some(request) = kind.delegated_request() {
892 kind.validate_delegated_submission()?;
893 self.sequence.persist_delegated_request(i, index, request.clone())?;
894 }
895 let provider = if matches!(kind, PreparedTransaction::Unlocked(_)) {
896 unlocked_submission_provider.clone()
897 } else {
898 provider.clone()
899 };
900 let mut pending = async move {
901 let res = kind.clone().send(provider).await;
902 (res, kind, 0, None, index)
903 }
904 .boxed();
905 if let Some(result) = pending.as_mut().now_or_never() {
906 buffer.push(futures::future::ready(result).boxed());
907 } else {
908 buffer.push(pending);
909 }
910 }
911
912 'send: while let Some((mut res, kind, attempt, original_res, index)) =
913 buffer.next().await
914 {
915 if res.is_err()
916 && let PreparedTransaction::Raw(_, hash) = kind
917 && provider
918 .get_transaction_by_hash(hash)
919 .await
920 .is_ok_and(|transaction| transaction.is_some())
921 {
922 res = Ok(hash);
923 }
924 if kind.delegated_request().is_some()
925 && let Err(error) = res
926 {
927 if kind.is_definite_non_submission(&error) {
928 self.sequence.clear_delegated_request(i, index)?;
929 return Err(error);
930 }
931 self.sequence.persist_delegated_status(
932 i,
933 index,
934 DelegatedStatus::OutcomeUnknown,
935 )?;
936 bail!(
937 "submission outcome for delegated operation {index} is unknown; refusing to risk a duplicate transaction"
938 );
939 }
940 if res.is_err() && attempt <= 3 {
941 let provider = provider.clone();
943 let progress = seq_progress.inner.clone();
944 buffer.push(Box::pin(async move {
945 debug!(err=?res, ?attempt, "retrying transaction ");
946 let attempt = attempt + 1;
947 progress.write().set_status(&format!(
948 "retrying transaction {res:?} (attempt {attempt})"
949 ));
950 tokio::time::sleep(Duration::from_millis(1000 * attempt)).await;
951 let r = kind.clone().send(provider).await;
952 (r, kind, attempt, original_res.or(Some(res)), index)
953 }));
954
955 continue 'send;
956 }
957
958 let tx_hash = res.wrap_err_with(|| {
960 if let Some(original_res) = original_res {
961 format!(
962 "Failed to send transaction after {attempt} attempts {original_res:?}"
963 )
964 } else {
965 "Failed to send transaction".to_string()
966 }
967 })?;
968 if let PreparedTransaction::Raw(_, expected) = kind
969 && expected != tx_hash
970 {
971 bail!("RPC returned hash {tx_hash} for signed payload {expected}");
972 }
973 if kind.delegated_request().is_some() {
974 self.sequence.persist_delegated_status(
975 i,
976 index,
977 DelegatedStatus::Pending { hash: tx_hash },
978 )?;
979 }
980 sequence = self.sequence.sequences_mut().get_mut(i).unwrap();
981 sequence.add_pending(index, tx_hash);
982
983 self.sequence.save(true, false)?;
985
986 seq_progress.inner.write().tx_sent(tx_hash);
987 }
988
989 self.sequence.save(true, false)?;
991 let (durable_hashes, replayable_hashes) = self.sequence.submission_hashes(i);
992 sequence = self.sequence.sequences_mut().get_mut(i).unwrap();
993
994 progress
995 .wait_for_pending(
996 i,
997 sequence,
998 &provider,
999 self.script_config.config.transaction_timeout,
1000 self.args.confirmations,
1001 (&durable_hashes, &replayable_hashes),
1002 )
1003 .await?;
1004 self.sequence.ensure_delegated_outcomes_known(i)?;
1005
1006 self.sequence.save(true, false)?;
1008 sequence = self.sequence.sequences_mut().get_mut(i).unwrap();
1009 }
1010 }
1011
1012 let (total_gas, avg_gas_price, total_paid) = fee_totals(
1013 sequence.receipts.iter().map(|r| (r.gas_used(), r.effective_gas_price())),
1014 );
1015 let paid = format_units(total_paid, 18).unwrap_or_else(|_| "N/A".to_string());
1016 let avg_gas_price = avg_gas_price
1017 .and_then(|avg| format_units(avg, 9).ok())
1018 .unwrap_or_else(|| "N/A".to_string());
1019
1020 let token_symbol = NamedChain::try_from(sequence.chain)
1021 .unwrap_or_default()
1022 .native_currency_symbol()
1023 .unwrap_or("ETH");
1024 seq_progress.inner.write().set_status(&format!(
1025 "Total Paid: {} {} ({} gas * avg {} gwei)\n",
1026 paid.trim_end_matches('0'),
1027 token_symbol,
1028 total_gas,
1029 avg_gas_price.trim_end_matches('0').trim_end_matches('.')
1030 ));
1031 seq_progress.inner.write().finish();
1032 }
1033
1034 if !shell::is_json() {
1035 sh_println!("\n\n==========================")?;
1036 sh_println!("\nONCHAIN EXECUTION COMPLETE & SUCCESSFUL.")?;
1037 }
1038
1039 Ok(BroadcastedState {
1040 args: self.args,
1041 script_config: self.script_config,
1042 build_data: self.build_data,
1043 sequence: self.sequence,
1044 })
1045 }
1046
1047 pub async fn verify_preflight_check(&self) -> Result<()> {
1048 if self.args.verify_external && self.script_config.config.offline {
1049 bail!("External contract verification is unavailable in offline mode");
1050 }
1051
1052 for sequence in self.sequence.sequences() {
1053 let chain: Chain = sequence.chain.into();
1054 let etherscan_key = self
1056 .script_config
1057 .config
1058 .get_etherscan_api_key(Some(chain))
1059 .or_else(|| self.script_config.config.etherscan_api_key.clone());
1060 let api_key =
1061 self.args.verifier.resolve_api_key(etherscan_key.as_deref()).map(str::to_owned);
1062 let has_url = self.args.verifier.verifier_url.is_some();
1063 let is_explicit = self.args.verifier.is_explicitly_set();
1064 self.args
1067 .verifier
1068 .resolve(api_key.as_deref(), Some(chain))
1069 .client(api_key.as_deref(), Some(chain), has_url, is_explicit)
1070 .wrap_err_with(|| {
1071 format!("Verification preflight check failed for chain {}", sequence.chain)
1072 })?;
1073 self.args
1075 .verifier
1076 .check_credentials(api_key.as_deref(), chain, &self.script_config.config)
1077 .await
1078 .wrap_err_with(|| {
1079 format!("Verification preflight check failed for chain {}", sequence.chain)
1080 })?;
1081 }
1082
1083 Ok(())
1084 }
1085}
1086
1087impl BundledState<TempoEvmNetwork> {
1088 pub async fn broadcast_batch(mut self) -> Result<BroadcastedState<TempoEvmNetwork>> {
1093 if self.sequence.sequences().len() != 1 {
1095 bail!(
1096 "--batch mode only supports single-chain scripts. \
1097 Use --multi without --batch for multi-chain."
1098 );
1099 }
1100
1101 let batch_resolution = if self.args.resume_tx_hash.is_some() {
1102 self.sequence
1103 .restore_delegated_pending(
1104 self.args.resume_attempt,
1105 self.args.resume_tx_hash,
1106 false,
1107 )?
1108 .map(|(_, _, attempt, hash)| (attempt, hash))
1109 } else {
1110 None
1111 };
1112 let mut recovered_batch = self
1113 .sequence
1114 .batch_attempt(0)
1115 .map(|(first, attempt, kind)| (first, attempt, kind.clone()));
1116
1117 let sequence = self.sequence.sequences().first().unwrap();
1118 let total_transactions = sequence.transactions.len();
1119 let completed_prefix = completed_transaction_prefix(sequence)?;
1120 let batch_start =
1121 recovered_batch.as_ref().map(|(first, _, _)| *first).unwrap_or(completed_prefix);
1122
1123 if batch_start == total_transactions {
1124 sh_println!("No transactions to broadcast in batch mode.")?;
1125 return Ok(BroadcastedState {
1126 args: self.args,
1127 script_config: self.script_config,
1128 build_data: self.build_data,
1129 sequence: self.sequence,
1130 });
1131 }
1132
1133 if let Some((idx, _)) =
1136 sequence.transactions().enumerate().find(|(_, tx)| !tx.is_unsigned())
1137 {
1138 bail!(
1139 "--batch cannot include pre-signed transactions (found at position {}); \
1140 batch mode signs a single atomic transaction from one sender.",
1141 idx + 1
1142 );
1143 }
1144
1145 let senders: AddressHashSet = sequence
1147 .transactions()
1148 .skip(batch_start)
1149 .filter(|tx| tx.is_unsigned())
1150 .filter_map(|tx| tx.from())
1151 .collect();
1152
1153 if senders.len() != 1 {
1154 bail!(
1155 "--batch mode requires all transactions to have the same sender. \
1156 Found {} unique senders: {:?}",
1157 senders.len(),
1158 senders
1159 );
1160 }
1161
1162 let sender = *senders.iter().next().unwrap();
1163 let chain_id = sequence.chain;
1164 let calls = tempo_batch_calls(sequence, batch_start)?;
1165 if let Some((_, _, kind)) = &recovered_batch {
1166 match kind {
1167 AttemptKind::Signed { request: Some(request), payload } => {
1168 let hash = validate_tempo_batch_payload(
1169 request,
1170 &payload.payload,
1171 sender,
1172 chain_id,
1173 &calls,
1174 )?;
1175 if hash != payload.hash {
1176 bail!("recovery plan batch payload does not match its hash");
1177 }
1178 }
1179 AttemptKind::Delegated { request, .. } => {
1180 validate_tempo_batch_request(request, sender, chain_id, &calls)?;
1181 }
1182 AttemptKind::Legacy { .. } => {}
1183 AttemptKind::Signed { request: None, .. } => {
1184 bail!("recovery plan batch payload has no persisted request");
1185 }
1186 }
1187 }
1188
1189 let rpc_url = sequence.rpc_url().to_owned();
1190 let _ = sequence;
1191 if let Some((attempt_id, hash)) = batch_resolution {
1192 let provider = ProviderBuilder::<TempoNetwork>::from_config_with_url(
1193 &self.script_config.config,
1194 &rpc_url,
1195 )?
1196 .build()?;
1197 let transaction = provider
1198 .get_transaction_by_hash(hash)
1199 .await?
1200 .context("resolved transaction is not available from the recovery endpoint")?;
1201 self.sequence.resolve_delegated_hash(attempt_id, hash, &transaction)?;
1202 recovered_batch = self
1203 .sequence
1204 .batch_attempt(0)
1205 .map(|(first, attempt, kind)| (first, attempt, kind.clone()));
1206 }
1207
1208 let recovered_hash = recovered_batch.as_ref().and_then(|(_, _, kind)| match kind {
1209 AttemptKind::Signed { payload, .. } => Some(payload.hash),
1210 AttemptKind::Delegated { status: DelegatedStatus::Pending { hash }, .. }
1211 | AttemptKind::Legacy { hash } => Some(*hash),
1212 AttemptKind::Delegated { .. } => None,
1213 });
1214 if completed_prefix == total_transactions {
1215 sh_println!("No transactions to broadcast in batch mode.")?;
1216 return Ok(BroadcastedState {
1217 args: self.args,
1218 script_config: self.script_config,
1219 build_data: self.build_data,
1220 sequence: self.sequence,
1221 });
1222 }
1223
1224 if matches!(
1225 recovered_batch,
1226 Some((_, _, AttemptKind::Delegated { status: DelegatedStatus::OutcomeUnknown, .. }))
1227 ) {
1228 bail!(
1229 "submission outcome for delegated Tempo batch is unknown; refusing to risk a duplicate transaction"
1230 );
1231 }
1232
1233 let sequence = self.sequence.sequences_mut().get_mut(0).unwrap();
1234
1235 if sender == Config::DEFAULT_SENDER {
1236 bail!(
1237 "You seem to be using Foundry's default sender. Be sure to set your own --sender."
1238 );
1239 }
1240
1241 let provider = Arc::new(
1242 ProviderBuilder::<TempoNetwork>::from_config_with_url(
1243 &self.script_config.config,
1244 sequence.rpc_url(),
1245 )?
1246 .build()?,
1247 );
1248
1249 if let Some((_, _, AttemptKind::Legacy { hash })) = &recovered_batch {
1250 let transaction = provider
1251 .get_transaction_by_hash(*hash)
1252 .await?
1253 .context("legacy batch transaction is not available for validation")?;
1254 if transaction.tx_hash() != *hash {
1255 bail!("legacy batch endpoint returned a transaction with the wrong hash");
1256 }
1257 validate_tempo_batch_envelope(transaction.as_ref(), sender, chain_id, &calls)?;
1258 }
1259
1260 if recovered_batch.is_none()
1264 && (!sequence.pending.is_empty()
1265 || sequence.transactions.iter().skip(batch_start).any(|tx| tx.hash.is_some()))
1266 {
1267 bail!("batch progress exists without a durable submission attempt");
1268 }
1269
1270 if let Some(hash) = recovered_hash {
1271 let mut changed = false;
1272 for tx in sequence.transactions.iter_mut().skip(batch_start) {
1273 if tx.hash != Some(hash) {
1274 tx.hash = Some(hash);
1275 changed = true;
1276 }
1277 }
1278 if !sequence.receipts.iter().any(|receipt| receipt.transaction_hash() == hash)
1279 && !sequence.pending.contains(&hash)
1280 {
1281 sequence.pending.push(hash);
1282 changed = true;
1283 }
1284 if changed {
1285 self.sequence.save(true, false)?;
1286 }
1287 }
1288
1289 if let Some((_, _, AttemptKind::Signed { payload, .. })) = &recovered_batch
1290 && provider.get_transaction_receipt(payload.hash).await?.is_none()
1291 && provider.get_transaction_by_hash(payload.hash).await?.is_none()
1292 {
1293 send_tempo_batch_payload(provider.as_ref(), &payload.payload, payload.hash).await?;
1294 }
1295
1296 if let Some(tx_hash) = recovered_hash {
1297 sh_println!(
1298 "Resuming batch: tx {tx_hash:#x} already submitted, waiting for receipt..."
1299 )?;
1300
1301 let timeout = self.script_config.config.transaction_timeout;
1302 let receipt_result = tokio::time::timeout(
1303 Duration::from_secs(timeout),
1304 wait_for_batch_receipt(provider.as_ref(), tx_hash, self.args.confirmations),
1305 )
1306 .await;
1307
1308 match receipt_result {
1309 Ok(Ok(Some(receipt))) => {
1310 Self::finish_batch(&mut self.sequence, receipt, tx_hash, batch_start, true)?;
1311 return Ok(BroadcastedState {
1312 args: self.args,
1313 script_config: self.script_config,
1314 build_data: self.build_data,
1315 sequence: self.sequence,
1316 });
1317 }
1318 Ok(Ok(None)) => {
1319 bail!(
1320 "Tempo batch {tx_hash:#x} is not currently visible; its pending hash remains checkpointed"
1321 );
1322 }
1323 Ok(Err(e)) => return Err(e),
1324 Err(_) => {
1325 return Err(eyre::eyre!(
1326 "Timeout waiting for batch transaction receipt (tx: {tx_hash:#x}). \
1327 The transaction hash remains checkpointed; run with --resume to retry."
1328 ));
1329 }
1330 }
1331 }
1332
1333 let sequence = self.sequence.sequences_mut().get_mut(0).unwrap();
1335
1336 let tempo_sponsor = self.script_config.tempo.sponsor_config().await?;
1337
1338 enum BatchSigner {
1340 Unlocked,
1341 Wallet(EthereumWallet),
1342 TempoKeychain(Box<TempoAccountsWallet>),
1343 }
1344
1345 let mut batch_signer = if self.args.unlocked {
1346 BatchSigner::Unlocked
1347 } else if let Some(session) = self.script_config.tempo.session_signer_for_multi_wallet(
1348 &self.args.wallets,
1349 Some(sender),
1350 chain_id,
1351 )? {
1352 BatchSigner::TempoKeychain(Box::new(session.access_key))
1353 } else {
1354 let mut signers = self.script_wallets.into_multi_wallet().into_signers()?;
1355 if let Some(signer) = signers.remove(&sender) {
1356 BatchSigner::Wallet(EthereumWallet::new(signer))
1357 } else {
1358 if self.script_config.evm_opts.networks.is_tempo()
1360 && let Some(wallet) = TempoAccountsWallet::try_from_default_store()?
1361 && wallet.has_account(sender)?
1362 {
1363 BatchSigner::TempoKeychain(Box::new(wallet.with_chain_id(chain_id)))
1364 } else {
1365 bail!("No wallet found for sender {}", sender);
1366 }
1367 }
1368 };
1369
1370 let create2_deployer = self.script_config.evm_opts.create2_deployer;
1372 let needs_factory =
1373 sequence.transactions.iter().skip(batch_start).any(|tx| tx.call_kind.is_any_create());
1374 if needs_factory {
1375 let code = provider.get_code_at(create2_deployer).await?;
1376 if keccak256(&code) != DEFAULT_CREATE2_DEPLOYER_CODEHASH {
1377 bail!(
1378 "CREATE2 deployer {create2_deployer:#x} is not deployed on this Tempo network; \
1379 --batch requires it. Deploy it first and retry."
1380 );
1381 }
1382 }
1383
1384 sh_println!(
1385 "\n## Broadcasting batch transaction with {} call(s) to chain {}...",
1386 calls.len(),
1387 sequence.chain
1388 )?;
1389
1390 let nonce = provider.get_transaction_count(sender).await?;
1392
1393 let fees = estimate_eip1559_fees(&provider, self.script_config.config.eip1559_fee_estimate)
1395 .await?;
1396 let fees = resolve_broadcast_eip1559_fees(
1397 fees,
1398 self.args.with_gas_price.map(|p| p.to()),
1399 self.args.priority_gas_price.map(|p| p.to()),
1400 None,
1401 )?;
1402 let max_fee_per_gas = fees.max_fee_per_gas;
1403 let max_priority_fee_per_gas = fees.max_priority_fee_per_gas;
1404
1405 let mut batch_tx = TempoTransactionRequest {
1406 inner: TransactionRequest {
1407 from: Some(sender),
1408 to: None,
1409 value: None,
1410 input: Default::default(),
1411 nonce: Some(nonce),
1412 chain_id: Some(chain_id),
1413 max_fee_per_gas: Some(max_fee_per_gas),
1414 max_priority_fee_per_gas: Some(max_priority_fee_per_gas),
1415 ..Default::default()
1416 },
1417 fee_token: self.script_config.tempo.fee_token,
1418 calls: calls.clone(),
1419 nonce_key: self.script_config.tempo.expiring_nonce.then_some(U256::MAX),
1420 valid_before: self.script_config.tempo.valid_before.and_then(NonZeroU64::new),
1421 ..Default::default()
1422 };
1423 self.script_config.tempo.apply::<TempoNetwork>(&mut batch_tx, None);
1424 let fee_token = if let Some(sponsor) = &tempo_sponsor {
1425 sponsor.resolve_and_set_fee_token(Some(provider.as_ref()), &mut batch_tx).await?;
1426 None
1427 } else {
1428 resolve_and_set_fee_token(Some(provider.as_ref()), &mut batch_tx, Some(sender)).await?
1429 };
1430
1431 if let BatchSigner::TempoKeychain(wallet) = &mut batch_signer {
1432 **wallet = batch_tx.prepare_with_tempo_wallet(provider.as_ref(), wallet).await?;
1433 }
1434
1435 estimate_gas(&mut batch_tx, provider.as_ref(), self.args.gas_estimate_multiplier, false)
1437 .await?;
1438
1439 sh_println!("Estimated gas: {}", batch_tx.inner.gas.unwrap_or(0))?;
1440
1441 if let Some(sponsor) = &tempo_sponsor {
1442 sponsor.attach_and_print::<TempoNetwork>(&mut batch_tx, sender).await?;
1443 } else {
1444 maybe_print_fee_token(Some(provider.as_ref()), fee_token).await?;
1445 }
1446 batch_tx.prep_for_submission();
1447
1448 let _ = sequence;
1449
1450 let signed_payload = match &batch_signer {
1452 BatchSigner::Wallet(wallet) => {
1453 Some(Bytes::from(batch_tx.clone().build(wallet).await?.encoded_2718()))
1454 }
1455 BatchSigner::TempoKeychain(wallet) => {
1456 Some(Bytes::from(batch_tx.clone().sign_with_tempo_wallet(wallet).await?))
1457 }
1458 BatchSigner::Unlocked => None,
1459 };
1460 let tx_hash = if let Some(payload) = signed_payload {
1461 validate_tempo_batch_payload(&batch_tx, &payload, sender, chain_id, &calls)?;
1462 let expected = self.sequence.persist_batch_signed_payload(
1463 0,
1464 batch_start,
1465 batch_tx,
1466 payload.clone(),
1467 )?;
1468 send_tempo_batch_payload(provider.as_ref(), &payload, expected).await?;
1469 expected
1470 } else {
1471 let provider = ProviderBuilder::<TempoNetwork>::from_config_with_url(
1472 &self.script_config.config,
1473 &rpc_url,
1474 )?
1475 .max_retry(0)
1476 .build()?;
1477 self.sequence.persist_batch_delegated_request(0, batch_start, batch_tx.clone())?;
1478 let (_, attempt_id, _) = self.sequence.batch_attempt(0).unwrap();
1479 let pending = match provider.send_transaction(batch_tx).await {
1480 Ok(pending) => pending,
1481 Err(error) => {
1482 let error = eyre::Report::from(error);
1483 if is_definite_unlocked_non_submission(&error) {
1484 self.sequence.clear_delegated_request(0, batch_start)?;
1485 return Err(error);
1486 }
1487 self.sequence.persist_delegated_status(
1488 0,
1489 batch_start,
1490 DelegatedStatus::OutcomeUnknown,
1491 )?;
1492 return Err(error.wrap_err(format!(
1493 "submission outcome for delegated Tempo batch attempt {attempt_id} is unknown; resolve it with --resume-attempt and --resume-tx-hash, or use --resume-retry only after proving it was not submitted"
1494 )));
1495 }
1496 };
1497 let hash = *pending.tx_hash();
1498 self.sequence.persist_delegated_status(
1499 0,
1500 batch_start,
1501 DelegatedStatus::Pending { hash },
1502 )?;
1503 hash
1504 };
1505
1506 sh_println!("Batch transaction sent: {:#x}", tx_hash)?;
1507
1508 let sequence = self.sequence.sequences_mut().get_mut(0).unwrap();
1512 for tx in sequence.transactions.iter_mut().skip(batch_start) {
1513 tx.hash = Some(tx_hash);
1514 }
1515 if !sequence.pending.contains(&tx_hash) {
1516 sequence.pending.push(tx_hash);
1517 }
1518 self.sequence.save(true, false)?;
1519
1520 let timeout = self.script_config.config.transaction_timeout;
1522 let receipt = tokio::time::timeout(
1523 Duration::from_secs(timeout),
1524 wait_for_batch_receipt(provider.as_ref(), tx_hash, self.args.confirmations),
1525 )
1526 .await
1527 .map_err(|_| eyre::eyre!("Timeout waiting for batch transaction receipt (tx: {tx_hash:#x}). Run with --resume to retry."))??
1528 .ok_or_else(|| eyre::eyre!("Batch transaction {tx_hash:#x} was dropped from the mempool. Run with --resume to retry."))?;
1529
1530 Self::finish_batch(&mut self.sequence, receipt, tx_hash, batch_start, false)?;
1531 Ok(BroadcastedState {
1532 args: self.args,
1533 script_config: self.script_config,
1534 build_data: self.build_data,
1535 sequence: self.sequence,
1536 })
1537 }
1538
1539 fn finish_batch(
1540 sequences: &mut ScriptSequenceKind<TempoNetwork>,
1541 receipt: TempoTransactionReceipt,
1542 tx_hash: TxHash,
1543 batch_start: usize,
1544 resumed: bool,
1545 ) -> Result<()> {
1546 if receipt.status() {
1547 sh_println!(
1548 "Batch transaction confirmed in block {}",
1549 receipt.block_number.unwrap_or(0)
1550 )?;
1551 } else {
1552 bail!("Batch transaction failed (reverted)");
1553 }
1554
1555 let sequence = sequences.sequences_mut().get_mut(0).unwrap();
1556 sequence.remove_pending(tx_hash);
1557
1558 let remaining_len = sequence.transactions.len() - batch_start;
1560 let per_tx_addresses: Vec<Option<Address>> = sequence
1565 .transactions
1566 .iter()
1567 .skip(batch_start)
1568 .map(|tx| match tx.call_kind {
1569 CallKind::Create | CallKind::Create2 => tx.contract_address,
1570 _ => None,
1571 })
1572 .collect();
1573
1574 for (idx, addr) in per_tx_addresses.iter().enumerate() {
1575 if let Some(addr) = addr {
1576 sh_println!(" call[{idx}] deployed at: {addr:#x}")?;
1577 }
1578 }
1579
1580 for addr in &per_tx_addresses {
1582 let mut tx_receipt = receipt.clone();
1583 tx_receipt.contract_address = *addr;
1584 sequence.receipts.push(tx_receipt);
1585 }
1586
1587 let chain = sequence.chain;
1588 let _ = sequence;
1589
1590 sequences.save(true, false)?;
1591
1592 let total_gas = receipt.gas_used();
1593 let gas_price = receipt.effective_gas_price();
1594 let total_paid = u128::from(total_gas).saturating_mul(gas_price);
1595 let paid = format_units(total_paid, 18).unwrap_or_else(|_| "N/A".to_string());
1596 let gas_price_gwei = format_units(gas_price, 9).unwrap_or_else(|_| "N/A".to_string());
1597
1598 let token_symbol = NamedChain::try_from(chain)
1599 .unwrap_or_default()
1600 .native_currency_symbol()
1601 .unwrap_or("ETH");
1602 let resumed =
1603 resumed.then(|| format!("\n(resumed from previous run, {remaining_len} tx(s))"));
1604 sh_println!(
1605 "\nTotal Paid: {} {} ({} gas * {} gwei){}",
1606 paid.trim_end_matches('0'),
1607 token_symbol,
1608 total_gas,
1609 gas_price_gwei.trim_end_matches('0').trim_end_matches('.'),
1610 resumed.as_deref().unwrap_or_default(),
1611 )?;
1612
1613 if !shell::is_json() {
1614 sh_println!("\n\n==========================")?;
1615 sh_println!("\nBATCH EXECUTION COMPLETE & SUCCESSFUL.")?;
1616 sh_println!(
1617 "All {} calls executed atomically in a single transaction.",
1618 remaining_len
1619 )?;
1620 }
1621
1622 Ok(())
1623 }
1624}
1625
1626async fn send_tempo_batch_payload(
1627 provider: &RootProvider<TempoNetwork>,
1628 payload: &[u8],
1629 expected: TxHash,
1630) -> Result<()> {
1631 match provider.send_raw_transaction(payload).await {
1632 Ok(pending) if *pending.tx_hash() == expected => Ok(()),
1633 Ok(pending) => {
1634 bail!("RPC returned hash {} for signed batch payload {expected}", pending.tx_hash())
1635 }
1636 Err(error) => {
1637 if provider.get_transaction_receipt(expected).await.ok().flatten().is_some()
1638 || provider.get_transaction_by_hash(expected).await.ok().flatten().is_some()
1639 {
1640 Ok(())
1641 } else {
1642 Err(error.into())
1643 }
1644 }
1645 }
1646}
1647
1648async fn wait_for_batch_receipt<N: Network>(
1649 provider: &RootProvider<N>,
1650 tx_hash: TxHash,
1651 confirmations: u64,
1652) -> Result<Option<N::ReceiptResponse>> {
1653 loop {
1654 if let Some(receipt) = provider.get_transaction_receipt(tx_hash).await?
1655 && is_mined_receipt_for(&receipt, tx_hash)
1656 && let Some(receipt_block) = receipt.block_number()
1657 {
1658 let latest_block = provider.get_block_number().await?;
1659 if latest_block >= receipt_block.saturating_add(confirmations.saturating_sub(1)) {
1660 return Ok(Some(receipt));
1661 }
1662 }
1663
1664 if provider.get_transaction_by_hash(tx_hash).await?.is_none() {
1665 return Ok(None);
1666 }
1667
1668 tokio::time::sleep(Duration::from_millis(500)).await;
1669 }
1670}
1671
1672pub async fn estimate_gas<N: Network, P: Provider<N>>(
1673 tx: &mut N::TransactionRequest,
1674 provider: &P,
1675 estimate_multiplier: u64,
1676 browser: bool,
1677) -> Result<()>
1678where
1679 N::TransactionRequest: FoundryTransactionBuilder<N>,
1680{
1681 tx.reset_gas_limit();
1684
1685 let request = if browser { tx.browser_wallet_gas_estimation_request() } else { tx.clone() };
1686 tx.set_gas_limit(
1687 provider.estimate_gas(request).await.wrap_err("Failed to estimate gas for tx")?
1688 * estimate_multiplier
1689 / 100,
1690 );
1691 Ok(())
1692}
1693
1694fn reject_access_key_create<N: Network>(
1695 tx: &N::TransactionRequest,
1696 uses_access_key: bool,
1697) -> Result<()>
1698where
1699 N::TransactionRequest: FoundryTransactionBuilder<N>,
1700{
1701 if uses_access_key && tx.tempo_calls().iter().any(|(to, _)| to.is_create()) {
1702 bail!("Tempo access-key transactions cannot use CREATE");
1703 }
1704 Ok(())
1705}
1706
1707fn convert_tempo_aa_create<N: Network>(tx: &mut N::TransactionRequest)
1708where
1709 N::TransactionRequest: FoundryTransactionBuilder<N>,
1710{
1711 if tx.is_tempo_aa() {
1712 tx.convert_create_to_call();
1713 }
1714}
1715
1716fn fee_totals(receipts: impl IntoIterator<Item = (u64, u128)>) -> (u64, Option<u128>, u128) {
1719 let (count, total_gas, total_gas_price, total_paid) =
1720 receipts.into_iter().fold((0u128, 0u64, 0u128, 0u128), |acc, (gas_used, gas_price)| {
1721 (
1722 acc.0 + 1,
1723 acc.1 + gas_used,
1724 acc.2.saturating_add(gas_price),
1725 acc.3.saturating_add(u128::from(gas_used).saturating_mul(gas_price)),
1726 )
1727 });
1728 (total_gas, total_gas_price.checked_div(count), total_paid)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734 use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, TxEnvelope};
1735 use alloy_network::Ethereum;
1736 use alloy_primitives::{B256, Bloom, address, hex};
1737 use alloy_provider::mock::Asserter;
1738 use alloy_rpc_types::TransactionReceipt;
1739 use alloy_signer::Signer;
1740 use forge_script_sequence::TransactionWithMetadata;
1741 use foundry_common::tempo::PATH_USD_ADDRESS;
1742 use foundry_evm::{backend::Backend, core::evm::EthEvmNetwork, opts::EvmOpts};
1743
1744 const ROOT_PRIVATE_KEY: &str =
1745 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1746 const ACCESS_KEY_PRIVATE_KEY: &str =
1747 "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
1748 const SIGNED_TX: &[u8] = &hex!(
1749 "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a"
1750 );
1751 const OTHER_SIGNED_TX: &[u8] = &hex!(
1752 "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505"
1753 );
1754
1755 #[tokio::test(flavor = "multi_thread")]
1756 async fn next_nonce_uses_exact_fork_hash() {
1757 let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
1758 let provider = handle.http_provider();
1759 let sender = handle.dev_accounts().next().unwrap();
1760 let recipient = Address::with_last_byte(1);
1761
1762 let receipt = provider
1763 .send_transaction(
1764 TransactionRequest::default().from(sender).to(recipient).value(U256::ONE).into(),
1765 )
1766 .await
1767 .unwrap()
1768 .get_receipt()
1769 .await
1770 .unwrap();
1771 let block_number = receipt.block_number.unwrap();
1772 let evm_opts = EvmOpts {
1773 fork_url: Some(handle.http_endpoint()),
1774 fork_block_number: Some(block_number),
1775 ..Default::default()
1776 };
1777 let backend =
1778 Backend::<EthEvmNetwork>::spawn(evm_opts.get_fork(&Config::default(), 31337, None))
1779 .unwrap();
1780 assert_eq!(backend.transaction_count(sender).await.unwrap(), 1);
1781
1782 provider
1783 .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::<serde_json::Value>::new()))
1784 .await
1785 .unwrap();
1786 assert_eq!(provider.get_transaction_count(sender).number(block_number).await.unwrap(), 0);
1787
1788 match backend.transaction_count(sender).await {
1789 Ok(0) => panic!("the exact lookup fell back to the replacement block"),
1790 Ok(1) | Err(_) => {}
1791 Ok(nonce) => panic!("unexpected nonce: {nonce}"),
1792 }
1793 }
1794
1795 #[test]
1796 fn access_key_signer_takes_precedence_over_same_sender_wallet() {
1797 let root = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap();
1798 let root_address = root.address();
1799 let access_key =
1800 foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap();
1801 let access_key_address = access_key.address();
1802 let mut eth_wallets = AddressHashMap::default();
1803 eth_wallets.insert(root_address, EthereumWallet::new(root));
1804 let mut access_keys = HashMap::default();
1805 access_keys.insert(
1806 SignerScope::new(4217, root_address),
1807 TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217),
1808 );
1809 let send_kind =
1810 SendTransactionsKind::<Ethereum>::Raw { eth_wallets, browser: None, access_keys };
1811
1812 let tx = TransactionRequest { from: Some(root_address), ..Default::default() };
1813 let sender = send_kind.for_sender(4217, &root_address, tx).unwrap();
1814
1815 match sender {
1816 SendTransactionKind::AccessKey(_, wallet) => {
1817 assert_eq!(wallet.key_id().unwrap(), access_key_address);
1818 assert_eq!(wallet.account(), root_address);
1819 }
1820 _ => panic!("expected access key signer"),
1821 }
1822 }
1823
1824 #[test]
1825 fn access_key_signer_is_scoped_to_chain() {
1826 let root = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap();
1827 let root_address = root.address();
1828 let access_key =
1829 foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap();
1830 let mut eth_wallets = AddressHashMap::default();
1831 eth_wallets.insert(root_address, EthereumWallet::new(root));
1832 let mut access_keys = HashMap::default();
1833 access_keys.insert(
1834 SignerScope::new(4217, root_address),
1835 TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217),
1836 );
1837 let send_kind =
1838 SendTransactionsKind::<Ethereum>::Raw { eth_wallets, browser: None, access_keys };
1839
1840 let tx = TransactionRequest { from: Some(root_address), ..Default::default() };
1841 let sender = send_kind.for_sender(1, &root_address, tx).unwrap();
1842
1843 match sender {
1844 SendTransactionKind::Raw(_, wallet) => {
1845 assert_eq!(wallet.default_signer().address(), root_address);
1846 }
1847 _ => panic!("expected root wallet signer for non-session chain"),
1848 }
1849 }
1850
1851 #[test]
1852 fn recovered_signed_payload_does_not_require_a_signer() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let mut sequence = ScriptSequence::<Ethereum> {
1855 chain: 1,
1856 transactions: [planned_tx(SIGNED_TX)].into(),
1857 ..Default::default()
1858 };
1859 sequence.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1860 let mut sequence = ScriptSequenceKind::new_single(sequence, false).unwrap();
1861 let payload = Bytes::from_static(SIGNED_TX);
1862 sequence.persist_signed_payload(0, 0, payload).unwrap();
1863
1864 assert!(remaining_unsigned_transactions_for_recovery(&sequence).is_empty());
1865 }
1866
1867 #[test]
1868 fn recovered_batch_attempt_does_not_require_a_signer() {
1869 let dir = tempfile::tempdir().unwrap();
1870 let sender = Address::repeat_byte(0x22);
1871 let mut deployment = ScriptSequence::<Ethereum> {
1872 chain: 1,
1873 transactions: [script_tx(sender)].into(),
1874 ..Default::default()
1875 };
1876 deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1877 let mut sequence = ScriptSequenceKind::new_single(deployment, true).unwrap();
1878 sequence.persist_batch_delegated_request(0, 0, Default::default()).unwrap();
1879
1880 assert!(remaining_unsigned_transactions_for_recovery(&sequence).is_empty());
1881 }
1882
1883 #[test]
1884 fn batch_retry_clears_the_shared_delegated_attempt() {
1885 let dir = tempfile::tempdir().unwrap();
1886 let recovery_path = dir.path().join("cache.json.recovery.json");
1887 let mut deployment = ScriptSequence::<Ethereum> {
1888 chain: 1,
1889 transactions: [script_tx(Address::ZERO), script_tx(Address::ZERO)].into(),
1890 ..Default::default()
1891 };
1892 deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1893 let mut sequence = ScriptSequenceKind::new_single(deployment, true).unwrap();
1894 sequence.persist_batch_delegated_request(0, 0, Default::default()).unwrap();
1895 let (_, attempt, _) = sequence.batch_attempt(0).unwrap();
1896
1897 sequence.restore_delegated_pending(Some(attempt), None, true).unwrap();
1898
1899 assert!(sequence.batch_attempt(0).is_none());
1900 drop(sequence);
1901 let recovery: serde_json::Value =
1902 foundry_common::fs::read_json_file(&recovery_path).unwrap();
1903 assert_eq!(recovery["deployments"][0]["operations"].as_array().unwrap().len(), 2);
1904 assert!(recovery["deployments"][0].get("attempts").is_none());
1905 }
1906
1907 #[test]
1908 fn recovered_sender_still_requires_sequential_ordering() {
1909 let dir = tempfile::tempdir().unwrap();
1910 let unsigned = Address::repeat_byte(0x22);
1911 let mut sequence = ScriptSequence::<Ethereum> {
1912 chain: 1,
1913 transactions: [planned_tx(SIGNED_TX), script_tx(unsigned)].into(),
1914 ..Default::default()
1915 };
1916 sequence.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1917 let mut sequence = ScriptSequenceKind::new_single(sequence, false).unwrap();
1918 sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX)).unwrap();
1919
1920 let required = remaining_unsigned_transactions_for_recovery(&sequence);
1921 assert_eq!(required.iter().map(|tx| tx.sender).collect::<Vec<_>>(), [unsigned]);
1922 assert_eq!(remaining_sender_addresses(&sequence).len(), 2);
1923 }
1924
1925 #[test]
1926 fn signed_only_sequences_remain_sequential() {
1927 assert!(should_broadcast_sequentially(false, false, 0, 1, true));
1928 assert!(!should_broadcast_sequentially(false, false, 1, 1, true));
1929 }
1930
1931 #[test]
1932 fn browser_rejections_are_definite_non_submissions() {
1933 let rejected =
1934 eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::Rejected {
1935 operation: "Transaction",
1936 reason: "Rejected by user".to_string(),
1937 }));
1938 assert!(is_definite_browser_non_submission(&rejected));
1939 let send_error = eyre::Report::new(alloy_signer::Error::other(
1940 BrowserWalletError::ServerError("replacement transaction underpriced".to_string()),
1941 ));
1942 assert!(!is_definite_browser_non_submission(&send_error));
1943 let not_connected =
1944 eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::NotConnected));
1945 assert!(is_definite_browser_non_submission(¬_connected));
1946 let timeout = eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::Timeout {
1947 operation: "Transaction",
1948 }));
1949 assert!(!is_definite_browser_non_submission(&timeout));
1950 }
1951
1952 #[test]
1953 fn only_anvil_no_signer_errors_are_definite_non_submissions() {
1954 assert!(is_unlocked_non_submission(-32602, "No Signer available"));
1955 assert!(!is_unlocked_non_submission(-32603, "No Signer available"));
1956 assert!(!is_unlocked_non_submission(-32602, "transaction rejected"));
1957 }
1958
1959 #[test]
1960 fn recovered_completion_is_matched_by_hash() {
1961 let dir = tempfile::tempdir().unwrap();
1962 let mut deployment = ScriptSequence::<Ethereum> {
1963 chain: 1,
1964 transactions: [planned_tx(SIGNED_TX), planned_tx(OTHER_SIGNED_TX)].into(),
1965 ..Default::default()
1966 };
1967 deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1968 let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap();
1969 sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX)).unwrap();
1970 let second_hash =
1971 sequence.persist_signed_payload(0, 1, Bytes::from_static(OTHER_SIGNED_TX)).unwrap();
1972 let mut second_receipt = receipt();
1973 second_receipt.transaction_hash = second_hash;
1974 sequence.sequences_mut()[0].receipts.push(second_receipt);
1975
1976 assert_eq!(remaining_operation_indices(&sequence, 0), [0]);
1977 }
1978
1979 #[test]
1980 fn externally_signed_completion_uses_the_persisted_hash() {
1981 let dir = tempfile::tempdir().unwrap();
1982 let sender = Address::repeat_byte(0x11);
1983 let mut deployment = ScriptSequence::<Ethereum> {
1984 chain: 1,
1985 transactions: [script_tx(sender), script_tx(sender)].into(),
1986 receipts: vec![receipt()],
1987 ..Default::default()
1988 };
1989 deployment.transactions[1].hash = Some(deployment.receipts[0].transaction_hash());
1990 deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
1991 let sequence = ScriptSequenceKind::new_single(deployment, false).unwrap();
1992
1993 assert_eq!(remaining_operation_indices(&sequence, 0), [0]);
1994 }
1995
1996 #[test]
1997 fn duplicate_receipts_do_not_complete_an_unsigned_operation() {
1998 let dir = tempfile::tempdir().unwrap();
1999 let mut deployment = ScriptSequence::<Ethereum> {
2000 chain: 1,
2001 transactions: [planned_tx(SIGNED_TX), planned_tx(OTHER_SIGNED_TX)].into(),
2002 ..Default::default()
2003 };
2004 deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json")));
2005 let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap();
2006 let first_hash =
2007 sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX)).unwrap();
2008 let mut first_receipt = receipt();
2009 first_receipt.transaction_hash = first_hash;
2010 sequence.sequences_mut()[0].receipts = vec![first_receipt.clone(), first_receipt];
2011
2012 assert_eq!(remaining_operation_indices(&sequence, 0), [1]);
2013 }
2014
2015 #[test]
2016 fn completed_prefix_rejects_receipt_holes() {
2017 let hash = B256::repeat_byte(0x44);
2018 let mut later = script_tx(Address::ZERO);
2019 later.hash = Some(hash);
2020 let mut receipt = receipt();
2021 receipt.transaction_hash = hash;
2022 let sequence = ScriptSequence::<Ethereum> {
2023 transactions: [script_tx(Address::ZERO), later].into(),
2024 receipts: vec![receipt],
2025 ..Default::default()
2026 };
2027
2028 assert!(completed_transaction_prefix(&sequence).is_err());
2029 }
2030
2031 #[tokio::test]
2032 async fn access_key_sets_key_id_before_estimation() {
2033 let root_address = Address::repeat_byte(0x11);
2034 let access_key =
2035 foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap();
2036 let access_key_address = access_key.address();
2037 let access_key_wallet =
2038 TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217);
2039 let mut sender = SendTransactionKind::<TempoNetwork>::AccessKey(
2041 TempoTransactionRequest {
2042 inner: TransactionRequest { from: Some(root_address), ..Default::default() },
2043 fee_token: Some(PATH_USD_ADDRESS),
2044 ..Default::default()
2045 },
2046 Box::new(access_key_wallet),
2047 );
2048 let provider =
2049 RootProvider::<TempoNetwork>::new_http("http://localhost:8545".parse().unwrap());
2050
2051 sender.prepare(&provider, false, true, false, 100, None).await.unwrap();
2052
2053 match sender {
2054 SendTransactionKind::AccessKey(tx, _) => {
2055 assert_eq!(tx.key_id, Some(access_key_address));
2056 }
2057 _ => panic!("expected access key transaction"),
2058 }
2059 }
2060
2061 #[test]
2062 fn tempo_aa_create_moves_deployment_into_calls() {
2063 let mut tx = TempoTransactionRequest {
2064 inner: TransactionRequest { to: Some(TxKind::Create), ..Default::default() },
2065 fee_token: Some(address!("0x20c0000000000000000000000000000000000000")),
2066 ..Default::default()
2067 };
2068
2069 convert_tempo_aa_create::<TempoNetwork>(&mut tx);
2070
2071 assert!(tx.inner.to.is_none());
2072 assert_eq!(tx.calls.len(), 1);
2073 assert!(tx.calls[0].to.is_create());
2074 }
2075
2076 #[tokio::test]
2077 async fn signed_tempo_batch_is_bound_to_its_calls() {
2078 let signer = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap();
2079 let sender = signer.address();
2080 let wallet = EthereumWallet::new(signer);
2081 let calls = vec![Call {
2082 to: TxKind::Call(Address::repeat_byte(0x11)),
2083 value: U256::ONE,
2084 input: Bytes::new(),
2085 }];
2086 let request = TempoTransactionRequest {
2087 inner: TransactionRequest {
2088 from: Some(sender),
2089 nonce: Some(0),
2090 chain_id: Some(4217),
2091 gas: Some(21_000),
2092 max_fee_per_gas: Some(1),
2093 max_priority_fee_per_gas: Some(1),
2094 ..Default::default()
2095 },
2096 calls: calls.clone(),
2097 ..Default::default()
2098 };
2099 let payload = request.clone().build(&wallet).await.unwrap().encoded_2718();
2100
2101 assert!(validate_tempo_batch_payload(&request, &payload, sender, 4217, &calls).is_ok());
2102 let other_calls =
2103 vec![Call { to: TxKind::Call(Address::repeat_byte(0x22)), ..calls[0].clone() }];
2104 assert!(
2105 validate_tempo_batch_payload(&request, &payload, sender, 4217, &other_calls).is_err()
2106 );
2107
2108 let other_signer =
2109 foundry_wallets::utils::create_private_key_signer(ACCESS_KEY_PRIVATE_KEY).unwrap();
2110 let mut other_request = request.clone();
2111 other_request.inner.from = Some(other_signer.address());
2112 let other_payload =
2113 other_request.build(&EthereumWallet::new(other_signer)).await.unwrap().encoded_2718();
2114 assert!(
2115 validate_tempo_batch_payload(&request, &other_payload, sender, 4217, &calls).is_err()
2116 );
2117 let other_envelope = TempoTxEnvelope::decode_2718_exact(&other_payload).unwrap();
2118 assert!(validate_tempo_batch_envelope(&other_envelope, sender, 4217, &calls).is_err());
2119 }
2120
2121 #[test]
2122 fn tempo_access_key_create_is_rejected_before_preparation() {
2123 let tx = TempoTransactionRequest {
2124 inner: TransactionRequest { to: Some(TxKind::Create), ..Default::default() },
2125 ..Default::default()
2126 };
2127
2128 let error = reject_access_key_create::<TempoNetwork>(&tx, true).unwrap_err();
2129
2130 assert!(error.to_string().contains("Tempo access-key transactions cannot use CREATE"));
2131 }
2132
2133 #[test]
2134 fn fee_totals_do_not_overflow_u64() {
2135 let gwei = 1_000_000_000;
2136 let receipts = [(30_000_000, 1_000 * gwei), (30_000_000, 2_000 * gwei)];
2137
2138 assert_eq!(
2139 fee_totals(receipts),
2140 (60_000_000, Some(1_500 * gwei), 90_000_000_000_000_000_000)
2141 );
2142 }
2143
2144 #[tokio::test]
2145 async fn batch_receipt_watcher_rejects_foreign_or_incomplete_receipts() {
2146 let hash = B256::repeat_byte(0x42);
2147 let receipt = |field: &str, value: serde_json::Value| {
2148 let mut receipt = serde_json::json!({
2149 "type": "0x02", "status": "0x1", "cumulativeGasUsed": "0x5208", "logs": [],
2150 "transactionHash": hash, "logsBloom": format!("{:#x}", Bloom::ZERO),
2151 "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x10",
2152 "gasUsed": "0x5208", "effectiveGasPrice": "0x1",
2153 "from": Address::ZERO, "to": Address::ZERO, "contractAddress": null
2154 });
2155 receipt[field] = value;
2156 receipt
2157 };
2158 let wait = async |receipt: serde_json::Value, next: serde_json::Value| {
2160 let asserter = Asserter::new();
2161 let provider: RootProvider<Ethereum> =
2162 alloy_provider::ProviderBuilder::default().connect_mocked_client(asserter.clone());
2163 asserter.push_success(&receipt);
2164 asserter.push_success(&next);
2165 wait_for_batch_receipt(&provider, hash, 1).await.unwrap()
2166 };
2167
2168 for (field, value) in [
2169 ("transactionHash", serde_json::json!(B256::repeat_byte(0x99))),
2170 ("blockHash", serde_json::Value::Null),
2171 ("transactionIndex", serde_json::Value::Null),
2172 ("blockNumber", serde_json::Value::Null),
2173 ] {
2174 let result = wait(receipt(field, value), serde_json::Value::Null).await;
2175 assert!(result.is_none(), "accepted receipt with bad {field}");
2176 }
2177 let accepted =
2178 wait(receipt("status", serde_json::json!("0x1")), serde_json::json!("0x10")).await;
2179 let accepted = accepted.unwrap();
2180 assert_eq!(accepted.transaction_hash(), hash);
2181 }
2182
2183 fn script_tx(from: Address) -> TransactionWithMetadata<Ethereum> {
2184 TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(TransactionRequest {
2185 from: Some(from),
2186 ..Default::default()
2187 }))
2188 }
2189
2190 fn planned_tx(payload: &[u8]) -> TransactionWithMetadata<Ethereum> {
2191 let envelope = TxEnvelope::decode_2718_exact(payload).unwrap();
2192 let from = envelope.recover_signer().unwrap();
2193 let mut request: TransactionRequest = envelope.into();
2194 request.from = Some(from);
2195 TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request))
2196 }
2197
2198 fn receipt() -> TransactionReceipt {
2199 TransactionReceipt {
2200 inner: ReceiptEnvelope::Legacy(ReceiptWithBloom {
2201 receipt: Receipt {
2202 status: Eip658Value::success(),
2203 cumulative_gas_used: 0,
2204 logs: vec![],
2205 },
2206 logs_bloom: Bloom::ZERO,
2207 }),
2208 transaction_hash: Default::default(),
2209 transaction_index: None,
2210 block_hash: None,
2211 block_number: None,
2212 gas_used: 0,
2213 effective_gas_price: 0,
2214 blob_gas_used: None,
2215 blob_gas_price: None,
2216 from: Address::ZERO,
2217 to: None,
2218 contract_address: None,
2219 }
2220 }
2221
2222 #[tokio::test]
2223 async fn preparation_preserves_signed_payload_without_rpc() {
2224 let envelope = TxEnvelope::decode_2718_exact(SIGNED_TX).unwrap();
2225 let hash = envelope.trie_hash();
2226 let provider = alloy_provider::ProviderBuilder::new()
2227 .disable_recommended_fillers()
2228 .connect_mocked_client(Asserter::new());
2229 for input in [
2230 SendTransactionKind::<Ethereum>::Signed(envelope),
2231 SendTransactionKind::PreparedRaw(Bytes::from_static(SIGNED_TX), hash),
2232 ] {
2233 let prepared = input
2234 .prepare_for_durable_send(&provider, true, false, true, 130, None)
2235 .await
2236 .unwrap();
2237 let PreparedTransaction::Raw(payload, prepared_hash) = prepared else {
2238 panic!("signed transactions must become fixed raw submissions");
2239 };
2240 assert_eq!(payload.as_ref(), SIGNED_TX);
2241 assert_eq!(prepared_hash, hash);
2242 }
2243 }
2244}