Skip to content

Commit 33c827e

Browse files
authored
feat(script): add --batch flag for Tempo native batching (foundry-rs#14167)
1 parent da85e55 commit 33c827e

4 files changed

Lines changed: 350 additions & 22 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/script/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ alloy-eips.workspace = true
5656
alloy-consensus.workspace = true
5757
thiserror.workspace = true
5858

59+
tempo-alloy.workspace = true
60+
tempo-primitives.workspace = true
5961

6062
[dev-dependencies]
6163
tempfile.workspace = true

crates/script/src/broadcast.rs

Lines changed: 291 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ use alloy_consensus::{SignableTransaction, Signed};
99
use alloy_eips::{BlockId, eip2718::Encodable2718};
1010
use alloy_network::{EthereumWallet, Network, ReceiptResponse, TransactionBuilder};
1111
use alloy_primitives::{
12-
Address, TxHash,
12+
Address, TxHash, TxKind, U256,
1313
map::{AddressHashMap, AddressHashSet},
1414
utils::format_units,
1515
};
1616
use alloy_provider::{Provider, RootProvider, utils::Eip1559Estimation};
17+
use alloy_rpc_types::TransactionRequest;
1718
use alloy_signer::Signature;
1819
use eyre::{Context, Result, bail};
1920
use forge_verify::provider::VerificationProviderType;
@@ -25,10 +26,16 @@ use foundry_common::{
2526
shell,
2627
};
2728
use foundry_config::Config;
28-
use foundry_evm::core::evm::FoundryEvmNetwork;
29-
use foundry_wallets::{TempoAccessKeyConfig, WalletSigner, wallet_browser::signer::BrowserSigner};
29+
use foundry_evm::core::evm::{FoundryEvmNetwork, TempoEvmNetwork};
30+
use foundry_wallets::{
31+
TempoAccessKeyConfig, WalletSigner,
32+
tempo::{TempoLookup, lookup_signer},
33+
wallet_browser::signer::BrowserSigner,
34+
};
3035
use futures::{FutureExt, StreamExt, future::join_all, stream::FuturesUnordered};
3136
use itertools::Itertools;
37+
use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest};
38+
use tempo_primitives::transaction::Call;
3239

3340
pub async fn estimate_gas<N: Network, P: Provider<N>>(
3441
tx: &mut N::TransactionRequest,
@@ -345,8 +352,8 @@ impl<FEN: FoundryEvmNetwork> BundledState<FEN> {
345352

346353
for addr in &required_addresses {
347354
if !signers.contains(addr) {
348-
match foundry_wallets::tempo::lookup_signer(*addr) {
349-
Ok(foundry_wallets::tempo::TempoLookup::Keychain(signer, config)) => {
355+
match lookup_signer(*addr) {
356+
Ok(TempoLookup::Keychain(signer, config)) => {
350357
access_keys.insert(*addr, (signer, *config));
351358
}
352359
_ => {
@@ -492,8 +499,9 @@ impl<FEN: FoundryEvmNetwork> BundledState<FEN> {
492499
|| required_addresses.len() != 1
493500
|| !has_batch_support(sequence.chain);
494501

495-
// We send transactions and wait for receipts in batches.
496-
let batch_size = if sequential_broadcast { 1 } else { self.args.batch_size };
502+
// We send transactions and wait for receipts in batches of 100, since some networks
503+
// cannot handle more than that.
504+
let batch_size = if sequential_broadcast { 1 } else { 100 };
497505
let mut index = already_broadcasted;
498506

499507
for (batch_number, batch) in transactions.chunks(batch_size).enumerate() {
@@ -640,3 +648,279 @@ impl<FEN: FoundryEvmNetwork> BundledState<FEN> {
640648
Ok(())
641649
}
642650
}
651+
652+
impl BundledState<TempoEvmNetwork> {
653+
/// Broadcasts all transactions as a single Tempo batch transaction (type 0x76).
654+
///
655+
/// This method collects all individual transactions from the script and combines them
656+
/// into a single batch transaction for atomic execution on Tempo.
657+
pub async fn broadcast_batch(mut self) -> Result<BroadcastedState<TempoEvmNetwork>> {
658+
// Batch mode only supports single chain for now
659+
if self.sequence.sequences().len() != 1 {
660+
bail!(
661+
"--batch mode only supports single-chain scripts. \
662+
Use --multi without --batch for multi-chain."
663+
);
664+
}
665+
666+
let sequence = self.sequence.sequences_mut().get_mut(0).unwrap();
667+
let provider = Arc::new(ProviderBuilder::<TempoNetwork>::new(sequence.rpc_url()).build()?);
668+
669+
// Collect sender addresses - batch mode requires single sender
670+
let senders: AddressHashSet = sequence
671+
.transactions()
672+
.filter(|tx| tx.is_unsigned())
673+
.filter_map(|tx| tx.from())
674+
.collect();
675+
676+
if senders.len() != 1 {
677+
bail!(
678+
"--batch mode requires all transactions to have the same sender. \
679+
Found {} unique senders: {:?}",
680+
senders.len(),
681+
senders
682+
);
683+
}
684+
685+
let sender = *senders.iter().next().unwrap();
686+
687+
if sender == Config::DEFAULT_SENDER {
688+
bail!(
689+
"You seem to be using Foundry's default sender. Be sure to set your own --sender."
690+
);
691+
}
692+
693+
// Get wallet for signing
694+
enum BatchSigner {
695+
Unlocked,
696+
Wallet(EthereumWallet),
697+
TempoKeychain(Box<WalletSigner>, Box<TempoAccessKeyConfig>),
698+
}
699+
700+
let batch_signer = if self.args.unlocked {
701+
BatchSigner::Unlocked
702+
} else {
703+
let mut signers = self.script_wallets.into_multi_wallet().into_signers()?;
704+
if let Some(signer) = signers.remove(&sender) {
705+
BatchSigner::Wallet(EthereumWallet::new(signer))
706+
} else {
707+
// Try Tempo keys.toml fallback
708+
match lookup_signer(sender)? {
709+
TempoLookup::Direct(signer) => BatchSigner::Wallet(EthereumWallet::new(signer)),
710+
TempoLookup::Keychain(signer, config) => {
711+
BatchSigner::TempoKeychain(Box::new(signer), config)
712+
}
713+
TempoLookup::NotFound => {
714+
bail!("No wallet found for sender {}", sender);
715+
}
716+
}
717+
}
718+
};
719+
720+
// Collect all transactions into Call structs
721+
// Tempo batch transactions support CREATE only as the first call
722+
let mut calls: Vec<Call> = Vec::new();
723+
let mut has_create = false;
724+
for (idx, tx) in sequence.transactions().enumerate() {
725+
let to = match tx.to() {
726+
Some(addr) => TxKind::Call(addr),
727+
None => {
728+
if idx > 0 {
729+
bail!(
730+
"Contract creation must be the first transaction in --batch mode. \
731+
Found CREATE at position {}. Reorder your script or deploy separately.",
732+
idx + 1
733+
);
734+
}
735+
if has_create {
736+
bail!("Only one contract creation is allowed per --batch transaction.");
737+
}
738+
has_create = true;
739+
TxKind::Create
740+
}
741+
};
742+
let value = tx.value().unwrap_or(U256::ZERO);
743+
let input = tx.input().cloned().unwrap_or_default();
744+
745+
calls.push(Call { to, value, input });
746+
}
747+
748+
if calls.is_empty() {
749+
sh_println!("No transactions to broadcast in batch mode.")?;
750+
return Ok(BroadcastedState {
751+
args: self.args,
752+
script_config: self.script_config,
753+
build_data: self.build_data,
754+
sequence: self.sequence,
755+
});
756+
}
757+
758+
sh_println!(
759+
"\n## Broadcasting batch transaction with {} call(s) to chain {}...",
760+
calls.len(),
761+
sequence.chain
762+
)?;
763+
764+
// Build the batch transaction request
765+
let nonce = provider.get_transaction_count(sender).await?;
766+
let chain_id = sequence.chain;
767+
768+
// Get gas prices - batch transactions are Tempo-only, always use EIP-1559 style fees
769+
let fees = provider.estimate_eip1559_fees().await?;
770+
let max_fee_per_gas =
771+
self.args.with_gas_price.map(|p| p.to()).unwrap_or(fees.max_fee_per_gas);
772+
let max_priority_fee_per_gas =
773+
self.args.priority_gas_price.map(|p| p.to()).unwrap_or(fees.max_priority_fee_per_gas);
774+
775+
let mut batch_tx = TempoTransactionRequest {
776+
inner: TransactionRequest {
777+
from: Some(sender),
778+
to: None,
779+
value: None,
780+
input: Default::default(),
781+
nonce: Some(nonce),
782+
chain_id: Some(chain_id),
783+
max_fee_per_gas: Some(max_fee_per_gas),
784+
max_priority_fee_per_gas: Some(max_priority_fee_per_gas),
785+
..Default::default()
786+
},
787+
calls: calls.clone(),
788+
..Default::default()
789+
};
790+
791+
// Estimate gas for the batch transaction
792+
estimate_gas(&mut batch_tx, provider.as_ref(), self.args.gas_estimate_multiplier).await?;
793+
794+
sh_println!("Estimated gas: {}", batch_tx.inner.gas.unwrap_or(0))?;
795+
796+
// Sign and send
797+
let tx_hash = match batch_signer {
798+
BatchSigner::Wallet(wallet) => {
799+
let provider_with_wallet =
800+
alloy_provider::ProviderBuilder::<_, _, TempoNetwork>::default()
801+
.wallet(wallet)
802+
.connect_provider(provider.as_ref());
803+
804+
let pending = provider_with_wallet.send_transaction(batch_tx).await?;
805+
*pending.tx_hash()
806+
}
807+
BatchSigner::TempoKeychain(signer, access_key) => {
808+
batch_tx.key_id = Some(access_key.key_address);
809+
810+
if let Some(ref auth) = access_key.key_authorization {
811+
batch_tx.key_authorization = Some(auth.clone());
812+
}
813+
814+
// Strip key_authorization if the key is already provisioned (saves gas)
815+
if batch_tx.key_authorization.is_some() {
816+
use tempo_alloy::provider::TempoProviderExt;
817+
let key_info = provider
818+
.get_keychain_key(access_key.wallet_address, access_key.key_address)
819+
.await;
820+
if key_info.map(|info| info.keyId != Address::ZERO).unwrap_or(false) {
821+
batch_tx.key_authorization = None;
822+
}
823+
}
824+
825+
let raw_tx =
826+
batch_tx.sign_with_access_key(&*signer, access_key.wallet_address).await?;
827+
828+
let pending = provider.send_raw_transaction(&raw_tx).await?;
829+
*pending.tx_hash()
830+
}
831+
BatchSigner::Unlocked => {
832+
let pending = provider.send_transaction(batch_tx).await?;
833+
*pending.tx_hash()
834+
}
835+
};
836+
837+
sh_println!("Batch transaction sent: {:#x}", tx_hash)?;
838+
839+
// Wait for receipt
840+
let timeout = self.script_config.config.transaction_timeout;
841+
let receipt = tokio::time::timeout(Duration::from_secs(timeout), async {
842+
loop {
843+
if let Some(receipt) = provider.get_transaction_receipt(tx_hash).await? {
844+
return Ok::<_, eyre::Error>(receipt);
845+
}
846+
tokio::time::sleep(Duration::from_millis(500)).await;
847+
}
848+
})
849+
.await
850+
.map_err(|_| eyre::eyre!("Timeout waiting for batch transaction receipt"))??;
851+
852+
let success = receipt.status();
853+
if success {
854+
sh_println!(
855+
"Batch transaction confirmed in block {}",
856+
receipt.block_number.unwrap_or(0)
857+
)?;
858+
} else {
859+
bail!("Batch transaction failed (reverted)");
860+
}
861+
862+
// For CREATE transactions, compute the deployed contract address
863+
let created_address = if has_create {
864+
let deployed_addr = sender.create(nonce);
865+
sh_println!("Contract deployed at: {:#x}", deployed_addr)?;
866+
Some(deployed_addr)
867+
} else {
868+
None
869+
};
870+
871+
// Add receipt to sequence for each original transaction.
872+
// In batch mode, all calls share the same receipt. Set contract_address
873+
// only for index 0 if CREATE, clear for the rest to prevent the verifier
874+
// from attempting to verify the same address multiple times.
875+
for idx in 0..calls.len() {
876+
let mut tx_receipt = receipt.clone();
877+
if idx == 0 && has_create {
878+
tx_receipt.contract_address = created_address;
879+
} else {
880+
tx_receipt.contract_address = None;
881+
}
882+
sequence.receipts.push(tx_receipt);
883+
}
884+
885+
// Mark all transactions as pending with the batch tx hash
886+
for i in 0..sequence.transactions.len() {
887+
sequence.add_pending(i, tx_hash);
888+
}
889+
890+
let chain = sequence.chain;
891+
let _ = sequence;
892+
893+
self.sequence.save(true, false)?;
894+
895+
let total_gas = receipt.gas_used();
896+
let gas_price = receipt.effective_gas_price() as u64;
897+
let total_paid = total_gas * gas_price;
898+
let paid = format_units(total_paid, 18).unwrap_or_else(|_| "N/A".to_string());
899+
let gas_price_gwei = format_units(gas_price, 9).unwrap_or_else(|_| "N/A".to_string());
900+
901+
let token_symbol = NamedChain::try_from(chain)
902+
.unwrap_or_default()
903+
.native_currency_symbol()
904+
.unwrap_or("ETH");
905+
sh_println!(
906+
"\nTotal Paid: {} {} ({} gas * {} gwei)",
907+
paid.trim_end_matches('0'),
908+
token_symbol,
909+
total_gas,
910+
gas_price_gwei.trim_end_matches('0').trim_end_matches('.')
911+
)?;
912+
913+
if !shell::is_json() {
914+
sh_println!("\n\n==========================")?;
915+
sh_println!("\nBATCH EXECUTION COMPLETE & SUCCESSFUL.")?;
916+
sh_println!("All {} calls executed atomically in a single transaction.", calls.len())?;
917+
}
918+
919+
Ok(BroadcastedState {
920+
args: self.args,
921+
script_config: self.script_config,
922+
build_data: self.build_data,
923+
sequence: self.sequence,
924+
})
925+
}
926+
}

0 commit comments

Comments
 (0)