1pub use totalreclaw_core::userop::{
11 encode_batch_call, encode_single_call, hash_userop, sign_userop, UserOperationV7,
12 DATA_EDGE_ADDRESS, ENTRYPOINT_ADDRESS, MAX_BATCH_SIZE, SIMPLE_ACCOUNT_FACTORY,
13};
14
15use crate::{Error, Result};
16
17#[derive(Debug)]
19pub struct SubmitResult {
20 pub tx_hash: String,
21 pub user_op_hash: String,
22 pub success: bool,
23}
24
25pub async fn submit_userop(
37 calldata: &[u8],
38 sender: &str,
39 private_key: &[u8; 32],
40 relay_url: &str,
41 auth_key_hex: &str,
42 chain_id: u64,
43 is_test: bool,
44) -> Result<SubmitResult> {
45 let bundler_url = format!("{}/v1/bundler", relay_url.trim_end_matches('/'));
46 let client = reqwest::Client::builder()
47 .timeout(std::time::Duration::from_secs(60))
48 .build()
49 .map_err(|e| Error::Http(e.to_string()))?;
50 let calldata_hex = format!("0x{}", hex::encode(calldata));
51
52 let headers = build_headers(auth_key_hex, sender, is_test);
53
54 let gas_price_resp = jsonrpc_call(
56 &client,
57 &bundler_url,
58 "pimlico_getUserOperationGasPrice",
59 serde_json::json!([]),
60 &headers,
61 )
62 .await?;
63
64 let mut max_fee = "0x0".to_string();
65 let mut max_priority_fee = "0x0".to_string();
66 if let Some(fast) = gas_price_resp.get("result").and_then(|r| r.get("fast")) {
67 if let Some(v) = fast.get("maxFeePerGas") {
68 max_fee = v.as_str().unwrap_or("0x0").to_string();
69 }
70 if let Some(v) = fast.get("maxPriorityFeePerGas") {
71 max_priority_fee = v.as_str().unwrap_or("0x0").to_string();
72 }
73 }
74
75 let nonce_hex = get_nonce(&client, sender, chain_id).await?;
77
78 let deployed = is_account_deployed(&client, sender, chain_id).await?;
80 let (factory, factory_data) = if deployed {
81 (None, None)
82 } else {
83 let signing_key = k256::ecdsa::SigningKey::from_bytes(private_key.into())
86 .map_err(|e| Error::Crypto(format!("Invalid signing key: {}", e)))?;
87 let verifying_key = signing_key.verifying_key();
88 let public_key = verifying_key.to_encoded_point(false);
89 let pubkey_raw = &public_key.as_bytes()[1..];
90 let eoa_hash = keccak256_hash(pubkey_raw);
91 let eoa_addr = format!("0x{}", hex::encode(&eoa_hash[12..]));
92
93 let owner_padded = format!("{:0>64}", eoa_addr.trim_start_matches("0x").to_lowercase());
96 let salt_padded = "0".repeat(64); let factory_data_hex = format!("0x5fbfb9cf{}{}", owner_padded, salt_padded);
98 (
99 Some(SIMPLE_ACCOUNT_FACTORY.to_string()),
100 Some(factory_data_hex),
101 )
102 };
103
104 let mut userop = UserOperationV7 {
107 sender: sender.to_string(),
108 nonce: nonce_hex,
109 factory,
110 factory_data,
111 call_data: calldata_hex,
112 call_gas_limit: "0x0".to_string(),
113 verification_gas_limit: "0x0".to_string(),
114 pre_verification_gas: "0x0".to_string(),
115 max_fee_per_gas: max_fee,
116 max_priority_fee_per_gas: max_priority_fee,
117 paymaster: None,
118 paymaster_verification_gas_limit: None,
119 paymaster_post_op_gas_limit: None,
120 paymaster_data: None,
121 signature: "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c".to_string(),
124 };
125
126 let sponsor_resp = jsonrpc_call(
128 &client,
129 &bundler_url,
130 "pm_sponsorUserOperation",
131 serde_json::json!([userop, ENTRYPOINT_ADDRESS]),
132 &headers,
133 )
134 .await?;
135
136 if let Some(result) = sponsor_resp.get("result") {
138 if let Some(v) = result.get("callGasLimit") {
139 userop.call_gas_limit = v.as_str().unwrap_or("0x0").to_string();
140 }
141 if let Some(v) = result.get("verificationGasLimit") {
142 userop.verification_gas_limit = v.as_str().unwrap_or("0x0").to_string();
143 }
144 if let Some(v) = result.get("preVerificationGas") {
145 userop.pre_verification_gas = v.as_str().unwrap_or("0x0").to_string();
146 }
147 if let Some(v) = result.get("maxFeePerGas") {
149 if let Some(s) = v.as_str() {
150 userop.max_fee_per_gas = s.to_string();
151 }
152 }
153 if let Some(v) = result.get("maxPriorityFeePerGas") {
154 if let Some(s) = v.as_str() {
155 userop.max_priority_fee_per_gas = s.to_string();
156 }
157 }
158 if let Some(v) = result.get("paymaster") {
160 userop.paymaster = v.as_str().map(|s| s.to_string());
161 }
162 if let Some(v) = result.get("paymasterVerificationGasLimit") {
163 userop.paymaster_verification_gas_limit = v.as_str().map(|s| s.to_string());
164 }
165 if let Some(v) = result.get("paymasterPostOpGasLimit") {
166 userop.paymaster_post_op_gas_limit = v.as_str().map(|s| s.to_string());
167 }
168 if let Some(v) = result.get("paymasterData") {
169 userop.paymaster_data = v.as_str().map(|s| s.to_string());
170 }
171 } else {
172 let err_msg = sponsor_resp
173 .get("error")
174 .map(|e| format!("{}", e))
175 .unwrap_or_else(|| format!("{:?}", sponsor_resp));
176 return Err(Error::Http(format!(
177 "Paymaster sponsorship failed: {}",
178 err_msg
179 )));
180 }
181
182 let userop_hash = hash_userop(&userop, ENTRYPOINT_ADDRESS, chain_id)?;
184 let signature = sign_userop(&userop_hash, private_key)?;
185 userop.signature = format!("0x{}", hex::encode(&signature));
186
187 let send_resp = jsonrpc_call(
189 &client,
190 &bundler_url,
191 "eth_sendUserOperation",
192 serde_json::json!([userop, ENTRYPOINT_ADDRESS]),
193 &headers,
194 )
195 .await?;
196
197 let op_hash = send_resp["result"]
198 .as_str()
199 .ok_or_else(|| {
200 let err_msg = send_resp
201 .get("error")
202 .map(|e| format!("{}", e))
203 .unwrap_or_else(|| format!("{:?}", send_resp));
204 Error::Http(format!("No userOpHash in response: {}", err_msg))
205 })?
206 .to_string();
207
208 let receipt = poll_receipt(&client, &bundler_url, &op_hash, &headers).await?;
210
211 Ok(SubmitResult {
212 tx_hash: receipt["receipt"]["transactionHash"]
213 .as_str()
214 .unwrap_or("")
215 .to_string(),
216 user_op_hash: op_hash,
217 success: receipt["success"].as_bool().unwrap_or(false),
218 })
219}
220
221fn build_headers(auth_key_hex: &str, wallet: &str, is_test: bool) -> reqwest::header::HeaderMap {
226 let mut h = reqwest::header::HeaderMap::new();
227 h.insert("X-TotalReclaw-Client", "zeroclaw-memory".parse().unwrap());
228 h.insert(
229 "Authorization",
230 format!("Bearer {}", auth_key_hex).parse().unwrap(),
231 );
232 h.insert("X-Wallet-Address", wallet.parse().unwrap());
233 if is_test {
234 h.insert("X-TotalReclaw-Test", "true".parse().unwrap());
235 }
236 h
237}
238
239async fn jsonrpc_call(
240 client: &reqwest::Client,
241 url: &str,
242 method: &str,
243 params: serde_json::Value,
244 headers: &reqwest::header::HeaderMap,
245) -> Result<serde_json::Value> {
246 let resp = client
247 .post(url)
248 .headers(headers.clone())
249 .json(&serde_json::json!({
250 "jsonrpc": "2.0",
251 "method": method,
252 "params": params,
253 "id": 1,
254 }))
255 .send()
256 .await
257 .map_err(|e| Error::Http(e.to_string()))?;
258
259 resp.json().await.map_err(|e| Error::Http(e.to_string()))
260}
261
262async fn is_account_deployed(client: &reqwest::Client, address: &str, chain_id: u64) -> Result<bool> {
264 let rpc_url = match chain_id {
265 84532 => "https://sepolia.base.org",
266 100 => "https://rpc.gnosischain.com",
267 _ => "https://sepolia.base.org",
268 };
269
270 let resp = client
271 .post(rpc_url)
272 .json(&serde_json::json!({
273 "jsonrpc": "2.0",
274 "method": "eth_getCode",
275 "params": [address, "latest"],
276 "id": 1,
277 }))
278 .send()
279 .await
280 .map_err(|e| Error::Http(e.to_string()))?;
281
282 let body: serde_json::Value = resp
283 .json()
284 .await
285 .map_err(|e| Error::Http(e.to_string()))?;
286
287 let code = body["result"].as_str().unwrap_or("0x");
288 Ok(code.len() > 2)
290}
291
292async fn get_nonce(client: &reqwest::Client, sender: &str, chain_id: u64) -> Result<String> {
296 let rpc_url = match chain_id {
297 84532 => "https://sepolia.base.org",
298 100 => "https://rpc.gnosischain.com",
299 _ => "https://sepolia.base.org",
300 };
301
302 let sender_padded = format!(
304 "{:0>64}",
305 sender.trim_start_matches("0x").to_lowercase()
306 );
307 let key_padded = "0".repeat(64);
308 let calldata = format!("0x35567e1a{}{}", sender_padded, key_padded);
309
310 let resp = client
311 .post(rpc_url)
312 .json(&serde_json::json!({
313 "jsonrpc": "2.0",
314 "method": "eth_call",
315 "params": [{"to": ENTRYPOINT_ADDRESS, "data": calldata}, "latest"],
316 "id": 1,
317 }))
318 .send()
319 .await
320 .map_err(|e| Error::Http(e.to_string()))?;
321
322 let body: serde_json::Value = resp
323 .json()
324 .await
325 .map_err(|e| Error::Http(e.to_string()))?;
326
327 let result = body["result"].as_str().unwrap_or("0x0");
328 let trimmed = result.trim_start_matches("0x").trim_start_matches('0');
330 if trimmed.is_empty() {
331 Ok("0x0".to_string())
332 } else {
333 Ok(format!("0x{}", trimmed))
334 }
335}
336
337fn keccak256_hash(data: &[u8]) -> [u8; 32] {
341 use tiny_keccak::{Hasher, Keccak};
342 let mut keccak = Keccak::v256();
343 let mut hash = [0u8; 32];
344 keccak.update(data);
345 keccak.finalize(&mut hash);
346 hash
347}
348
349async fn poll_receipt(
350 client: &reqwest::Client,
351 bundler_url: &str,
352 op_hash: &str,
353 headers: &reqwest::header::HeaderMap,
354) -> Result<serde_json::Value> {
355 for _ in 0..60 {
356 let resp = jsonrpc_call(
357 client,
358 bundler_url,
359 "eth_getUserOperationReceipt",
360 serde_json::json!([op_hash]),
361 headers,
362 )
363 .await?;
364
365 if resp.get("result").and_then(|r| r.as_object()).is_some() {
366 return Ok(resp["result"].clone());
367 }
368
369 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
370 }
371
372 Err(Error::Http("UserOp receipt timeout after 120s".into()))
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
381 fn test_re_exported_encode_single_call() {
382 let payload = b"test protobuf data";
383 let encoded = encode_single_call(payload);
384 assert_eq!(&encoded[..4], &[0xb6, 0x1d, 0x27, 0xf6]);
386 assert!(encoded.len() > 100);
387 }
388
389 #[test]
390 fn test_re_exported_encode_batch_call() {
391 let payloads = vec![
392 b"fact one".to_vec(),
393 b"fact two".to_vec(),
394 b"fact three".to_vec(),
395 ];
396 let encoded = encode_batch_call(&payloads).unwrap();
397 assert_eq!(&encoded[..4], &[0x47, 0xe1, 0xda, 0x2a]);
398 }
399
400 #[test]
401 fn test_re_exported_hash_and_sign() {
402 let userop = UserOperationV7 {
404 sender: "0x949bc374325a4f41e46e8e78a07d910332934542".to_string(),
405 nonce: "0x0".to_string(),
406 factory: Some("0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985".to_string()),
407 factory_data: Some("0x5fbfb9cf0000000000000000000000008eb626f727e92a73435f2b85dd6fd0c6da5dbb720000000000000000000000000000000000000000000000000000000000000000".to_string()),
408 call_data: "0xb61d27f6".to_string(),
409 call_gas_limit: "0x186a0".to_string(),
410 verification_gas_limit: "0x30d40".to_string(),
411 pre_verification_gas: "0xc350".to_string(),
412 max_fee_per_gas: "0xf4240".to_string(),
413 max_priority_fee_per_gas: "0x7a120".to_string(),
414 paymaster: Some("0x0000000000000039cd5e8ae05257ce51c473ddd1".to_string()),
415 paymaster_verification_gas_limit: Some("0x186a0".to_string()),
416 paymaster_post_op_gas_limit: Some("0xc350".to_string()),
417 paymaster_data: Some("0xabcd".to_string()),
418 signature: format!("0x{}", "00".repeat(65)),
419 };
420
421 let hash = hash_userop(
422 &userop,
423 "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
424 84532,
425 )
426 .unwrap();
427
428 assert_eq!(
429 format!("0x{}", hex::encode(hash)),
430 "0x4525d2a8a555a1a56f6313735b83fe3ee55f81d504d905ea85613524973f97c2",
431 );
432
433 let pk_hex = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
435 let mut pk = [0u8; 32];
436 pk.copy_from_slice(&hex::decode(pk_hex).unwrap());
437
438 let test_hash_hex = "1b25552f7901991cd4e2793945f694a09c9d0b9454a86cee16123ac9e84bd2de";
439 let mut test_hash = [0u8; 32];
440 test_hash.copy_from_slice(&hex::decode(test_hash_hex).unwrap());
441
442 let sig = sign_userop(&test_hash, &pk).unwrap();
443 assert_eq!(
444 hex::encode(&sig),
445 "24b6fabd386f1580aa1fc09b04dd274ea334a9bf63e4fc994e0bef9a505f618335cb2b7d20454a0526f5c66f52ed73b9e76e9696ab5959998e7fc3984fba91691c",
446 );
447 }
448
449 #[test]
450 fn test_constants_re_exported() {
451 assert_eq!(DATA_EDGE_ADDRESS, "0xC445af1D4EB9fce4e1E61fE96ea7B8feBF03c5ca");
452 assert_eq!(ENTRYPOINT_ADDRESS, "0x0000000071727De22E5E9d8BAf0edAc6f37da032");
453 assert_eq!(SIMPLE_ACCOUNT_FACTORY, "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985");
454 assert_eq!(MAX_BATCH_SIZE, 15);
455 }
456}