espresso_safe_tx_builder/
lib.rs

1use std::{collections::BTreeMap, path::Path, time::SystemTime};
2
3use alloy::{
4    json_abi::Function,
5    primitives::{Address, Bytes, U256},
6};
7use anyhow::Result;
8use serde::Serialize;
9
10/// Optional decoded function call info for Safe Transaction Builder output.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct FunctionInfo {
13    pub signature: String,
14    pub args: Vec<String>,
15}
16
17#[derive(Serialize)]
18pub struct CalldataInfo {
19    pub to: Address,
20    pub data: Bytes,
21    /// Included because Safe UI requires this field, even when value is 0.
22    pub value: U256,
23    #[serde(skip)]
24    pub function_info: Option<FunctionInfo>,
25    #[serde(skip)]
26    pub description: String,
27}
28
29impl CalldataInfo {
30    pub fn new(to: Address, data: Bytes) -> Self {
31        Self {
32            to,
33            data,
34            value: U256::ZERO,
35            function_info: None,
36            description: String::new(),
37        }
38    }
39
40    pub fn with_method(to: Address, data: Bytes, value: U256, function_info: FunctionInfo) -> Self {
41        Self {
42            to,
43            data,
44            value,
45            function_info: Some(function_info),
46            description: String::new(),
47        }
48    }
49
50    pub fn with_description(mut self, description: String) -> Self {
51        self.description = description;
52        self
53    }
54}
55
56/// Safe Transaction Builder batch format
57/// See: https://help.safe.global/en/articles/40795-transaction-builder
58#[derive(Serialize)]
59struct SafeTransactionBuilderBatch {
60    version: &'static str,
61    #[serde(rename = "chainId")]
62    chain_id: String,
63    #[serde(rename = "createdAt")]
64    created_at: u64,
65    meta: SafeBatchMeta,
66    transactions: Vec<SafeTransaction>,
67}
68
69#[derive(Serialize)]
70struct SafeBatchMeta {
71    name: &'static str,
72    description: String,
73}
74
75#[derive(Serialize)]
76struct SafeContractMethod {
77    inputs: Vec<SafeMethodInput>,
78    name: String,
79    payable: bool,
80}
81
82#[derive(Serialize)]
83struct SafeMethodInput {
84    #[serde(rename = "internalType")]
85    internal_type: String,
86    name: String,
87    #[serde(rename = "type")]
88    solidity_type: String,
89}
90
91#[derive(Serialize)]
92struct SafeTransaction {
93    to: String,
94    value: String,
95    /// When `contractMethod` is present, `data` must be `null` — the Safe UI
96    /// ignores `contractMethod` if `data` is truthy and falls back to
97    /// "Custom hex data".
98    data: Option<String>,
99    #[serde(rename = "contractMethod")]
100    contract_method: Option<SafeContractMethod>,
101    #[serde(rename = "contractInputsValues")]
102    contract_inputs_values: Option<BTreeMap<String, String>>,
103}
104
105fn build_safe_method(
106    info: &FunctionInfo,
107) -> Result<(SafeContractMethod, BTreeMap<String, String>)> {
108    let func = Function::parse(&info.signature).map_err(|e| {
109        anyhow::anyhow!(
110            "failed to parse function signature '{}': {e}",
111            info.signature
112        )
113    })?;
114
115    anyhow::ensure!(
116        info.args.len() == func.inputs.len(),
117        "function '{}' expects {} args but got {}",
118        info.signature,
119        func.inputs.len(),
120        info.args.len(),
121    );
122
123    let inputs: Vec<SafeMethodInput> = func
124        .inputs
125        .iter()
126        .enumerate()
127        .map(|(i, param)| {
128            let ty = param.ty.to_string();
129            SafeMethodInput {
130                internal_type: param
131                    .internal_type
132                    .as_ref()
133                    .map(|t| t.to_string())
134                    .unwrap_or_else(|| ty.clone()),
135                name: if param.name.is_empty() {
136                    format!("arg{i}")
137                } else {
138                    param.name.clone()
139                },
140                solidity_type: ty,
141            }
142        })
143        .collect();
144
145    let mut values = BTreeMap::new();
146    for (i, arg) in info.args.iter().enumerate() {
147        let name = func
148            .inputs
149            .get(i)
150            .filter(|p| !p.name.is_empty())
151            .map(|p| p.name.clone())
152            .unwrap_or_else(|| format!("arg{i}"));
153        values.insert(name, arg.clone());
154    }
155
156    let method = SafeContractMethod {
157        inputs,
158        name: func.name.clone(),
159        payable: func.state_mutability == alloy::json_abi::StateMutability::Payable,
160    };
161    Ok((method, values))
162}
163
164pub fn output_safe_tx_builder(
165    info: &CalldataInfo,
166    output_path: Option<&Path>,
167    chain_id: u64,
168) -> Result<()> {
169    let created_at = u64::try_from(
170        SystemTime::now()
171            .duration_since(SystemTime::UNIX_EPOCH)
172            .unwrap_or_default()
173            .as_millis(),
174    )
175    .expect("timestamp fits u64");
176
177    let batch = SafeTransactionBuilderBatch {
178        version: "1.0",
179        chain_id: chain_id.to_string(),
180        created_at,
181        meta: SafeBatchMeta {
182            name: "Espresso Multisig Transactions",
183            description: info.description.clone(),
184        },
185        transactions: vec![{
186            let (data, contract_method, contract_inputs_values) = match &info.function_info {
187                Some(fi) => {
188                    let (m, v) = build_safe_method(fi)?;
189                    (None, Some(m), Some(v))
190                },
191                None => (Some(info.data.to_string()), None, None),
192            };
193            SafeTransaction {
194                to: info.to.to_checksum(None),
195                value: info.value.to_string(),
196                data,
197                contract_method,
198                contract_inputs_values,
199            }
200        }],
201    };
202    let text = serde_json::to_string_pretty(&batch)?;
203
204    if let Some(path) = output_path {
205        std::fs::write(path, &text)?;
206        tracing::info!("Calldata written to {}", path.display());
207    } else {
208        println!("{text}");
209    }
210
211    Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216    use alloy::primitives::{Address, Bytes, U256};
217
218    use super::*;
219
220    fn test_addr() -> Address {
221        "0x1234567890abcdef1234567890abcdef12345678"
222            .parse()
223            .unwrap()
224    }
225
226    #[test]
227    fn test_output_safe_tx_builder_to_file() {
228        let addr = test_addr();
229        let data = Bytes::from(vec![0xca, 0xfe]);
230        let info = CalldataInfo::new(addr, data);
231
232        let dir = tempfile::tempdir().unwrap();
233        let path = dir.path().join("test_output_safe_tx.json");
234        output_safe_tx_builder(&info, Some(&path), 1).unwrap();
235
236        let contents = std::fs::read_to_string(&path).unwrap();
237        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
238        assert_eq!(parsed["version"].as_str().unwrap(), "1.0");
239        let txs = parsed["transactions"].as_array().unwrap();
240        assert_eq!(txs.len(), 1);
241        let to = txs[0]["to"].as_str().unwrap();
242        assert_eq!(to, addr.to_checksum(None));
243        assert!(txs[0]["data"].as_str().is_some());
244    }
245
246    #[test]
247    fn test_output_safe_tx_builder_chain_id() {
248        let info = CalldataInfo::new(test_addr(), Bytes::from(vec![0x01]));
249        let dir = tempfile::tempdir().unwrap();
250        let path = dir.path().join("test_output_chain_id.json");
251        output_safe_tx_builder(&info, Some(&path), 11155111).unwrap();
252
253        let contents = std::fs::read_to_string(&path).unwrap();
254        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
255        assert_eq!(parsed["chainId"].as_str().unwrap(), "11155111");
256    }
257
258    #[test]
259    fn test_output_safe_tx_builder_contract_method() {
260        let addr = test_addr();
261        let recipient: Address = "0x000000000000000000000000000000000000dead"
262            .parse()
263            .unwrap();
264        let info = CalldataInfo::with_method(
265            addr,
266            Bytes::from(vec![0xca, 0xfe]),
267            U256::ZERO,
268            FunctionInfo {
269                signature: "transfer(address recipient, uint256 amount)".to_string(),
270                args: vec![format!("{recipient:#x}"), "1000".to_string()],
271            },
272        );
273
274        let dir = tempfile::tempdir().unwrap();
275        let path = dir.path().join("test_contract_method.json");
276        output_safe_tx_builder(&info, Some(&path), 1).unwrap();
277
278        let contents = std::fs::read_to_string(&path).unwrap();
279        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
280        let tx = &parsed["transactions"][0];
281
282        // contractMethod is populated
283        let method = &tx["contractMethod"];
284        assert_eq!(method["name"].as_str().unwrap(), "transfer");
285        assert!(!method["payable"].as_bool().unwrap());
286        let inputs = method["inputs"].as_array().unwrap();
287        assert_eq!(inputs.len(), 2);
288        assert_eq!(inputs[0]["name"].as_str().unwrap(), "recipient");
289        assert_eq!(inputs[0]["type"].as_str().unwrap(), "address");
290        assert_eq!(inputs[0]["internalType"].as_str().unwrap(), "address");
291        assert_eq!(inputs[1]["name"].as_str().unwrap(), "amount");
292        assert_eq!(inputs[1]["type"].as_str().unwrap(), "uint256");
293        assert_eq!(inputs[1]["internalType"].as_str().unwrap(), "uint256");
294
295        // data is null when contractMethod is present (Safe UI requirement)
296        assert!(tx["data"].is_null());
297
298        // contractInputsValues is populated
299        let values = &tx["contractInputsValues"];
300        assert_eq!(
301            values["recipient"].as_str().unwrap(),
302            format!("{recipient:#x}")
303        );
304        assert_eq!(values["amount"].as_str().unwrap(), "1000");
305    }
306
307    #[test]
308    fn test_build_safe_method_arg_count_mismatch() {
309        let addr = test_addr();
310        let info = CalldataInfo::with_method(
311            addr,
312            Bytes::from(vec![0xca, 0xfe]),
313            U256::ZERO,
314            FunctionInfo {
315                signature: "transfer(address,uint256)".to_string(),
316                args: vec!["0xdead".to_string()], // only 1 arg, expects 2
317            },
318        );
319
320        let dir = tempfile::tempdir().unwrap();
321        let path = dir.path().join("test_mismatch.json");
322        let err = output_safe_tx_builder(&info, Some(&path), 1).unwrap_err();
323        assert!(
324            err.to_string().contains("expects 2 args but got 1"),
325            "unexpected error: {err}"
326        );
327    }
328
329    #[test]
330    fn test_output_safe_tx_builder_no_method_has_raw_data() {
331        let info = CalldataInfo::new(test_addr(), Bytes::from(vec![0x01]));
332        let dir = tempfile::tempdir().unwrap();
333        let path = dir.path().join("test_no_method.json");
334        output_safe_tx_builder(&info, Some(&path), 1).unwrap();
335
336        let contents = std::fs::read_to_string(&path).unwrap();
337        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
338        let tx = &parsed["transactions"][0];
339        // Without function info, data is present and method fields are null
340        assert!(tx["data"].is_string());
341        assert!(tx["contractMethod"].is_null());
342        assert!(tx["contractInputsValues"].is_null());
343    }
344
345    #[test]
346    fn test_output_safe_tx_builder_description() {
347        let info = CalldataInfo::new(test_addr(), Bytes::from(vec![0x01]))
348            .with_description("Register validator 0xdead with 5.00% commission".to_string());
349
350        let dir = tempfile::tempdir().unwrap();
351        let path = dir.path().join("test_description.json");
352        output_safe_tx_builder(&info, Some(&path), 1).unwrap();
353
354        let contents = std::fs::read_to_string(&path).unwrap();
355        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
356        assert_eq!(
357            parsed["meta"]["description"].as_str().unwrap(),
358            "Register validator 0xdead with 5.00% commission"
359        );
360    }
361}