Skip to main content

totalreclaw_core/
userop.rs

1//! ERC-4337 v0.7 UserOperation building and signing (pure computation).
2//!
3//! This module provides the pure crypto/encoding pieces of ERC-4337:
4//! - ABI encoding for SimpleAccount execute/executeBatch
5//! - v0.7 UserOp struct
6//! - UserOp hash computation (Keccak256)
7//! - ECDSA signing (EIP-191 prefixed)
8//!
9//! The **submission** (JSON-RPC calls to relay/bundler) is I/O and lives
10//! in the `totalreclaw-memory` crate's `userop` module.
11//!
12//! Feature-gated under `managed` (default-on). Self-hosted users compile
13//! without this module.
14
15use alloy_primitives::{Address, Bytes, U256};
16use alloy_sol_types::{sol, SolCall};
17
18use crate::{Error, Result};
19
20/// DataEdge contract address (same on all chains).
21pub const DATA_EDGE_ADDRESS: &str = "0xC445af1D4EB9fce4e1E61fE96ea7B8feBF03c5ca";
22
23/// EntryPoint v0.7 address.
24pub const ENTRYPOINT_ADDRESS: &str = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";
25
26/// SimpleAccountFactory address (v0.7, same on all EVM chains).
27pub const SIMPLE_ACCOUNT_FACTORY: &str = "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985";
28
29/// Max batch size (matches extraction cap).
30pub const MAX_BATCH_SIZE: usize = 15;
31
32// ABI definitions using alloy sol! macro
33sol! {
34    /// SimpleAccount.execute(address dest, uint256 value, bytes calldata)
35    function execute(address dest, uint256 value, bytes data);
36
37    /// SimpleAccount.executeBatch(address[] dest, uint256[] values, bytes[] data)
38    function executeBatch(address[] dest, uint256[] values, bytes[] data);
39}
40
41/// Encode a single fact submission as SimpleAccount.execute() calldata.
42///
43/// The DataEdge contract has a fallback() that emits Log(bytes),
44/// so the inner calldata IS the raw protobuf payload.
45pub fn encode_single_call(protobuf_payload: &[u8]) -> Vec<u8> {
46    let dest: Address = DATA_EDGE_ADDRESS.parse().unwrap();
47    let value = U256::ZERO;
48    let data = Bytes::copy_from_slice(protobuf_payload);
49
50    let call = executeCall { dest, value, data };
51    call.abi_encode()
52}
53
54/// Encode multiple fact submissions as SimpleAccount.executeBatch() calldata.
55///
56/// Each protobuf payload becomes one call to DataEdge's fallback().
57/// All calls have value=0.
58pub fn encode_batch_call(protobuf_payloads: &[Vec<u8>]) -> Result<Vec<u8>> {
59    if protobuf_payloads.is_empty() {
60        return Err(Error::Crypto("Batch must contain at least 1 payload".into()));
61    }
62    if protobuf_payloads.len() > MAX_BATCH_SIZE {
63        return Err(Error::Crypto(format!(
64            "Batch size {} exceeds maximum of {}",
65            protobuf_payloads.len(),
66            MAX_BATCH_SIZE
67        )));
68    }
69
70    // Single payload -> use execute() (no batch overhead)
71    if protobuf_payloads.len() == 1 {
72        return Ok(encode_single_call(&protobuf_payloads[0]));
73    }
74
75    let dest: Address = DATA_EDGE_ADDRESS.parse().unwrap();
76    let dests: Vec<Address> = vec![dest; protobuf_payloads.len()];
77    let values: Vec<U256> = vec![U256::ZERO; protobuf_payloads.len()];
78    let datas: Vec<Bytes> = protobuf_payloads
79        .iter()
80        .map(|p| Bytes::copy_from_slice(p))
81        .collect();
82
83    let call = executeBatchCall {
84        dest: dests,
85        values,
86        data: datas,
87    };
88    Ok(call.abi_encode())
89}
90
91// ---------------------------------------------------------------------------
92// ERC-4337 v0.7 UserOp struct + hashing + signing
93// ---------------------------------------------------------------------------
94
95/// ERC-4337 v0.7 UserOperation.
96///
97/// Field names match the Pimlico bundler v0.7 API exactly.
98/// Optional fields are serialized as null when absent.
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct UserOperationV7 {
102    pub sender: String,
103    pub nonce: String,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub factory: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub factory_data: Option<String>,
108    pub call_data: String,
109    pub call_gas_limit: String,
110    pub verification_gas_limit: String,
111    pub pre_verification_gas: String,
112    pub max_fee_per_gas: String,
113    pub max_priority_fee_per_gas: String,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub paymaster: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub paymaster_verification_gas_limit: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub paymaster_post_op_gas_limit: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub paymaster_data: Option<String>,
122    pub signature: String,
123}
124
125/// Compute the UserOp hash for signing (ERC-4337 v0.7 format).
126///
127/// v0.7 packing:
128///   hashStruct = keccak256(abi.encode(
129///     sender, nonce, keccak256(initCode), keccak256(callData),
130///     accountGasLimits, preVerificationGas, gasFees,
131///     keccak256(paymasterAndData)
132///   ))
133///   hash = keccak256(abi.encode(hashStruct, entryPoint, chainId))
134///
135/// Where:
136///   initCode = factory ? (factory + factoryData) : bytes(0)
137///   accountGasLimits = bytes32(verificationGasLimit << 128 | callGasLimit)
138///   gasFees = bytes32(maxPriorityFeePerGas << 128 | maxFeePerGas)
139///   paymasterAndData = paymaster ? (paymaster + pmVerificationGasLimit(16) + pmPostOpGasLimit(16) + paymasterData) : bytes(0)
140pub fn hash_userop(
141    userop: &UserOperationV7,
142    entrypoint: &str,
143    chain_id: u64,
144) -> Result<[u8; 32]> {
145    let decode_hex = |s: &str| -> Vec<u8> {
146        hex::decode(s.trim_start_matches("0x")).unwrap_or_default()
147    };
148
149    // Build initCode: factory(20) + factoryData
150    let init_code = if let Some(ref factory) = userop.factory {
151        let factory_bytes = decode_hex(factory);
152        let factory_data = userop
153            .factory_data
154            .as_deref()
155            .map(|s| decode_hex(s))
156            .unwrap_or_default();
157        let mut ic = factory_bytes;
158        ic.extend_from_slice(&factory_data);
159        ic
160    } else {
161        vec![]
162    };
163
164    // Build accountGasLimits: bytes32(verificationGasLimit(16) || callGasLimit(16))
165    let vgl = parse_hex_u128(&userop.verification_gas_limit);
166    let cgl = parse_hex_u128(&userop.call_gas_limit);
167    let mut account_gas_limits = [0u8; 32];
168    account_gas_limits[..16].copy_from_slice(&vgl.to_be_bytes());
169    account_gas_limits[16..].copy_from_slice(&cgl.to_be_bytes());
170
171    // preVerificationGas as uint256
172    let pvg = parse_hex_u128(&userop.pre_verification_gas);
173
174    // Build gasFees: bytes32(maxPriorityFeePerGas(16) || maxFeePerGas(16))
175    let mpfpg = parse_hex_u128(&userop.max_priority_fee_per_gas);
176    let mfpg = parse_hex_u128(&userop.max_fee_per_gas);
177    let mut gas_fees = [0u8; 32];
178    gas_fees[..16].copy_from_slice(&mpfpg.to_be_bytes());
179    gas_fees[16..].copy_from_slice(&mfpg.to_be_bytes());
180
181    // Build paymasterAndData: paymaster(20) + pmVerificationGasLimit(16) + pmPostOpGasLimit(16) + paymasterData
182    let paymaster_and_data = if let Some(ref pm) = userop.paymaster {
183        let pm_bytes = decode_hex(pm);
184        let pm_vgl = parse_hex_u128(
185            userop
186                .paymaster_verification_gas_limit
187                .as_deref()
188                .unwrap_or("0x0"),
189        );
190        let pm_pgl = parse_hex_u128(
191            userop
192                .paymaster_post_op_gas_limit
193                .as_deref()
194                .unwrap_or("0x0"),
195        );
196        let pm_data = userop
197            .paymaster_data
198            .as_deref()
199            .map(|s| decode_hex(s))
200            .unwrap_or_default();
201
202        let mut pad = pm_bytes;
203        pad.extend_from_slice(&pm_vgl.to_be_bytes());
204        pad.extend_from_slice(&pm_pgl.to_be_bytes());
205        pad.extend_from_slice(&pm_data);
206        pad
207    } else {
208        vec![]
209    };
210
211    // Pack: sender + nonce + keccak256(initCode) + keccak256(callData) +
212    //        accountGasLimits + preVerificationGas + gasFees + keccak256(paymasterAndData)
213    let mut packed = Vec::new();
214    // sender (address, 32 bytes padded)
215    packed.extend_from_slice(&[0u8; 12]);
216    packed.extend_from_slice(&decode_hex(&userop.sender));
217    // nonce (uint256 -- can exceed u64, e.g. with non-zero nonce keys)
218    let nonce_hex_str = userop.nonce.trim_start_matches("0x");
219    // Pad to even length for hex decoding
220    let nonce_padded = if nonce_hex_str.len() % 2 != 0 {
221        format!("0{}", nonce_hex_str)
222    } else {
223        nonce_hex_str.to_string()
224    };
225    let nonce_raw = hex::decode(&nonce_padded).unwrap_or_default();
226    let mut nonce_bytes = [0u8; 32];
227    if !nonce_raw.is_empty() && nonce_raw.len() <= 32 {
228        nonce_bytes[32 - nonce_raw.len()..].copy_from_slice(&nonce_raw);
229    }
230    packed.extend_from_slice(&nonce_bytes);
231    // keccak256(initCode)
232    packed.extend_from_slice(&keccak256_hash(&init_code));
233    // keccak256(callData)
234    packed.extend_from_slice(&keccak256_hash(&decode_hex(&userop.call_data)));
235    // accountGasLimits (bytes32)
236    packed.extend_from_slice(&account_gas_limits);
237    // preVerificationGas (uint256)
238    let mut pvg_bytes = [0u8; 32];
239    pvg_bytes[16..].copy_from_slice(&pvg.to_be_bytes());
240    packed.extend_from_slice(&pvg_bytes);
241    // gasFees (bytes32)
242    packed.extend_from_slice(&gas_fees);
243    // keccak256(paymasterAndData)
244    packed.extend_from_slice(&keccak256_hash(&paymaster_and_data));
245
246    let inner_hash = keccak256_hash(&packed);
247
248    // abi.encode(innerHash, entryPoint, chainId)
249    let mut outer = Vec::with_capacity(96);
250    outer.extend_from_slice(&inner_hash);
251    let ep_bytes = hex::decode(entrypoint.trim_start_matches("0x"))
252        .map_err(|e| Error::Crypto(e.to_string()))?;
253    outer.extend_from_slice(&[0u8; 12]);
254    outer.extend_from_slice(&ep_bytes);
255    let mut chain_bytes = [0u8; 32];
256    chain_bytes[24..].copy_from_slice(&chain_id.to_be_bytes());
257    outer.extend_from_slice(&chain_bytes);
258
259    Ok(keccak256_hash(&outer))
260}
261
262/// Sign a UserOp hash with a private key (ECDSA + EIP-191 prefix).
263///
264/// The SimpleAccount's _validateSignature applies toEthSignedMessageHash
265/// internally, so we must sign the EIP-191 prefixed hash:
266///   keccak256("\x19Ethereum Signed Message:\n32" + userOpHash)
267///
268/// Returns a 65-byte signature: r (32) + s (32) + v (1, 27 or 28).
269pub fn sign_userop(hash: &[u8; 32], private_key: &[u8; 32]) -> Result<Vec<u8>> {
270    use k256::ecdsa::SigningKey;
271
272    let signing_key = SigningKey::from_bytes(private_key.into())
273        .map_err(|e| Error::Crypto(format!("Invalid signing key: {}", e)))?;
274
275    // EIP-191: "\x19Ethereum Signed Message:\n32" + hash
276    let mut prefixed = Vec::with_capacity(60);
277    prefixed.extend_from_slice(b"\x19Ethereum Signed Message:\n32");
278    prefixed.extend_from_slice(hash);
279    let msg_hash = keccak256_hash(&prefixed);
280
281    let (sig, recovery_id) = signing_key
282        .sign_prehash_recoverable(&msg_hash)
283        .map_err(|e| Error::Crypto(format!("Signing failed: {}", e)))?;
284
285    // Encode as r (32) + s (32) + v (1)
286    let mut signature = Vec::with_capacity(65);
287    signature.extend_from_slice(&sig.to_bytes());
288    signature.push(recovery_id.to_byte() + 27); // v = 27 or 28
289
290    Ok(signature)
291}
292
293// ---------------------------------------------------------------------------
294// Internal helpers
295// ---------------------------------------------------------------------------
296
297fn parse_hex_u128(hex_str: &str) -> u128 {
298    u128::from_str_radix(hex_str.trim_start_matches("0x"), 16).unwrap_or(0)
299}
300
301fn keccak256_hash(data: &[u8]) -> [u8; 32] {
302    use tiny_keccak::{Hasher, Keccak};
303    let mut keccak = Keccak::v256();
304    let mut hash = [0u8; 32];
305    keccak.update(data);
306    keccak.finalize(&mut hash);
307    hash
308}
309
310// ---------------------------------------------------------------------------
311// Tests
312// ---------------------------------------------------------------------------
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_encode_single_call() {
320        let payload = b"test protobuf data";
321        let encoded = encode_single_call(payload);
322        // Should start with execute() selector 0xb61d27f6
323        assert_eq!(&encoded[..4], &[0xb6, 0x1d, 0x27, 0xf6]);
324        assert!(encoded.len() > 100); // ABI encoding adds padding
325    }
326
327    #[test]
328    fn test_encode_batch_call() {
329        let payloads = vec![
330            b"fact one".to_vec(),
331            b"fact two".to_vec(),
332            b"fact three".to_vec(),
333        ];
334        let encoded = encode_batch_call(&payloads).unwrap();
335        // Should start with executeBatch(address[],uint256[],bytes[]) selector 0x47e1da2a
336        assert_eq!(&encoded[..4], &[0x47, 0xe1, 0xda, 0x2a]);
337    }
338
339    #[test]
340    fn test_single_payload_uses_execute() {
341        let payloads = vec![b"single fact".to_vec()];
342        let encoded = encode_batch_call(&payloads).unwrap();
343        // Single payload should use execute(), not executeBatch()
344        assert_eq!(&encoded[..4], &[0xb6, 0x1d, 0x27, 0xf6]);
345    }
346
347    #[test]
348    fn test_empty_batch_rejected() {
349        let result = encode_batch_call(&[]);
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn test_oversized_batch_rejected() {
355        let payloads: Vec<Vec<u8>> = (0..16).map(|i| vec![i as u8]).collect();
356        let result = encode_batch_call(&payloads);
357        assert!(result.is_err());
358    }
359
360    #[test]
361    fn test_userop_hash_v7_parity() {
362        // Same UserOp as the viem reference test
363        let userop = UserOperationV7 {
364            sender: "0x949bc374325a4f41e46e8e78a07d910332934542".to_string(),
365            nonce: "0x0".to_string(),
366            factory: Some("0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985".to_string()),
367            factory_data: Some("0x5fbfb9cf0000000000000000000000008eb626f727e92a73435f2b85dd6fd0c6da5dbb720000000000000000000000000000000000000000000000000000000000000000".to_string()),
368            call_data: "0xb61d27f6".to_string(),
369            call_gas_limit: "0x186a0".to_string(),     // 100000
370            verification_gas_limit: "0x30d40".to_string(), // 200000
371            pre_verification_gas: "0xc350".to_string(),    // 50000
372            max_fee_per_gas: "0xf4240".to_string(),        // 1000000
373            max_priority_fee_per_gas: "0x7a120".to_string(), // 500000
374            paymaster: Some("0x0000000000000039cd5e8ae05257ce51c473ddd1".to_string()),
375            paymaster_verification_gas_limit: Some("0x186a0".to_string()), // 100000
376            paymaster_post_op_gas_limit: Some("0xc350".to_string()),       // 50000
377            paymaster_data: Some("0xabcd".to_string()),
378            signature: format!("0x{}", "00".repeat(65)),
379        };
380
381        let hash = hash_userop(
382            &userop,
383            "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
384            84532,
385        )
386        .unwrap();
387
388        assert_eq!(
389            format!("0x{}", hex::encode(hash)),
390            "0x4525d2a8a555a1a56f6313735b83fe3ee55f81d504d905ea85613524973f97c2",
391            "v0.7 UserOp hash must match viem's getUserOperationHash"
392        );
393    }
394
395    #[test]
396    fn test_signing_parity() {
397        // Use the same test key as viem test above (Hardhat account #0)
398        let private_key_hex = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
399        let mut private_key = [0u8; 32];
400        private_key.copy_from_slice(&hex::decode(private_key_hex).unwrap());
401
402        let hash_hex = "1b25552f7901991cd4e2793945f694a09c9d0b9454a86cee16123ac9e84bd2de";
403        let mut hash = [0u8; 32];
404        hash.copy_from_slice(&hex::decode(hash_hex).unwrap());
405
406        let sig = sign_userop(&hash, &private_key).unwrap();
407        let sig_hex = hex::encode(&sig);
408
409        // viem signature for this key + hash
410        assert_eq!(
411            sig_hex,
412            "24b6fabd386f1580aa1fc09b04dd274ea334a9bf63e4fc994e0bef9a505f618335cb2b7d20454a0526f5c66f52ed73b9e76e9696ab5959998e7fc3984fba91691c",
413            "Signature must match viem's signMessage({{message: {{raw: hash}}}})"
414        );
415    }
416
417    #[test]
418    fn test_signing_parity_abandon_mnemonic() {
419        // Private key for the "abandon...about" mnemonic
420        let private_key_hex = "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727";
421        let mut private_key = [0u8; 32];
422        private_key.copy_from_slice(&hex::decode(private_key_hex).unwrap());
423
424        // Real UserOp hash from the E2E test
425        let hash_hex = "6de60c2ca586227294ffce39e30a3c6ec8ddf6ae01d0d579344e8d2e2dbf8b26";
426        let mut hash = [0u8; 32];
427        hash.copy_from_slice(&hex::decode(hash_hex).unwrap());
428
429        let sig = sign_userop(&hash, &private_key).unwrap();
430        let sig_hex = hex::encode(&sig);
431
432        assert_eq!(
433            sig_hex,
434            "a5ad7388dd018236a6cfc25556f35d0d05fff7a9a59ef29fef65b1855298f767107418521a5ca48e56a4d5de67e954df5d6dd49fe98eba3d1c45ad22eeae3fd11c",
435            "Signature must match viem's signMessage for abandon mnemonic"
436        );
437    }
438
439    #[test]
440    fn test_keccak256_hash() {
441        let hash = keccak256_hash(b"hello");
442        assert_eq!(
443            hex::encode(hash),
444            "1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"
445        );
446    }
447
448    #[test]
449    fn test_userop_hash_v7_real_paymaster() {
450        // Real UserOp from a successful viem/permissionless submission
451        // against staging (Base Sepolia, chain 84532).
452        let userop = UserOperationV7 {
453            sender: "0x695241674733a452a5373b16baf2dc2d9435be8e".to_string(),
454            nonce: "0x0".to_string(),
455            factory: Some("0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985".to_string()),
456            factory_data: Some("0x5fbfb9cf000000000000000000000000cd894ed607b25d52e9ac776cf48e9407d3a263d30000000000000000000000000000000000000000000000000000000000000000".to_string()),
457            call_data: "0xb61d27f6000000000000000000000000c445af1d4eb9fce4e1e61fe96ea7b8febf03c5ca000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000".to_string(),
458            call_gas_limit: "0x4623".to_string(),
459            verification_gas_limit: "0x41bab".to_string(),
460            pre_verification_gas: "0xc9c9".to_string(),
461            max_fee_per_gas: "0x757e20".to_string(),
462            max_priority_fee_per_gas: "0x10c8e0".to_string(),
463            paymaster: Some("0x777777777777AeC03fd955926DbF81597e66834C".to_string()),
464            paymaster_verification_gas_limit: Some("0x8a8e".to_string()),
465            paymaster_post_op_gas_limit: Some("0x1".to_string()),
466            paymaster_data: Some("0x01000069cb37390000000000006568f8cf98f823f68c4fedbde90b241f30e9323b436eeb3cddeb688e0859b23565005402808774472237b1808f0006721bd065729a72a84710fdceaa465737f61c".to_string()),
467            signature: format!("0x{}", "00".repeat(65)),
468        };
469
470        let hash = hash_userop(
471            &userop,
472            "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
473            84532,
474        )
475        .unwrap();
476
477        assert_eq!(
478            format!("0x{}", hex::encode(hash)),
479            "0x3d4467d9a3c070eea659ef9a7cf42f1b4e87e14cd91792d869faf031b6fea3e8",
480            "v0.7 hash with real paymaster data must match viem"
481        );
482    }
483
484    #[test]
485    fn test_nonce_uint256_parsing() {
486        // Test that large nonces (used by viem with non-zero nonce keys) parse correctly
487        let userop = UserOperationV7 {
488            sender: "0x949bc374325a4f41e46e8e78a07d910332934542".to_string(),
489            nonce: "0x19d41c68d5e0000000000000000".to_string(), // Large nonce key
490            factory: None,
491            factory_data: None,
492            call_data: "0xb61d27f6".to_string(),
493            call_gas_limit: "0x186a0".to_string(),
494            verification_gas_limit: "0x30d40".to_string(),
495            pre_verification_gas: "0xc350".to_string(),
496            max_fee_per_gas: "0xf4240".to_string(),
497            max_priority_fee_per_gas: "0x7a120".to_string(),
498            paymaster: None,
499            paymaster_verification_gas_limit: None,
500            paymaster_post_op_gas_limit: None,
501            paymaster_data: None,
502            signature: format!("0x{}", "00".repeat(65)),
503        };
504
505        // Should not panic or produce zero hash
506        let hash = hash_userop(
507            &userop,
508            "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
509            84532,
510        )
511        .unwrap();
512        assert_ne!(hex::encode(hash), "0".repeat(64));
513    }
514}