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