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//! - `0x123` - Just an address (empty call)
7//! - `0x123:0.1ether` - ETH transfer
8//! - `0x123::transfer(address,uint256):0x789,1000` - Contract call with signature
9//! - `0x123::0xabcdef` - Contract call with raw calldata
10
11use alloy_network::Network;
12use alloy_primitives::{Address, Bytes, U256, hex};
13use alloy_provider::Provider;
14use eyre::{Result, WrapErr, eyre};
15use foundry_cli::utils::parse_function_args;
16use foundry_config::Chain;
17use std::str::FromStr;
18use tempo_primitives::transaction::Call;
19
20/// A parsed call specification for batch transactions.
21#[derive(Debug, Clone)]
22pub struct CallSpec {
23    /// Target address (required)
24    pub to: Address,
25    /// ETH value to send (optional, defaults to 0)
26    pub value: U256,
27    /// Function signature, e.g., "transfer(address,uint256)" (optional)
28    pub sig: Option<String>,
29    /// Function arguments (optional)
30    pub args: Vec<String>,
31    /// Raw calldata if provided instead of sig+args (optional)
32    pub data: Option<Bytes>,
33}
34
35impl CallSpec {
36    /// Parse a call spec string.
37    ///
38    /// Format: `to[:<value>][:<sig>[:<args>]]` or `to[:<value>][:<0xrawdata>]`
39    ///
40    /// The delimiter is `:` but we need to be careful about:
41    /// - Colons in function signatures (none expected)
42    /// - Colons in hex addresses (none expected)
43    /// - We use double-colon `::` to separate value from sig/data when value is empty
44    pub fn parse(s: &str) -> Result<Self> {
45        let s = s.trim();
46        if s.is_empty() {
47            return Err(eyre!("Empty call specification"));
48        }
49
50        // Split by `:` but handle `::` for empty value
51        let parts: Vec<&str> = s.split(':').collect();
52
53        if parts.is_empty() {
54            return Err(eyre!("Invalid call specification: {}", s));
55        }
56
57        // First part is always the address
58        let to = Address::from_str(parts[0])
59            .map_err(|e| eyre!("Invalid address '{}': {}", parts[0], e))?;
60
61        let mut value = U256::ZERO;
62        let mut sig = None;
63        let mut args = Vec::new();
64        let mut data = None;
65
66        // Parse remaining parts
67        // Pattern: to:value:sig:args or to::sig:args (empty value) or to:value:0xdata
68        let mut idx = 1;
69
70        // Check for value (non-empty and not a signature). A terminal lowercase hex field is
71        // treated as raw calldata, while one followed by another field is a value.
72        if idx < parts.len() {
73            let part = parts[idx];
74            if !part.is_empty()
75                && (!part.starts_with("0x") || idx + 1 < parts.len())
76                && !part.contains('(')
77            {
78                // This looks like a value
79                value = parse_ether_or_wei(part)?;
80                idx += 1;
81            } else if part.is_empty() {
82                // Empty value (::), skip
83                idx += 1;
84            }
85        }
86
87        // Check for sig/data
88        if idx < parts.len() {
89            let part = parts[idx];
90            if part.starts_with("0x") {
91                // Raw calldata
92                let decoded =
93                    hex::decode(part).map_err(|e| eyre!("Invalid hex data '{}': {}", part, e))?;
94                if idx + 1 != parts.len() {
95                    return Err(eyre!("Unexpected trailing field(s) after raw calldata"));
96                }
97                data = Some(Bytes::from(decoded));
98            } else if !part.is_empty() {
99                // Function signature
100                sig = Some(part.to_string());
101                idx += 1;
102
103                // Collect remaining parts as args (comma-separated in the last part)
104                if idx < parts.len() {
105                    let args_str = parts[idx..].join(":");
106                    args = args_str.split(',').map(|s| s.trim().to_string()).collect();
107                }
108            }
109        }
110
111        Ok(Self { to, value, sig, args, data })
112    }
113
114    /// Resolves this spec into a [`Call`], encoding function arguments if needed.
115    /// `i` is the 0-based index of this call; displayed as `i + 1` in error messages.
116    pub async fn resolve<N: Network, P: Provider<N>>(
117        &self,
118        i: usize,
119        chain: Chain,
120        provider: &P,
121        etherscan_api_key: Option<&str>,
122        etherscan_api_url: Option<&str>,
123    ) -> Result<Call> {
124        let input = if let Some(data) = &self.data {
125            data.clone()
126        } else if let Some(sig) = &self.sig {
127            let (encoded, _) = parse_function_args(
128                sig,
129                self.args.clone(),
130                Some(self.to),
131                chain,
132                provider,
133                etherscan_api_key,
134                etherscan_api_url,
135            )
136            .await
137            .map_err(|e| eyre!("Failed to encode call {}: {e}", i + 1))?;
138            Bytes::from(encoded)
139        } else {
140            Bytes::new()
141        };
142        Ok(Call { to: self.to.into(), value: self.value, input })
143    }
144}
145
146impl FromStr for CallSpec {
147    type Err = eyre::Error;
148
149    fn from_str(s: &str) -> Result<Self> {
150        Self::parse(s)
151    }
152}
153
154/// Parse a value string that can be in ether notation (e.g., "0.1ether") or raw wei.
155fn parse_ether_or_wei(s: &str) -> Result<U256> {
156    // Use alloy's DynSolType coercion which handles "1ether", "1gwei", "1000" etc.
157    if s.starts_with("0x") || s.starts_with("0X") {
158        U256::from_str(s).map_err(|e| eyre!("Invalid hex value '{}': {}", s, e))
159    } else {
160        alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), s)
161            .wrap_err_with(|| format!("Invalid value '{s}'"))?
162            .as_uint()
163            .map(|(v, _)| v)
164            .ok_or_else(|| eyre!("Could not parse value '{}'", s))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_parse_address_only() {
174        let spec = CallSpec::parse("0x1234567890123456789012345678901234567890").unwrap();
175        assert_eq!(
176            spec.to,
177            "0x1234567890123456789012345678901234567890".parse::<Address>().unwrap()
178        );
179        assert_eq!(spec.value, U256::ZERO);
180        assert!(spec.sig.is_none());
181        assert!(spec.args.is_empty());
182        assert!(spec.data.is_none());
183    }
184
185    #[test]
186    fn test_parse_with_value() {
187        let spec = CallSpec::parse("0x1234567890123456789012345678901234567890:1ether").unwrap();
188        assert_eq!(spec.value, parse_ether_or_wei("1ether").unwrap());
189        assert!(spec.sig.is_none());
190    }
191
192    #[test]
193    fn test_parse_hex_value() {
194        assert_eq!(parse_ether_or_wei("0x10").unwrap(), U256::from(16));
195        assert_eq!(parse_ether_or_wei("0X10").unwrap(), U256::from(16));
196    }
197
198    #[test]
199    fn test_parse_lowercase_hex_value() {
200        let address = "0x1234567890123456789012345678901234567890";
201
202        let spec = CallSpec::parse(&format!("{address}:0x10:deposit()")).unwrap();
203        assert_eq!(spec.value, U256::from(16));
204        assert_eq!(spec.sig.as_deref(), Some("deposit()"));
205
206        let spec = CallSpec::parse(&format!("{address}:0x10")).unwrap();
207        assert_eq!(spec.value, U256::ZERO);
208        assert_eq!(spec.data, Some(Bytes::from([0x10])));
209    }
210
211    #[test]
212    fn test_parse_with_sig() {
213        let spec = CallSpec::parse(
214            "0x1234567890123456789012345678901234567890::transfer(address,uint256):0xabc,1000",
215        )
216        .unwrap();
217        assert_eq!(spec.value, U256::ZERO);
218        assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string()));
219        assert_eq!(spec.args, vec!["0xabc", "1000"]);
220    }
221
222    #[test]
223    fn test_parse_with_value_and_sig() {
224        let spec = CallSpec::parse(
225            "0x1234567890123456789012345678901234567890:0.5ether:transfer(address,uint256):0xabc,1000",
226        )
227        .unwrap();
228        assert_eq!(spec.value, parse_ether_or_wei("0.5ether").unwrap());
229        assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string()));
230    }
231
232    #[test]
233    fn test_parse_with_raw_data() {
234        let spec = CallSpec::parse("0x1234567890123456789012345678901234567890::0xabcdef").unwrap();
235        assert_eq!(spec.value, U256::ZERO);
236        assert!(spec.sig.is_none());
237        assert_eq!(spec.data, Some(Bytes::from(hex::decode("abcdef").unwrap())));
238    }
239
240    #[test]
241    fn test_parse_raw_data_rejects_trailing_fields() {
242        for spec in [
243            "0x1234567890123456789012345678901234567890::0xabcdef:typo",
244            "0x1234567890123456789012345678901234567890:1wei:0xabcdef:unexpected",
245        ] {
246            assert_eq!(
247                CallSpec::parse(spec).unwrap_err().to_string(),
248                "Unexpected trailing field(s) after raw calldata"
249            );
250        }
251    }
252}