1use alloy_primitives::{Address, B256, Bytes, U256, keccak256};
4use alloy_sol_types::{SolCall, SolStruct, eip712_domain, sol};
5use k256::ecdsa::SigningKey;
6use serde_json::{Value, json};
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use crate::chain::calldata::ChainCall;
10use crate::chain::environment::{POLYESTER_TESTNET_ENVIRONMENT, PolyesterChainEnvironment};
11use crate::chain::rpc::JsonRpcClient;
12use crate::chain::safe::predict_safe_address_with_data;
13use crate::errors::{Error, Result};
14
15pub const USER_OPERATION_GAS_BUFFER_BPS: u64 = 2_000;
16pub const USER_OPERATION_MIN_GAS_BUFFER: u64 = 50_000;
17
18const STUB_ECDSA_SIGNATURE: [u8; 65] = [
19 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0,
20 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
21 0x7a, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
22 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
23 0x1c,
24];
25
26sol! {
27 function executeUserOpWithErrorString(address to, uint256 value, bytes data, uint8 operation);
28 function getNonce(address sender, uint192 key);
29
30 #[derive(Debug)]
31 struct SafeOp {
32 address safe;
33 uint256 nonce;
34 bytes initCode;
35 bytes callData;
36 uint128 verificationGasLimit;
37 uint128 callGasLimit;
38 uint256 preVerificationGas;
39 uint128 maxPriorityFeePerGas;
40 uint128 maxFeePerGas;
41 bytes paymasterAndData;
42 uint48 validAfter;
43 uint48 validUntil;
44 address entryPoint;
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct UserOperationReceipt {
51 pub user_operation_hash: String,
52 pub transaction_hash: String,
53 pub success: bool,
54 pub raw: Value,
55}
56
57#[derive(Debug, Clone)]
59pub enum SendCallsResult {
60 Hash(String),
61 Receipt(UserOperationReceipt),
62}
63
64pub fn add_user_operation_gas_buffer(gas: u64) -> u64 {
66 let percent = gas.saturating_mul(USER_OPERATION_GAS_BUFFER_BPS) / 10_000;
67 gas.saturating_add(percent.max(USER_OPERATION_MIN_GAS_BUFFER))
68}
69
70pub fn encode_execute_user_op_call_data(call: &ChainCall) -> Result<Vec<u8>> {
72 let to = parse_address(&call.to)?;
73 Ok(executeUserOpWithErrorStringCall {
74 to,
75 value: U256::from(call.value),
76 data: Bytes::copy_from_slice(&call.data),
77 operation: 0,
78 }
79 .abi_encode())
80}
81
82pub fn pack_paymaster_and_data(
84 paymaster: Option<&str>,
85 paymaster_verification_gas_limit: u64,
86 paymaster_post_op_gas_limit: u64,
87 paymaster_data: &[u8],
88) -> Result<Vec<u8>> {
89 let Some(paymaster) = paymaster else {
90 return Ok(Vec::new());
91 };
92 let addr = parse_address(paymaster)?;
93 let mut out = Vec::with_capacity(20 + 16 + 16 + paymaster_data.len());
94 out.extend_from_slice(addr.as_slice());
95 out.extend_from_slice(&u128::from(paymaster_verification_gas_limit).to_be_bytes());
96 out.extend_from_slice(&u128::from(paymaster_post_op_gas_limit).to_be_bytes());
97 out.extend_from_slice(paymaster_data);
98 Ok(out)
99}
100
101pub fn stub_signature() -> Vec<u8> {
103 let mut out = vec![0u8; 12];
104 out.extend_from_slice(&STUB_ECDSA_SIGNATURE);
105 out
106}
107
108fn parse_address(value: &str) -> Result<Address> {
109 let text = value.trim();
110 let hex = text.strip_prefix("0x").unwrap_or(text);
111 if hex.len() != 40 {
112 return Err(Error::validation(
113 "address must be a 20-byte 0x-prefixed hex string",
114 ));
115 }
116 let raw = hex::decode(hex).map_err(|_| Error::validation("address is not valid hex"))?;
117 Ok(Address::from_slice(&raw))
118}
119
120fn address_from_signing_key(key: &SigningKey) -> Address {
121 let vk = key.verifying_key();
122 let encoded = vk.to_encoded_point(false);
124 let hash = keccak256(&encoded.as_bytes()[1..]);
125 Address::from_slice(&hash[12..])
126}
127
128fn parse_private_key(owner_private_key: &str) -> Result<SigningKey> {
129 let hex = owner_private_key
130 .trim()
131 .strip_prefix("0x")
132 .unwrap_or(owner_private_key.trim());
133 let raw =
134 hex::decode(hex).map_err(|_| Error::validation("owner_private_key is not valid hex"))?;
135 if raw.len() != 32 {
136 return Err(Error::validation("owner_private_key must be 32 bytes"));
137 }
138 SigningKey::from_slice(&raw)
139 .map_err(|e| Error::validation(format!("invalid secp256k1 private key: {e}")))
140}
141
142fn hex_int(value: u64) -> String {
143 format!("0x{value:x}")
144}
145
146fn hex_u256(value: U256) -> String {
147 format!("0x{value:x}")
148}
149
150fn as_u64(value: &Value) -> Result<u64> {
151 match value {
152 Value::Number(n) => n
153 .as_u64()
154 .ok_or_else(|| Error::transport("numeric field exceeds u64")),
155 Value::String(s) => {
156 let text = s.trim();
157 if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
158 u64::from_str_radix(hex, 16)
159 .map_err(|e| Error::transport(format!("invalid hex int: {e}")))
160 } else {
161 text.parse::<u64>()
162 .map_err(|e| Error::transport(format!("invalid int: {e}")))
163 }
164 }
165 _ => Err(Error::transport(format!("cannot convert {value} to int"))),
166 }
167}
168
169fn encode_hex(data: &[u8]) -> String {
170 format!("0x{}", hex::encode(data))
171}
172
173fn decode_hex_bytes(value: &str) -> Result<Vec<u8>> {
174 let hex = value.strip_prefix("0x").unwrap_or(value);
175 if hex.is_empty() {
176 return Ok(Vec::new());
177 }
178 hex::decode(hex).map_err(|e| Error::transport(format!("invalid hex bytes: {e}")))
179}
180
181#[allow(clippy::too_many_arguments)]
183pub fn sign_safe_user_operation(
184 signing_key: &SigningKey,
185 environment: &PolyesterChainEnvironment,
186 sender: &str,
187 nonce: U256,
188 init_code: &[u8],
189 call_data: &[u8],
190 call_gas_limit: u64,
191 verification_gas_limit: u64,
192 pre_verification_gas: u64,
193 max_fee_per_gas: u64,
194 max_priority_fee_per_gas: u64,
195 paymaster_and_data: &[u8],
196 valid_after: u64,
197 valid_until: u64,
198) -> Result<Vec<u8>> {
199 let module = parse_address(
200 environment
201 .account_abstraction
202 .safe
203 .safe_4337_module_address,
204 )?;
205 let entry_point = parse_address(environment.account_abstraction.entry_point.address)?;
206 let safe = parse_address(sender)?;
207
208 if valid_after > (1u64 << 48) - 1 || valid_until > (1u64 << 48) - 1 {
209 return Err(Error::validation("uint48 overflow"));
210 }
211
212 let message = SafeOp {
213 safe,
214 nonce,
215 initCode: Bytes::copy_from_slice(init_code),
216 callData: Bytes::copy_from_slice(call_data),
217 verificationGasLimit: u128::from(verification_gas_limit),
218 callGasLimit: u128::from(call_gas_limit),
219 preVerificationGas: U256::from(pre_verification_gas),
220 maxPriorityFeePerGas: u128::from(max_priority_fee_per_gas),
221 maxFeePerGas: u128::from(max_fee_per_gas),
222 paymasterAndData: Bytes::copy_from_slice(paymaster_and_data),
223 validAfter: alloy_primitives::Uint::<48, 1>::from(valid_after),
224 validUntil: alloy_primitives::Uint::<48, 1>::from(valid_until),
225 entryPoint: entry_point,
226 };
227
228 let domain = eip712_domain! {
229 chain_id: environment.chain_id,
230 verifying_contract: module,
231 };
232 let hash: B256 = message.eip712_signing_hash(&domain);
233 let (sig, recid) = signing_key
234 .sign_prehash_recoverable(hash.as_slice())
235 .map_err(|e| Error::validation(format!("failed to sign SafeOp: {e}")))?;
236
237 let mut packed = Vec::with_capacity(12 + 65);
238 packed.extend_from_slice(&u48_be_bytes(valid_after));
239 packed.extend_from_slice(&u48_be_bytes(valid_until));
240 packed.extend_from_slice(&sig.to_bytes());
241 packed.push(u8::from(recid) + 27);
242 Ok(packed)
243}
244
245fn u48_be_bytes(value: u64) -> [u8; 6] {
246 let full = value.to_be_bytes();
247 [full[2], full[3], full[4], full[5], full[6], full[7]]
248}
249
250pub struct PolyesterSmartAccount {
252 signing_key: SigningKey,
253 pub environment: PolyesterChainEnvironment,
254 pub salt_nonce: u64,
255 pub address: String,
256 pub owner_address: String,
257 pub initializer: Vec<u8>,
259 factory_calldata: Vec<u8>,
260 rpc: JsonRpcClient,
261 bundler: JsonRpcClient,
262 paymaster: JsonRpcClient,
263}
264
265impl PolyesterSmartAccount {
266 pub fn new(
267 owner_private_key: &str,
268 environment: Option<PolyesterChainEnvironment>,
269 salt_nonce: u64,
270 timeout: Duration,
271 ) -> Result<Self> {
272 let signing_key = parse_private_key(owner_private_key)?;
273 let owner = address_from_signing_key(&signing_key);
274 let owner_address = owner.to_checksum(None);
275 let environment = environment.unwrap_or_else(|| POLYESTER_TESTNET_ENVIRONMENT.clone());
276 let predicted = predict_safe_address_with_data(
277 &[&owner_address],
278 salt_nonce,
279 None,
280 None,
281 Some(&environment),
282 )?;
283 let aa = &environment.account_abstraction;
284 Ok(Self {
285 signing_key,
286 rpc: JsonRpcClient::new(environment.rpc_url, timeout),
287 bundler: JsonRpcClient::new(aa.bundler_url, timeout),
288 paymaster: JsonRpcClient::new(aa.paymaster_url, timeout),
289 environment,
290 salt_nonce,
291 address: predicted.address,
292 owner_address,
293 initializer: predicted.initializer,
294 factory_calldata: predicted.factory_calldata,
295 })
296 }
297
298 pub async fn is_deployed(&self) -> Result<bool> {
299 let code = self
300 .rpc
301 .request("eth_getCode", json!([self.address, "latest"]))
302 .await?;
303 let text = code.as_str().unwrap_or("");
304 Ok(!matches!(text, "" | "0x" | "0x0"))
305 }
306
307 pub async fn get_nonce(&self, key: Option<u128>) -> Result<U256> {
313 let nonce_key = match key {
315 Some(k) => k,
316 None => SystemTime::now()
317 .duration_since(UNIX_EPOCH)
318 .map_err(|e| Error::transport(format!("system clock error: {e}")))?
319 .as_millis(),
320 };
321 let ep = self.environment.account_abstraction.entry_point.address;
322 let data = getNonceCall {
323 sender: parse_address(&self.address)?,
324 key: alloy_primitives::Uint::<192, 3>::from(nonce_key),
325 }
326 .abi_encode();
327 let result = self
328 .rpc
329 .request(
330 "eth_call",
331 json!([{ "to": ep, "data": encode_hex(&data) }, "latest"]),
332 )
333 .await?;
334 let hex = result
335 .as_str()
336 .ok_or_else(|| Error::transport("eth_call returned non-string"))?;
337 let raw = decode_hex_bytes(hex)?;
338 if raw.len() > 32 {
339 return Err(Error::transport("eth_call nonce overflow"));
340 }
341 let mut word = [0u8; 32];
342 word[32 - raw.len()..].copy_from_slice(&raw);
343 Ok(U256::from_be_bytes(word))
344 }
345
346 pub async fn send_calls(
347 &self,
348 calls: &[ChainCall],
349 wait: bool,
350 receipt_timeout: Duration,
351 ) -> Result<SendCallsResult> {
352 if calls.is_empty() {
353 return Err(Error::validation("at least one call is required"));
354 }
355 if calls.len() != 1 {
356 return Err(Error::validation(
357 "multi-call UserOps are not implemented yet; submit one ChainCall at a time",
358 ));
359 }
360 let call_data = encode_execute_user_op_call_data(&calls[0])?;
361
362 let deployed = self.is_deployed().await?;
363 let mut factory: Option<String> = None;
364 let mut factory_data: Option<Vec<u8>> = None;
365 let mut init_code = Vec::new();
366 if !deployed {
367 let factory_addr = self
368 .environment
369 .account_abstraction
370 .safe
371 .safe_proxy_factory_address;
372 factory = Some(parse_address(factory_addr)?.to_checksum(None));
373 factory_data = Some(self.factory_calldata.clone());
374 let mut code = parse_address(factory_addr)?.as_slice().to_vec();
375 code.extend_from_slice(&self.factory_calldata);
376 init_code = code;
377 }
378
379 let nonce = self.get_nonce(None).await?;
380 let gas_price = self
381 .paymaster
382 .request("pimlico_getUserOperationGasPrice", json!([]))
383 .await?;
384 let fast = gas_price
385 .get("fast")
386 .ok_or_else(|| Error::transport("gas price missing fast tier"))?;
387 let max_fee = as_u64(
388 fast.get("maxFeePerGas")
389 .ok_or_else(|| Error::transport("missing maxFeePerGas"))?,
390 )?;
391 let max_prio = as_u64(
392 fast.get("maxPriorityFeePerGas")
393 .ok_or_else(|| Error::transport("missing maxPriorityFeePerGas"))?,
394 )?;
395
396 let mut user_op = json!({
397 "sender": self.address,
398 "nonce": hex_u256(nonce),
399 "callData": encode_hex(&call_data),
400 "callGasLimit": hex_int(0),
401 "verificationGasLimit": hex_int(0),
402 "preVerificationGas": hex_int(0),
403 "maxFeePerGas": hex_int(max_fee),
404 "maxPriorityFeePerGas": hex_int(max_prio),
405 "signature": encode_hex(&stub_signature()),
406 });
407 if let (Some(f), Some(fd)) = (&factory, &factory_data) {
408 user_op["factory"] = json!(f);
409 user_op["factoryData"] = json!(encode_hex(fd));
410 }
411
412 let entry_point = self.environment.account_abstraction.entry_point.address;
413
414 let sponsored = self
418 .paymaster
419 .request("pm_sponsorUserOperation", json!([user_op, entry_point]))
420 .await?;
421
422 let call_gas = add_user_operation_gas_buffer(as_u64(
423 sponsored
424 .get("callGasLimit")
425 .ok_or_else(|| Error::transport("sponsored missing callGasLimit"))?,
426 )?);
427 let verification_gas = add_user_operation_gas_buffer(as_u64(
428 sponsored
429 .get("verificationGasLimit")
430 .ok_or_else(|| Error::transport("sponsored missing verificationGasLimit"))?,
431 )?);
432 let pre_verification = add_user_operation_gas_buffer(as_u64(
433 sponsored
434 .get("preVerificationGas")
435 .ok_or_else(|| Error::transport("sponsored missing preVerificationGas"))?,
436 )?);
437 let pm_ver = add_user_operation_gas_buffer(
438 sponsored
439 .get("paymasterVerificationGasLimit")
440 .map(as_u64)
441 .transpose()?
442 .unwrap_or(0),
443 )
444 .max(USER_OPERATION_MIN_GAS_BUFFER);
445 let pm_post = add_user_operation_gas_buffer(
446 sponsored
447 .get("paymasterPostOpGasLimit")
448 .map(as_u64)
449 .transpose()?
450 .unwrap_or(0),
451 )
452 .max(USER_OPERATION_MIN_GAS_BUFFER * 2);
453
454 let mut buffered_op = user_op.clone();
455 buffered_op["callGasLimit"] = json!(hex_int(call_gas));
456 buffered_op["verificationGasLimit"] = json!(hex_int(verification_gas));
457 buffered_op["preVerificationGas"] = json!(hex_int(pre_verification));
458 buffered_op["paymasterVerificationGasLimit"] = json!(hex_int(pm_ver));
459 buffered_op["paymasterPostOpGasLimit"] = json!(hex_int(pm_post));
460
461 let sponsored = self
462 .paymaster
463 .request("pm_sponsorUserOperation", json!([buffered_op, entry_point]))
464 .await?;
465
466 let paymaster = sponsored
470 .get("paymaster")
471 .and_then(|v| v.as_str())
472 .map(|s| s.to_string());
473 let pm_data_hex = sponsored
474 .get("paymasterData")
475 .and_then(|v| v.as_str())
476 .unwrap_or("0x");
477 let pm_data_bytes = decode_hex_bytes(pm_data_hex)?;
478
479 let paymaster_and_data =
480 pack_paymaster_and_data(paymaster.as_deref(), pm_ver, pm_post, &pm_data_bytes)?;
481 let signature = sign_safe_user_operation(
482 &self.signing_key,
483 &self.environment,
484 &self.address,
485 nonce,
486 &init_code,
487 &call_data,
488 call_gas,
489 verification_gas,
490 pre_verification,
491 max_fee,
492 max_prio,
493 &paymaster_and_data,
494 0,
495 0,
496 )?;
497
498 let mut final_op = json!({
499 "sender": self.address,
500 "nonce": hex_u256(nonce),
501 "callData": encode_hex(&call_data),
502 "callGasLimit": hex_int(call_gas),
503 "verificationGasLimit": hex_int(verification_gas),
504 "preVerificationGas": hex_int(pre_verification),
505 "maxFeePerGas": hex_int(max_fee),
506 "maxPriorityFeePerGas": hex_int(max_prio),
507 "signature": encode_hex(&signature),
508 });
509 if let (Some(f), Some(fd)) = (&factory, &factory_data) {
510 final_op["factory"] = json!(f);
511 final_op["factoryData"] = json!(encode_hex(fd));
512 }
513 if let Some(pm) = &paymaster {
514 final_op["paymaster"] = json!(parse_address(pm)?.to_checksum(None));
515 final_op["paymasterVerificationGasLimit"] = json!(hex_int(pm_ver));
516 final_op["paymasterPostOpGasLimit"] = json!(hex_int(pm_post));
517 final_op["paymasterData"] = json!(encode_hex(&pm_data_bytes));
518 }
519
520 let user_op_hash = self
521 .bundler
522 .request("eth_sendUserOperation", json!([final_op, entry_point]))
523 .await?;
524 let hash = user_op_hash
525 .as_str()
526 .ok_or_else(|| Error::transport("eth_sendUserOperation returned non-string"))?
527 .to_string();
528 if !wait {
529 return Ok(SendCallsResult::Hash(hash));
530 }
531 Ok(SendCallsResult::Receipt(
532 self.wait_for_receipt(&hash, receipt_timeout, Duration::from_secs(1))
533 .await?,
534 ))
535 }
536
537 pub async fn wait_for_receipt(
538 &self,
539 user_operation_hash: &str,
540 timeout: Duration,
541 poll_interval: Duration,
542 ) -> Result<UserOperationReceipt> {
543 let deadline = Instant::now() + timeout;
544 while Instant::now() < deadline {
545 let raw = self
546 .bundler
547 .request("eth_getUserOperationReceipt", json!([user_operation_hash]))
548 .await?;
549 if !raw.is_null() {
550 let receipt = raw.get("receipt").cloned().unwrap_or(Value::Null);
551 let success = raw
552 .get("success")
553 .and_then(|v| v.as_bool())
554 .unwrap_or_else(|| {
555 matches!(
556 receipt.get("status"),
557 Some(Value::Number(n)) if n.as_u64() == Some(1)
558 ) || matches!(
559 receipt.get("status").and_then(|v| v.as_str()),
560 Some("0x1" | "0x01")
561 )
562 });
563 let tx_hash = receipt
564 .get("transactionHash")
565 .or_else(|| raw.get("transactionHash"))
566 .and_then(|v| v.as_str())
567 .unwrap_or("")
568 .to_string();
569 return Ok(UserOperationReceipt {
570 user_operation_hash: user_operation_hash.to_string(),
571 transaction_hash: tx_hash,
572 success,
573 raw,
574 });
575 }
576 if let Ok(status) = self
577 .bundler
578 .request(
579 "pimlico_getUserOperationStatus",
580 json!([user_operation_hash]),
581 )
582 .await
583 && status.get("status").and_then(|v| v.as_str()) == Some("rejected")
584 {
585 return Err(Error::transport(format!(
586 "bundler rejected UserOperation {user_operation_hash}: {status}"
587 )));
588 }
589 tokio::time::sleep(poll_interval).await;
590 }
591 Err(Error::transport(format!(
592 "timed out waiting for UserOperation receipt {user_operation_hash}"
593 )))
594 }
595}
596
597pub type SmartAccount = PolyesterSmartAccount;
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn execute_user_op_selector() {
606 assert_eq!(
608 hex::encode(executeUserOpWithErrorStringCall::SELECTOR),
609 "541d63c8"
610 );
611 let call = ChainCall {
612 to: "0x1111111111111111111111111111111111111111".into(),
613 data: vec![0xab, 0xcd],
614 value: 0,
615 };
616 let encoded = encode_execute_user_op_call_data(&call).unwrap();
617 assert_eq!(
618 &encoded[..4],
619 executeUserOpWithErrorStringCall::SELECTOR.as_slice()
620 );
621 }
622
623 #[test]
624 fn get_nonce_selector() {
625 assert_eq!(hex::encode(getNonceCall::SELECTOR), "35567e1a");
627 }
628
629 #[test]
630 fn gas_buffer_applies_minimum() {
631 assert_eq!(add_user_operation_gas_buffer(100), 50_100);
632 }
633
634 #[test]
635 fn gas_buffer_applies_percent_when_larger() {
636 assert_eq!(add_user_operation_gas_buffer(1_000_000), 1_200_000);
638 }
639
640 #[test]
641 fn stub_signature_length() {
642 assert_eq!(stub_signature().len(), 12 + 65);
643 }
644
645 #[test]
646 fn smart_account_predicts_safe_from_key_one() {
647 let account = PolyesterSmartAccount::new(
649 "0x0000000000000000000000000000000000000000000000000000000000000001",
650 None,
651 0,
652 Duration::from_secs(60),
653 )
654 .unwrap();
655 assert_eq!(
656 account.owner_address,
657 "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"
658 );
659 assert_eq!(
660 account.address,
661 "0xA244Ed1dc6B46C75F37E0119054fFa45E76c9B6f"
662 );
663 assert!(hex::encode(&account.initializer).starts_with("b63e800d"));
664 }
665}