Skip to main content

cast/cmd/
batch_mktx.rs

1//! `cast batch-mktx` command implementation.
2//!
3//! Creates a signed or unsigned batch transaction using Tempo's native call batching.
4//! Outputs the RLP-encoded transaction hex.
5
6use crate::{
7    call_spec::CallSpec,
8    cmd::auth::{confirm_auth_rpc_disclosure, confirm_auth_rpc_disclosure_during_build},
9    tempo,
10    tx::{self, CastTxBuilder},
11};
12use alloy_consensus::SignableTransaction;
13use alloy_eips::eip2718::Encodable2718;
14use alloy_network::{EthereumWallet, NetworkTransactionBuilder, TransactionBuilder};
15use alloy_primitives::Address;
16use alloy_provider::Provider;
17use alloy_signer::Signer;
18use clap::Parser;
19use eyre::{Result, eyre};
20use foundry_cli::{
21    opts::{EthereumOpts, TempoOpts, TransactionOpts},
22    utils::{self, LoadConfig, maybe_print_resolved_lane, resolve_lane},
23};
24use foundry_common::{
25    FoundryTransactionBuilder,
26    provider::ProviderBuilder,
27    tempo::{maybe_print_fee_token, resolve_and_set_fee_token},
28};
29use foundry_wallets::{TempoAccountsWallet, WalletOpts, WalletSigner};
30use tempo_alloy::TempoNetwork;
31
32/// CLI arguments for `cast batch-mktx`.
33///
34/// Creates a signed (or unsigned) batch transaction.
35#[derive(Debug, Parser)]
36pub struct BatchMakeTxArgs {
37    /// Call specifications in format: `to[:<value>][:<sig>[:<args>]]` or `to[:<value>][:<0xdata>]`
38    ///
39    /// Examples:
40    ///   --call "0x123:0.1ether" (ETH transfer)
41    ///   --call "0x456::transfer(address,uint256):0x789,1000" (ERC20 transfer)
42    ///   --call "0xabc::0x123def" (raw calldata)
43    #[arg(long = "call", value_name = "SPEC", required = true)]
44    pub calls: Vec<String>,
45
46    #[command(flatten)]
47    pub tx: TransactionOpts,
48
49    /// Skip the EIP-7702 authorization disclosure confirmation.
50    #[arg(long)]
51    pub force: bool,
52
53    #[command(flatten)]
54    pub eth: EthereumOpts,
55
56    /// Generate a raw RLP-encoded unsigned transaction.
57    #[arg(long)]
58    pub raw_unsigned: bool,
59
60    /// Call `eth_signTransaction` using the `--from` argument or $ETH_FROM as sender
61    #[arg(long, requires = "from", conflicts_with = "raw_unsigned")]
62    pub ethsign: bool,
63}
64
65impl BatchMakeTxArgs {
66    pub async fn run(self) -> Result<()> {
67        let Self { calls, mut tx, force, eth, raw_unsigned, ethsign } = self;
68        let has_nonce = tx.nonce.is_some();
69        let has_session = tx.tempo.session_id()?.is_some();
70        let expires_at = tx.tempo.resolve_expires();
71
72        if calls.is_empty() {
73            return Err(eyre!("No calls specified. Use --call to specify at least one call."));
74        }
75
76        if has_session && raw_unsigned {
77            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --raw-unsigned");
78        }
79        if has_session && ethsign {
80            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --ethsign");
81        }
82
83        let config = eth.load_config()?;
84        let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
85
86        // Resolve `--tempo.lane <name>` against the lanes file (default
87        // `<root>/tempo.lanes.toml`) and populate `tx.tempo.nonce_key` from the lane.
88        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
89
90        // Parse all call specs
91        let call_specs: Vec<CallSpec> =
92            calls.iter().map(|s| CallSpec::parse(s)).collect::<Result<Vec<_>>>()?;
93
94        // Get chain for parsing function args
95        let chain = utils::get_chain(config.chain, &provider).await?;
96        let (signer, tempo_access_key) =
97            resolve_signer(&tx.tempo, &eth.wallet, chain.id(), raw_unsigned).await?;
98        let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten();
99        let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone());
100        let etherscan_api_url = etherscan_config.map(|c| c.api_url);
101
102        let mut tempo_calls = Vec::with_capacity(call_specs.len());
103        for (i, spec) in call_specs.iter().enumerate() {
104            tempo_calls.push(
105                spec.resolve(
106                    i,
107                    chain,
108                    &provider,
109                    etherscan_api_key.as_deref(),
110                    etherscan_api_url.as_deref(),
111                )
112                .await?,
113            );
114        }
115
116        sh_status!("Building batch transaction with {} call(s)...", tempo_calls.len())?;
117        tempo::print_expires(expires_at)?;
118
119        // Preserve key_id for modes that do not call build_with_tempo_wallet, such as raw unsigned.
120        if let Some(ref access_key) = tempo_access_key {
121            tx.tempo.key_id = Some(access_key.key_id()?);
122        }
123
124        // Build transaction request with calls
125        let mut builder = CastTxBuilder::<TempoNetwork, _, _>::new(&provider, tx, &config).await?;
126
127        // Set calls on the transaction
128        builder.tx.calls = tempo_calls;
129
130        // Set dummy "to" from first call
131        let first_call_to = call_specs.first().map(|s| s.to);
132        let builder = builder.with_to(first_call_to.map(Into::into)).await?;
133        let tx_builder = builder.with_code_sig_and_args(None, None, vec![]).await?;
134
135        if raw_unsigned {
136            if eth.wallet.from.is_none() && !has_nonce {
137                eyre::bail!(
138                    "Missing required parameters for raw unsigned transaction. When --from is not provided, you must specify: --nonce"
139                );
140            }
141
142            let from = eth.wallet.from.unwrap_or(Address::ZERO);
143            if !confirm_auth_rpc_disclosure_during_build(&tx_builder, from, force)? {
144                return Ok(());
145            }
146            let (mut tx, _) = tx_builder.build(from).await?;
147            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
148            let fee_token = resolve_and_set_fee_token(
149                (!config.eth_rpc_curl).then_some(&provider),
150                Some(chain),
151                &mut tx,
152                Some(from),
153            )
154            .await?;
155            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
156            let raw_tx =
157                alloy_primitives::hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing());
158            sh_println!("{raw_tx}")?;
159            return Ok(());
160        }
161
162        if ethsign {
163            let sender = config.sender.into();
164            if tx_builder.has_auth() && !confirm_auth_rpc_disclosure(&tx_builder, &sender, force)? {
165                return Ok(());
166            }
167            let (mut tx, _) = tx_builder.build(config.sender).await?;
168            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
169            let fee_token = resolve_and_set_fee_token(
170                (!config.eth_rpc_curl).then_some(&provider),
171                Some(chain),
172                &mut tx,
173                Some(config.sender),
174            )
175            .await?;
176            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
177            let signed_tx = provider.sign_transaction(tx).await?;
178            sh_println!("{signed_tx}")?;
179            return Ok(());
180        }
181
182        let signed_tx = if let Some(ref access_key) = tempo_access_key {
183            if !confirm_auth_rpc_disclosure_during_build(&tx_builder, access_key.account(), force)?
184            {
185                return Ok(());
186            }
187            let (mut tx, _, prepared) = tx_builder.build_with_tempo_wallet(access_key).await?;
188            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
189            let fee_token = resolve_and_set_fee_token(
190                (!config.eth_rpc_curl).then_some(&provider),
191                Some(chain),
192                &mut tx,
193                Some(prepared.account()),
194            )
195            .await?;
196            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
197            let raw_tx = tx.sign_with_tempo_wallet(&prepared).await?;
198            alloy_primitives::hex::encode(raw_tx)
199        } else {
200            let signer = match signer {
201                Some(s) => s,
202                None => eth.wallet.signer().await?,
203            };
204            tx::validate_from_address(eth.wallet.from, Signer::address(&signer))?;
205            if !confirm_auth_rpc_disclosure_during_build(&tx_builder, &signer, force)? {
206                return Ok(());
207            }
208            let (mut tx, _) = tx_builder.build(&signer).await?;
209            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
210            let fee_token = resolve_and_set_fee_token(
211                (!config.eth_rpc_curl).then_some(&provider),
212                Some(chain),
213                &mut tx,
214                Some(Signer::address(&signer)),
215            )
216            .await?;
217            maybe_print_fee_token((!config.eth_rpc_curl).then_some(&provider), fee_token).await?;
218            let envelope = tx.build(&EthereumWallet::new(signer)).await?;
219            alloy_primitives::hex::encode(envelope.encoded_2718())
220        };
221
222        sh_println!("0x{signed_tx}")?;
223
224        Ok(())
225    }
226}
227
228async fn resolve_signer(
229    tempo: &TempoOpts,
230    wallet: &WalletOpts,
231    chain_id: u64,
232    raw_unsigned: bool,
233) -> Result<(Option<WalletSigner>, Option<TempoAccountsWallet>)> {
234    if raw_unsigned {
235        let (_, access_key) = wallet.maybe_signer_for_chain(chain_id).await?;
236        return Ok((None, access_key));
237    }
238
239    tempo::resolve_session_or_wallet_signer(tempo, wallet, chain_id).await
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use alloy_primitives::address;
246
247    #[test]
248    fn raw_unsigned_resolver_discards_signer_but_keeps_access_key_metadata() {
249        let runtime = tokio::runtime::Runtime::new().unwrap();
250        runtime.block_on(async {
251            let wallet = WalletOpts {
252                tempo_access_key: Some(
253                    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
254                        .to_string(),
255                ),
256                tempo_root_account: Some(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")),
257                ..Default::default()
258            };
259
260            let (signer, access_key) =
261                resolve_signer(&TempoOpts::default(), &wallet, 31337, true).await.unwrap();
262
263            assert!(signer.is_none());
264            let access_key = access_key.expect("access-key metadata");
265            assert_eq!(
266                access_key.account(),
267                address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
268            );
269            assert_eq!(
270                access_key.key_id().unwrap(),
271                address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8")
272            );
273        });
274    }
275}