Skip to main content

cast/cmd/
batch_send.rs

1//! `cast batch-send` command implementation.
2//!
3//! Sends a batch of calls as a single Tempo transaction using native call batching.
4//! Unlike upstream Foundry's sequential transactions, this uses a single type 0x76
5//! transaction with multiple calls executed atomically.
6
7use crate::{
8    call_spec::CallSpec,
9    cmd::send::{cast_send, cast_send_with_access_key},
10    tempo,
11    tx::{self, CastTxBuilder, SendTxOpts},
12};
13use alloy_network::{EthereumWallet, TransactionBuilder};
14use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
15use alloy_signer::Signer;
16use clap::Parser;
17use eyre::{Result, eyre};
18use foundry_cli::{
19    opts::TransactionOpts,
20    utils::{self, LoadConfig, maybe_print_resolved_lane, resolve_lane},
21};
22use foundry_common::provider::ProviderBuilder;
23use std::time::Duration;
24use tempo_alloy::TempoNetwork;
25
26/// CLI arguments for `cast batch-send`.
27///
28/// Sends multiple calls as a single atomic Tempo transaction.
29#[derive(Debug, Parser)]
30pub struct BatchSendArgs {
31    /// Call specifications in format: `to[:<value>][:<sig>[:<args>]]` or `to[:<value>][:<0xdata>]`
32    ///
33    /// Examples:
34    ///   --call "0x123:0.1ether" (ETH transfer)
35    ///   --call "0x456::transfer(address,uint256):0x789,1000" (ERC20 transfer)
36    ///   --call "0xabc::0x123def" (raw calldata)
37    ///   --call "0x123:1ether:deposit()" (value + function call)
38    #[arg(long = "call", value_name = "SPEC", required = true)]
39    pub calls: Vec<String>,
40
41    #[command(flatten)]
42    pub send_tx: SendTxOpts,
43
44    #[command(flatten)]
45    pub tx: TransactionOpts,
46
47    /// Send via `eth_sendTransaction` using the `--from` argument or $ETH_FROM as sender
48    #[arg(long, requires = "from")]
49    pub unlocked: bool,
50}
51
52impl BatchSendArgs {
53    pub async fn run(self) -> Result<()> {
54        let Self { calls, send_tx, mut tx, unlocked } = self;
55        let has_session = tx.tempo.session_id()?.is_some();
56        // Tempo sessions must sign with the session key; these modes route signing through a
57        // node-managed account or browser wallet instead.
58        if has_session && unlocked {
59            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked");
60        }
61        if has_session && send_tx.browser.browser {
62            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser");
63        }
64
65        let expires_at = tx.tempo.resolve_expires();
66
67        if calls.is_empty() {
68            return Err(eyre!("No calls specified. Use --call to specify at least one call."));
69        }
70
71        let config = send_tx.eth.load_config()?;
72        let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
73
74        // Resolve `--tempo.lane <name>` against the lanes file (default
75        // `<root>/tempo.lanes.toml`) and populate `tx.tempo.nonce_key` from the lane.
76        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
77
78        if let Some(interval) = send_tx.poll_interval {
79            provider.client().set_poll_interval(Duration::from_secs(interval))
80        }
81
82        // Resolve signer to detect keychain mode. Tempo sessions are resolved after chain lookup
83        // so they can fail closed on wrong-chain session use.
84        let (mut signer, mut tempo_access_key) =
85            if has_session { (None, None) } else { send_tx.eth.wallet.maybe_signer().await? };
86
87        // Parse all call specs
88        let call_specs: Vec<CallSpec> =
89            calls.iter().map(|s| CallSpec::parse(s)).collect::<Result<Vec<_>>>()?;
90
91        // Get chain for parsing function args
92        let chain = utils::get_chain(config.chain, &provider).await?;
93        if has_session
94            && let Some(session) =
95                tx.tempo.session_signer_for_wallet(&send_tx.eth.wallet, chain.id())?
96        {
97            (signer, tempo_access_key) = (Some(session.signer), Some(session.access_key));
98        }
99
100        let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten();
101        let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone());
102        let etherscan_api_url = etherscan_config.map(|c| c.api_url);
103
104        // Build Vec<Call> from specs
105        let mut tempo_calls = Vec::with_capacity(call_specs.len());
106        for (i, spec) in call_specs.iter().enumerate() {
107            tempo_calls.push(
108                spec.resolve(
109                    i,
110                    chain,
111                    &provider,
112                    etherscan_api_key.as_deref(),
113                    etherscan_api_url.as_deref(),
114                )
115                .await?,
116            );
117        }
118
119        sh_status!("Building batch transaction with {} call(s)...", tempo_calls.len())?;
120        tempo::print_expires(expires_at)?;
121
122        // Preserve key_id for modes that do not call build_with_access_key, such as unlocked.
123        if let Some(ref access_key) = tempo_access_key {
124            tx.tempo.key_id = Some(access_key.key_address);
125        }
126
127        // Build transaction request with calls
128        let mut builder = CastTxBuilder::<TempoNetwork, _, _>::new(&provider, tx, &config).await?;
129
130        // Access the inner tx and set calls
131        builder.tx.calls = tempo_calls;
132
133        // We need to set a dummy "to" to satisfy the state machine, but the calls field
134        // will be used by build_aa. Set to first call's target.
135        let first_call_to = call_specs.first().map(|s| s.to);
136        let builder = builder.with_to(first_call_to.map(Into::into)).await?;
137
138        // Use empty sig/args since we're using calls directly
139        let builder = builder.with_code_sig_and_args(None, None, vec![]).await?;
140
141        let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
142
143        if unlocked {
144            let (tx, _) = builder.build(config.sender).await?;
145            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
146            cast_send(
147                provider,
148                tx,
149                Some(chain),
150                send_tx.cast_async,
151                send_tx.sync,
152                send_tx.confirmations,
153                timeout,
154            )
155            .await
156            .map(drop)
157        } else {
158            let signer = match signer {
159                Some(s) => s,
160                None => send_tx.eth.wallet.signer().await?,
161            };
162
163            if let Some(ref access_key) = tempo_access_key {
164                let (tx_request, _) =
165                    builder.build_with_access_key(access_key.wallet_address, access_key).await?;
166                maybe_print_resolved_lane(
167                    resolved_lane.as_ref(),
168                    tx_request.nonce().unwrap_or_default(),
169                )?;
170                cast_send_with_access_key(
171                    &provider,
172                    tx_request,
173                    &signer,
174                    access_key,
175                    Some(chain),
176                    send_tx.cast_async,
177                    send_tx.confirmations,
178                    timeout,
179                )
180                .await?;
181            } else {
182                tx::validate_from_address(send_tx.eth.wallet.from, Signer::address(&signer))?;
183                let (tx_request, _) = builder.build(&signer).await?;
184                maybe_print_resolved_lane(
185                    resolved_lane.as_ref(),
186                    tx_request.nonce().unwrap_or_default(),
187                )?;
188                let wallet = EthereumWallet::from(signer);
189                let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
190                    .wallet(wallet)
191                    .connect_provider(&provider);
192
193                cast_send(
194                    provider,
195                    tx_request,
196                    Some(chain),
197                    send_tx.cast_async,
198                    send_tx.sync,
199                    send_tx.confirmations,
200                    timeout,
201                )
202                .await?;
203            }
204
205            Ok(())
206        }
207    }
208}