Skip to main content

cast/
call_spec.rs

1//! Call specification parsing for batch transactions.
2//!
3//! Parses call specs in the format: `to[:<value>][:<sig>[:<args>]]` or `to[:<value>][:<0xrawdata>]`
4//!
5//! Examples:
6//! - `0x1234567890123456789012345678901234567890` - Just an address (empty call)
7//! - `0x1234567890123456789012345678901234567890:0.1ether` - ETH transfer
8//! - `0x1234567890123456789012345678901234567890::transfer(address,uint256):
9//!   0x0987654321098765432109876543210987654321,1000` - Contract call with signature
10//! - `0x1234567890123456789012345678901234567890::batch(uint256[],(uint256,uint256)): [1,2],(3,4)`
11//!   - Array and tuple arguments.
12//! - `0x1234567890123456789012345678901234567890::0x123def` - Contract call with raw calldata
13//! - `0x1234567890123456789012345678901234567890:1ether:deposit()` - Value + function call
14
15use alloy_network::Network;
16use alloy_primitives::{Address, Bytes, U256, hex};
17use alloy_provider::Provider;
18use eyre::{Result, WrapErr, eyre};
19use foundry_cli::utils::{parse_ether_value, parse_function_args};
20use foundry_config::Chain;
21use std::str::FromStr;
22use tempo_primitives::transaction::Call;
23
24/// A parsed call specification for batch transactions.
25#[derive(Debug, Clone)]
26pub struct CallSpec {
27    /// Target address (required)
28    pub to: Address,
29    /// ETH value to send (optional, defaults to 0)
30    pub value: U256,
31    /// Function signature, e.g., "transfer(address,uint256)" (optional)
32    pub sig: Option<String>,
33    /// Function arguments (optional)
34    pub args: Vec<String>,
35    /// Arguments split using the legacy comma-separated grammar, when that differs from `args`.
36    legacy_args: Option<Vec<String>>,
37    /// Raw calldata if provided instead of sig+args (optional)
38    pub data: Option<Bytes>,
39}
40
41impl CallSpec {
42    /// Parse a call spec string.
43    ///
44    /// Format: `to[:<value>][:<sig>[:<args>]]` or `to[:<value>][:<0xrawdata>]`. A double colon
45    /// (`::`) separates the address from the sig/data when the value is omitted.
46    pub fn parse(s: &str) -> Result<Self> {
47        let s = s.trim();
48        if s.is_empty() {
49            return Err(eyre!("Empty call specification"));
50        }
51
52        let parts: Vec<&str> = s.split(':').collect();
53        let to = Address::from_str(parts[0])
54            .map_err(|e| eyre!("Invalid address '{}': {}", parts[0], e))?;
55        let mut spec = Self {
56            to,
57            value: U256::ZERO,
58            sig: None,
59            args: Vec::new(),
60            legacy_args: None,
61            data: None,
62        };
63
64        // The first field is the value unless it is empty, a signature, or a terminal lowercase
65        // hex field, which is raw calldata.
66        let mut rest = &parts[1..];
67        if let Some((part, tail)) = rest.split_first() {
68            if part.is_empty() {
69                rest = tail;
70            } else if (!part.starts_with("0x") || !tail.is_empty()) && !part.contains('(') {
71                spec.value =
72                    parse_ether_value(part).wrap_err_with(|| format!("Invalid value '{part}'"))?;
73                rest = tail;
74            }
75        }
76
77        match rest.split_first() {
78            Some((part, tail)) if part.starts_with("0x") => {
79                let decoded =
80                    hex::decode(part).map_err(|e| eyre!("Invalid hex data '{}': {}", part, e))?;
81                eyre::ensure!(tail.is_empty(), "Unexpected trailing field(s) after raw calldata");
82                spec.data = Some(Bytes::from(decoded));
83            }
84            Some((part, tail)) if !part.is_empty() => {
85                spec.sig = Some(part.to_string());
86                if !tail.is_empty() {
87                    // Args are comma-separated; rejoin any colons that were split off.
88                    let args_str = tail.join(":");
89                    spec.args = split_call_args(&args_str);
90                    let legacy_args =
91                        args_str.split(',').map(|arg| arg.trim().to_string()).collect::<Vec<_>>();
92                    if legacy_args != spec.args {
93                        spec.legacy_args = Some(legacy_args);
94                    }
95                }
96            }
97            _ => {}
98        }
99
100        Ok(spec)
101    }
102
103    /// Resolves this spec into a [`Call`], encoding function arguments if needed.
104    /// `i` is the 0-based index of this call; displayed as `i + 1` in error messages.
105    pub async fn resolve<N: Network, P: Provider<N>>(
106        &self,
107        i: usize,
108        chain: Chain,
109        provider: &P,
110        etherscan_api_key: Option<&str>,
111        etherscan_api_url: Option<&str>,
112    ) -> Result<Call> {
113        let input = if let Some(data) = &self.data {
114            data.clone()
115        } else if let Some(sig) = &self.sig {
116            let mut result = None;
117            for args in self.legacy_args.iter().chain(std::iter::once(&self.args)) {
118                result = Some(
119                    parse_function_args(
120                        sig,
121                        args.clone(),
122                        Some(self.to),
123                        chain,
124                        provider,
125                        etherscan_api_key,
126                        etherscan_api_url,
127                    )
128                    .await,
129                );
130                if result.as_ref().is_some_and(Result::is_ok) {
131                    break;
132                }
133            }
134            let (encoded, _) = result
135                .expect("argument candidates are never empty")
136                .map_err(|e| eyre!("Failed to encode call {}: {e}", i + 1))?;
137            Bytes::from(encoded)
138        } else {
139            Bytes::new()
140        };
141        Ok(Call { to: self.to.into(), value: self.value, input })
142    }
143}
144
145/// Split call arguments on top-level commas, respecting nested parentheses, brackets, and quoted
146/// strings.
147///
148/// This ensures that array and tuple arguments containing internal commas are not incorrectly
149/// split. For example:
150/// - `[1,2]` stays as one argument
151/// - `(7,hello),9` splits into `(7,hello)` and `9`
152fn split_call_args(s: &str) -> Vec<String> {
153    let mut args = Vec::new();
154    let mut depth = 0usize;
155    let mut start = 0usize;
156    let mut quote = None;
157    let mut at_value_start = true;
158
159    for (idx, ch) in s.char_indices() {
160        if let Some(active_quote) = quote {
161            if ch == active_quote {
162                quote = None;
163            }
164            continue;
165        }
166
167        match ch {
168            '\'' | '"' if at_value_start => {
169                quote = Some(ch);
170                at_value_start = false;
171            }
172            '(' | '[' => {
173                depth += 1;
174                at_value_start = true;
175            }
176            ')' | ']' => {
177                depth = depth.saturating_sub(1);
178                at_value_start = false;
179            }
180            ',' if depth == 0 => {
181                args.push(s[start..idx].trim().to_string());
182                start = idx + ch.len_utf8();
183                at_value_start = true;
184            }
185            ',' => at_value_start = true,
186            ch if ch.is_whitespace() && at_value_start => {}
187            _ => {
188                at_value_start = false;
189            }
190        }
191    }
192
193    args.push(s[start..].trim().to_string());
194    args
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use alloy_provider::{ProviderBuilder, mock::Asserter};
201    use foundry_common::abi::{encode_function_args, get_func};
202
203    const ADDRESS: &str = "0x1234567890123456789012345678901234567890";
204
205    async fn assert_resolves(cases: &[(&str, &str, &[&str])]) {
206        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
207        for &(suffix, sig, args) in cases {
208            let spec = format!("{ADDRESS}{suffix}");
209            let call = CallSpec::parse(&spec)
210                .unwrap()
211                .resolve(0, Chain::from_id(1), &provider, None, None)
212                .await
213                .unwrap();
214            let expected = encode_function_args(&get_func(sig).unwrap(), args).unwrap();
215            assert_eq!(call.input.as_ref(), expected, "call spec: {spec}");
216        }
217    }
218
219    #[test]
220    fn test_parse_address_and_value() {
221        let address = "0x1234567890123456789012345678901234567890";
222
223        let spec = CallSpec::parse(address).unwrap();
224        assert_eq!(spec.to, address.parse::<Address>().unwrap());
225        assert_eq!(spec.value, U256::ZERO);
226        assert!(spec.sig.is_none() && spec.args.is_empty() && spec.data.is_none());
227
228        let spec = CallSpec::parse(&format!("{address}:1ether")).unwrap();
229        assert_eq!(spec.value, parse_ether_value("1ether").unwrap());
230        assert!(spec.sig.is_none());
231    }
232
233    #[test]
234    fn test_parse_lowercase_hex_value() {
235        let address = "0x1234567890123456789012345678901234567890";
236
237        let spec = CallSpec::parse(&format!("{address}:0x10:deposit()")).unwrap();
238        assert_eq!(spec.value, U256::from(16));
239        assert_eq!(spec.sig.as_deref(), Some("deposit()"));
240
241        let spec = CallSpec::parse(&format!("{address}:0x10")).unwrap();
242        assert_eq!(spec.value, U256::ZERO);
243        assert_eq!(spec.data, Some(Bytes::from([0x10])));
244    }
245
246    #[test]
247    fn test_parse_with_sig() {
248        let spec = CallSpec::parse(
249            "0x1234567890123456789012345678901234567890::transfer(address,uint256):0xabc,1000",
250        )
251        .unwrap();
252        assert_eq!(spec.value, U256::ZERO);
253        assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string()));
254        assert_eq!(spec.args, vec!["0xabc", "1000"]);
255    }
256
257    #[test]
258    fn test_parse_with_value_and_sig() {
259        let spec = CallSpec::parse(
260            "0x1234567890123456789012345678901234567890:0.5ether:transfer(address,uint256):0xabc,1000",
261        )
262        .unwrap();
263        assert_eq!(spec.value, parse_ether_value("0.5ether").unwrap());
264        assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string()));
265    }
266
267    #[test]
268    fn test_parse_with_raw_data() {
269        let spec = CallSpec::parse("0x1234567890123456789012345678901234567890::0xabcdef").unwrap();
270        assert_eq!(spec.value, U256::ZERO);
271        assert!(spec.sig.is_none());
272        assert_eq!(spec.data, Some(Bytes::from(hex::decode("abcdef").unwrap())));
273    }
274
275    #[test]
276    fn test_parse_raw_data_rejects_trailing_fields() {
277        for spec in [
278            "0x1234567890123456789012345678901234567890::0xabcdef:typo",
279            "0x1234567890123456789012345678901234567890:1wei:0xabcdef:unexpected",
280        ] {
281            assert_eq!(
282                CallSpec::parse(spec).unwrap_err().to_string(),
283                "Unexpected trailing field(s) after raw calldata"
284            );
285        }
286    }
287
288    #[test]
289    fn test_parse_array_args() {
290        let spec =
291            CallSpec::parse("0x1234567890123456789012345678901234567890::foo(uint256[]):[1,2]")
292                .unwrap();
293        assert_eq!(spec.sig, Some("foo(uint256[])".to_string()));
294        assert_eq!(spec.args, vec!["[1,2]"]);
295
296        let spec = CallSpec::parse(
297            "0x1234567890123456789012345678901234567890::foo(uint256[][]):[[1,2],[3,4]]",
298        )
299        .unwrap();
300        assert_eq!(spec.sig, Some("foo(uint256[][])".to_string()));
301        assert_eq!(spec.args, vec!["[[1,2],[3,4]]"]);
302    }
303
304    #[test]
305    fn test_parse_tuple_args() {
306        let spec = CallSpec::parse(
307            "0x1234567890123456789012345678901234567890::foo((uint256,string)):(7,hello)",
308        )
309        .unwrap();
310        assert_eq!(spec.sig, Some("foo((uint256,string))".to_string()));
311        assert_eq!(spec.args, vec!["(7,hello)"]);
312
313        let spec = CallSpec::parse(
314            "0x1234567890123456789012345678901234567890::foo((uint256,string),uint256):(7,hello),9",
315        )
316        .unwrap();
317        assert_eq!(spec.sig, Some("foo((uint256,string),uint256)".to_string()));
318        assert_eq!(spec.args, vec!["(7,hello)", "9"]);
319    }
320
321    #[test]
322    fn test_parse_nested_structures() {
323        let spec = CallSpec::parse(
324            "0x1234567890123456789012345678901234567890::foo((uint256[],string)):([1,2],hello)",
325        )
326        .unwrap();
327        assert_eq!(spec.sig, Some("foo((uint256[],string))".to_string()));
328        assert_eq!(spec.args, vec!["([1,2],hello)"]);
329    }
330
331    #[test]
332    fn test_split_call_args() {
333        assert_eq!(split_call_args("[1,2]"), vec!["[1,2]"]);
334        assert_eq!(split_call_args("[[1,2],[3,4]]"), vec!["[[1,2],[3,4]]"]);
335        assert_eq!(split_call_args("(7,hello)"), vec!["(7,hello)"]);
336        assert_eq!(split_call_args("(7,hello),9"), vec!["(7,hello)", "9"]);
337        assert_eq!(split_call_args("1,2,3"), vec!["1", "2", "3"]);
338        assert_eq!(split_call_args("(1,2),(3,4)"), vec!["(1,2)", "(3,4)"]);
339        assert_eq!(split_call_args("[1,2],[3,4]"), vec!["[1,2]", "[3,4]"]);
340        assert_eq!(split_call_args("\"a,b\",9"), vec!["\"a,b\"", "9"]);
341        assert_eq!(split_call_args("(7,\"a],b\"),9"), vec!["(7,\"a],b\")", "9"]);
342        assert_eq!(split_call_args("can't,9"), vec!["can't", "9"]);
343    }
344
345    #[tokio::test]
346    async fn test_resolve_nested_args() {
347        const MIXED_ARGS: &str = "[(0x1111111111111111111111111111111111111111,[1,2]),(0x2222222222222222222222222222222222222222,[3])]";
348        let mixed =
349            format!("{ADDRESS}:1ether:airdrop((address,uint256[])[],bytes):{MIXED_ARGS},0x00");
350        assert_eq!(CallSpec::parse(&mixed).unwrap().value, parse_ether_value("1ether").unwrap());
351        assert_resolves(&[
352            ("::foo(uint256[][]):[[1,2],[3,4]]", "foo(uint256[][])", &["[[1,2],[3,4]]"]),
353            (
354                ":1ether:airdrop((address,uint256[])[],bytes):[(0x1111111111111111111111111111111111111111,[1,2]),(0x2222222222222222222222222222222222222222,[3])],0x00",
355                "airdrop((address,uint256[])[],bytes)",
356                &[MIXED_ARGS, "0x00"],
357            ),
358            (
359                "::foo((uint256,string),uint256):(7,hello),9",
360                "foo((uint256,string),uint256)",
361                &["(7,hello)", "9"],
362            ),
363            (
364                "::foo((uint256,string),uint256):(7,\"a],b\"),9",
365                "foo((uint256,string),uint256)",
366                &["(7,\"a],b\")", "9"],
367            ),
368        ])
369        .await;
370    }
371
372    #[tokio::test]
373    async fn test_resolve_preserves_legacy_string_args() {
374        assert_resolves(&[
375            ("::foo(string,string):(a,b)", "foo(string,string)", &["(a", "b)"]),
376            ("::foo(string,uint256):hello[,9", "foo(string,uint256)", &["hello[", "9"]),
377            ("::foo(string[],uint256):[\"[\"],9", "foo(string[],uint256)", &["[\"[\"]", "9"]),
378            ("::foo(string,uint256):can't,9", "foo(string,uint256)", &["can't", "9"]),
379        ])
380        .await;
381    }
382
383    #[tokio::test]
384    async fn test_resolve_quoted_commas_and_colons() {
385        assert_resolves(&[
386            ("::foo(string,uint256):\"a,b\",9", "foo(string,uint256)", &["\"a,b\"", "9"]),
387            ("::foo(string,uint256):'a,b',9", "foo(string,uint256)", &["'a,b'", "9"]),
388            ("::foo(string,uint256): urn:a:b , 9", "foo(string,uint256)", &["urn:a:b", "9"]),
389        ])
390        .await;
391    }
392
393    #[tokio::test]
394    async fn test_resolve_empty_args() {
395        assert_resolves(&[
396            ("::foo()", "foo()", &[]),
397            ("::foo(string):", "foo(string)", &[""]),
398            ("::foo(string,string):,value", "foo(string,string)", &["", "value"]),
399            ("::foo(string,string):value,", "foo(string,string)", &["value", ""]),
400            ("::foo(uint256[]):[]", "foo(uint256[])", &["[]"]),
401        ])
402        .await;
403    }
404
405    #[tokio::test]
406    async fn test_resolve_rejects_malformed_nested_args() {
407        let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new());
408        for args in ["[1,2)", "[1,2", "[1,2]]", "\"a,b,9"] {
409            let sig = if args.starts_with('[') { "foo(uint256[])" } else { "foo(string,uint256)" };
410            let spec = CallSpec::parse(&format!("{ADDRESS}::{sig}:{args}")).unwrap();
411            assert!(
412                spec.resolve(0, Chain::from_id(1), &provider, None, None).await.is_err(),
413                "malformed arguments unexpectedly encoded: {args}"
414            );
415        }
416    }
417}