Skip to main content

totalreclaw_memory/
userop.rs

1//! Native ERC-4337 UserOperation construction and submission.
2//!
3//! Pure crypto/encoding (ABI encoding, hashing, signing) is provided by
4//! `totalreclaw_core::userop`. This module re-exports those and adds the
5//! I/O layer: JSON-RPC submission to the relay bundler proxy.
6//!
7//! Reference: mcp/src/subgraph/store.ts (TypeScript implementation)
8
9// Re-export pure functions and types from core.
10pub 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/// Result of a UserOp submission.
18#[derive(Debug)]
19pub struct SubmitResult {
20    pub tx_hash: String,
21    pub user_op_hash: String,
22    pub success: bool,
23}
24
25/// Submit a UserOp to the relay bundler endpoint.
26///
27/// Full flow (matches viem/permissionless ordering):
28/// 1. Get gas prices from bundler
29/// 2. Get nonce from EntryPoint
30/// 3. Check if Smart Account is deployed; if not, include factory initCode
31/// 4. Build unsigned v0.7 UserOp (with gas prices already set)
32/// 5. Get paymaster sponsorship (gas estimates + paymaster data)
33/// 6. Sign the UserOp
34/// 7. Submit via eth_sendUserOperation
35/// 8. Wait for receipt
36pub 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    // 1. Get gas prices FIRST (matches viem/permissionless flow)
55    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    // 2. Get nonce: eth_call to EntryPoint.getNonce(sender, 0)
76    let nonce_hex = get_nonce(&client, sender, chain_id).await?;
77
78    // 3. Check if Smart Account is deployed; include factory if not
79    let deployed = is_account_deployed(&client, sender, chain_id).await?;
80    let (factory, factory_data) = if deployed {
81        (None, None)
82    } else {
83        // Factory: SimpleAccountFactory.createAccount(owner, salt)
84        // We need the EOA address (owner). Derive it from the private key.
85        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        // ABI-encode: createAccount(address owner, uint256 salt)
94        // selector: keccak256("createAccount(address,uint256)")[:4] = 0x5fbfb9cf
95        let owner_padded = format!("{:0>64}", eoa_addr.trim_start_matches("0x").to_lowercase());
96        let salt_padded = "0".repeat(64); // salt = 0
97        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    // 4. Build unsigned v0.7 UserOp with gas prices already set
105    //    Use the same stub signature as viem/permissionless for gas estimation.
106    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        // Stub signature matching viem/permissionless SimpleAccount default.
122        // Uses max r and s-curve values that won't revert ecrecover.
123        signature: "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c".to_string(),
124    };
125
126    // 5. Get paymaster sponsorship via pm_sponsorUserOperation
127    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    // Apply paymaster response (v0.7 format)
137    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        // Only update gas prices if sponsor explicitly provides them
148        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        // v0.7 paymaster fields
159        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    // 6. Sign the UserOp
183    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    // 7. Submit via eth_sendUserOperation
188    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    // 8. Poll for receipt
209    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
221// ---------------------------------------------------------------------------
222// I/O helpers (stay in this crate — they need reqwest)
223// ---------------------------------------------------------------------------
224
225fn 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
262/// Check if a Smart Account is deployed by checking its code size.
263async 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    // Account is deployed if it has code (more than just "0x")
289    Ok(code.len() > 2)
290}
291
292/// Get the nonce for a sender from the EntryPoint.
293/// Returns a hex string (e.g. "0x0", "0x1a") that can be used directly
294/// in the UserOp. Handles uint256 nonces (not just u64).
295async 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    // EntryPoint.getNonce(address sender, uint192 key) -> selector 0x35567e1a
303    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    // Strip leading zeros but keep at least one digit after 0x
329    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
337/// Local keccak256 for submit_userop's EOA derivation.
338/// (The core crate's keccak256 is private; this is only used for the
339/// factory initCode computation in submit_userop.)
340fn 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    /// Verify that re-exports from core produce identical results.
380    #[test]
381    fn test_re_exported_encode_single_call() {
382        let payload = b"test protobuf data";
383        let encoded = encode_single_call(payload);
384        // Should start with execute() selector 0xb61d27f6
385        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        // Verify the full hash+sign pipeline through re-exports
403        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        // Sign with Hardhat #0 key
434        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}