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