espresso_safe_tx_builder/
lib.rs1use 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#[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 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#[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 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 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 assert!(tx["data"].is_null());
297
298 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()], },
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 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}