Skip to main content

polyester/chain/
userop.rs

1//! ERC-4337 EntryPoint v0.7 Safe UserOperation helpers (Pimlico-compatible).
2
3use 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/// Receipt for a submitted UserOperation.
49#[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/// Result of [`PolyesterSmartAccount::send_calls`].
58#[derive(Debug, Clone)]
59pub enum SendCallsResult {
60    Hash(String),
61    Receipt(UserOperationReceipt),
62}
63
64/// Apply the standard gas buffer (20% or +50k, whichever is larger).
65pub 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
70/// Encode Safe4337Module.executeUserOpWithErrorString for a single call.
71pub 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
82/// Pack paymaster fields into EntryPoint v0.7 `paymasterAndData`.
83pub 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
101/// Stub signature used while requesting paymaster sponsorship.
102pub 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    // SEC1 uncompressed encoding: 0x04 || x || y (65 bytes)
123    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/// EIP-712 SafeOp signature packed as uint48/uint48/bytes (single EOA owner).
182#[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
250/// Owner-key smart account: derive Safe, build/sign/submit Funding UserOps.
251pub 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    /// Safe `setup` initializer used for CREATE2 prediction / undeployed initCode.
258    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    /// Return the next EntryPoint nonce.
308    ///
309    /// Matches viem/permissionless: when `key` is `None`, use a fresh
310    /// timestamp-based nonce key (`Date.now()` millis) so ops are not stuck on
311    /// key `0` (Polyester's bundler rejects some key-0 mempool submissions).
312    pub async fn get_nonce(&self, key: Option<u128>) -> Result<U256> {
313        // u128 always fits in uint192 (2^192-1).
314        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        // Sponsor once for estimates, buffer gas (incl. paymaster), then re-sponsor so
415        // paymasterData matches the final limits. Polyester's paymaster often returns
416        // paymasterPostOpGasLimit=1; without a floor the bundler accepts then rejects.
417        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        // Keep the exact buffered limits we asked the paymaster to cover. Taking
467        // higher sponsor-returned callGas without re-binding paymasterData causes
468        // bundler accept-then-reject.
469        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
597/// Alias matching the task naming (`SmartAccount`).
598pub type SmartAccount = PolyesterSmartAccount;
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn execute_user_op_selector() {
606        // executeUserOpWithErrorString
607        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        // getNonce(address,uint192)
626        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        // 20% of 1_000_000 = 200_000 > 50_000
637        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        // private key 0x01 → owner 0x7E5F4552...
648        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}