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, provider::ProviderBuilder, tempo::print_resolved_fee_token_selection,
25};
26use foundry_wallets::{TempoAccessKeyConfig, WalletOpts, WalletSigner};
27use tempo_alloy::TempoNetwork;
28
29/// CLI arguments for `cast batch-mktx`.
30///
31/// Creates a signed (or unsigned) batch transaction.
32#[derive(Debug, Parser)]
33pub struct BatchMakeTxArgs {
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    #[arg(long = "call", value_name = "SPEC", required = true)]
41    pub calls: Vec<String>,
42
43    #[command(flatten)]
44    pub tx: TransactionOpts,
45
46    #[command(flatten)]
47    pub eth: EthereumOpts,
48
49    /// Generate a raw RLP-encoded unsigned transaction.
50    #[arg(long)]
51    pub raw_unsigned: bool,
52
53    /// Call `eth_signTransaction` using the `--from` argument or $ETH_FROM as sender
54    #[arg(long, requires = "from", conflicts_with = "raw_unsigned")]
55    pub ethsign: bool,
56}
57
58impl BatchMakeTxArgs {
59    pub async fn run(self) -> Result<()> {
60        let Self { calls, mut tx, eth, raw_unsigned, ethsign } = self;
61        let has_nonce = tx.nonce.is_some();
62        let has_session = tx.tempo.session_id()?.is_some();
63        let expires_at = tx.tempo.resolve_expires();
64
65        if calls.is_empty() {
66            return Err(eyre!("No calls specified. Use --call to specify at least one call."));
67        }
68
69        if has_session && raw_unsigned {
70            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --raw-unsigned");
71        }
72        if has_session && ethsign {
73            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --ethsign");
74        }
75
76        let config = eth.load_config()?;
77        let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
78
79        // Resolve `--tempo.lane <name>` against the lanes file (default
80        // `<root>/tempo.lanes.toml`) and populate `tx.tempo.nonce_key` from the lane.
81        let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?;
82
83        // Parse all call specs
84        let call_specs: Vec<CallSpec> =
85            calls.iter().map(|s| CallSpec::parse(s)).collect::<Result<Vec<_>>>()?;
86
87        // Get chain for parsing function args
88        let chain = utils::get_chain(config.chain, &provider).await?;
89        let (signer, tempo_access_key) =
90            resolve_signer(&tx.tempo, &eth.wallet, chain.id(), raw_unsigned).await?;
91        let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten();
92        let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone());
93        let etherscan_api_url = etherscan_config.map(|c| c.api_url);
94
95        let mut tempo_calls = Vec::with_capacity(call_specs.len());
96        for (i, spec) in call_specs.iter().enumerate() {
97            tempo_calls.push(
98                spec.resolve(
99                    i,
100                    chain,
101                    &provider,
102                    etherscan_api_key.as_deref(),
103                    etherscan_api_url.as_deref(),
104                )
105                .await?,
106            );
107        }
108
109        sh_status!("Building batch transaction with {} call(s)...", tempo_calls.len())?;
110        tempo::print_expires(expires_at)?;
111
112        // Preserve key_id for modes that do not call build_with_access_key, such as raw unsigned.
113        if let Some(ref access_key) = tempo_access_key {
114            tx.tempo.key_id = Some(access_key.key_address);
115        }
116
117        // Build transaction request with calls
118        let mut builder = CastTxBuilder::<TempoNetwork, _, _>::new(&provider, tx, &config).await?;
119
120        // Set calls on the transaction
121        builder.tx.calls = tempo_calls;
122
123        // Set dummy "to" from first call
124        let first_call_to = call_specs.first().map(|s| s.to);
125        let builder = builder.with_to(first_call_to.map(Into::into)).await?;
126        let tx_builder = builder.with_code_sig_and_args(None, None, vec![]).await?;
127
128        if raw_unsigned {
129            if eth.wallet.from.is_none() && !has_nonce {
130                eyre::bail!(
131                    "Missing required parameters for raw unsigned transaction. When --from is not provided, you must specify: --nonce"
132                );
133            }
134
135            let from = eth.wallet.from.unwrap_or(Address::ZERO);
136            let (tx, _) = tx_builder.build(from).await?;
137            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
138            print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
139            let raw_tx =
140                alloy_primitives::hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing());
141            sh_println!("{raw_tx}")?;
142            return Ok(());
143        }
144
145        if ethsign {
146            let (tx, _) = tx_builder.build(config.sender).await?;
147            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
148            print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
149            let signed_tx = provider.sign_transaction(tx).await?;
150            sh_println!("{signed_tx}")?;
151            return Ok(());
152        }
153
154        // Default: use local signer
155        let signer = match signer {
156            Some(s) => s,
157            None => eth.wallet.signer().await?,
158        };
159
160        let signed_tx = if let Some(ref access_key) = tempo_access_key {
161            let (tx, _) =
162                tx_builder.build_with_access_key(access_key.wallet_address, access_key).await?;
163            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
164            print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
165            let raw_tx = tx
166                .sign_with_access_key(
167                    &provider,
168                    &signer,
169                    access_key.wallet_address,
170                    access_key.key_address,
171                    access_key.key_authorization.as_ref(),
172                )
173                .await?;
174            alloy_primitives::hex::encode(raw_tx)
175        } else {
176            tx::validate_from_address(eth.wallet.from, Signer::address(&signer))?;
177            let (tx, _) = tx_builder.build(&signer).await?;
178            maybe_print_resolved_lane(resolved_lane.as_ref(), tx.nonce().unwrap_or_default())?;
179            print_resolved_fee_token_selection(Some(chain), tx.fee_token())?;
180            let envelope = tx.build(&EthereumWallet::new(signer)).await?;
181            alloy_primitives::hex::encode(envelope.encoded_2718())
182        };
183
184        sh_println!("0x{signed_tx}")?;
185
186        Ok(())
187    }
188}
189
190async fn resolve_signer(
191    tempo: &TempoOpts,
192    wallet: &WalletOpts,
193    chain_id: u64,
194    raw_unsigned: bool,
195) -> Result<(Option<WalletSigner>, Option<TempoAccessKeyConfig>)> {
196    if raw_unsigned {
197        let (_, access_key) = wallet.maybe_signer().await?;
198        return Ok((None, access_key));
199    }
200
201    tempo::resolve_session_or_wallet_signer(tempo, wallet, chain_id).await
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use alloy_primitives::address;
208
209    #[test]
210    fn raw_unsigned_resolver_discards_signer_but_keeps_access_key_metadata() {
211        let runtime = tokio::runtime::Runtime::new().unwrap();
212        runtime.block_on(async {
213            let wallet = WalletOpts {
214                tempo_access_key: Some(
215                    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
216                        .to_string(),
217                ),
218                tempo_root_account: Some(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")),
219                ..Default::default()
220            };
221
222            let (signer, access_key) =
223                resolve_signer(&TempoOpts::default(), &wallet, 31337, true).await.unwrap();
224
225            assert!(signer.is_none());
226            let access_key = access_key.expect("access-key metadata");
227            assert_eq!(
228                access_key.wallet_address,
229                address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
230            );
231            assert_eq!(
232                access_key.key_address,
233                address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8")
234            );
235        });
236    }
237}