From 0b634c4c62fad8a91808ae9fcb2f1c948dbf479e Mon Sep 17 00:00:00 2001 From: dimxy Date: Fri, 7 Nov 2025 17:51:43 +0500 Subject: [PATCH 1/4] UserOps with 7702 delegation works --- mm2src/coins/eth/eth_utils.rs | 2 ++ mm2src/coins/eth/for_tests.rs | 1 + mm2src/coins/eth/v2_activation.rs | 3 +++ mm2src/mm2_eth/src/eip712.rs | 7 ++++-- mm2src/mm2_eth/src/eip712_encode.rs | 39 ++++++++++++++++++++++++----- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/mm2src/coins/eth/eth_utils.rs b/mm2src/coins/eth/eth_utils.rs index c2c67644e0..fdfdffa54d 100644 --- a/mm2src/coins/eth/eth_utils.rs +++ b/mm2src/coins/eth/eth_utils.rs @@ -18,6 +18,8 @@ pub(super) const GAS_PRICE_ADJUST: &str = "gas_price_adjust"; pub(super) const ESTIMATE_GAS_MULT: &str = "estimate_gas_mult"; /// Coin config parameter name for the default eth swap gas fee policy pub(super) const SWAP_GAS_FEE_POLICY: &str = "swap_gas_fee_policy"; +/// Bundler service url +pub(super) const EIP4337_URL: &str = "eip4337_url"; pub(crate) mod nonce_sequencer { use super::*; diff --git a/mm2src/coins/eth/for_tests.rs b/mm2src/coins/eth/for_tests.rs index 42337fe356..599cb1e642 100644 --- a/mm2src/coins/eth/for_tests.rs +++ b/mm2src/coins/eth/for_tests.rs @@ -96,6 +96,7 @@ pub(crate) fn eth_coin_from_keypair( contract_supports_watchers: false, ticker, web3_instances: AsyncMutex::new(web3_instances), + eip4337_rpc: None, ctx: ctx.weak(), required_confirmations: 1.into(), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), diff --git a/mm2src/coins/eth/v2_activation.rs b/mm2src/coins/eth/v2_activation.rs index 702946dbbe..275e7b85ae 100644 --- a/mm2src/coins/eth/v2_activation.rs +++ b/mm2src/coins/eth/v2_activation.rs @@ -539,6 +539,7 @@ impl EthCoin { decimals, ticker, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), + eip4337_rpc: None, history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, @@ -635,6 +636,7 @@ impl EthCoin { fallback_swap_contract: self.fallback_swap_contract, contract_supports_watchers: self.contract_supports_watchers, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), + eip4337_rpc: None, decimals: self.decimals, history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), @@ -779,6 +781,7 @@ pub async fn eth_coin_from_conf_and_request_v2( decimals: ETH_DECIMALS, ticker: ticker.to_string(), web3_instances: AsyncMutex::new(web3_instances), + eip4337_rpc: None, history_sync_state: Mutex::new(HistorySyncState::NotEnabled), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, diff --git a/mm2src/mm2_eth/src/eip712.rs b/mm2src/mm2_eth/src/eip712.rs index bca185225f..3d3760da6a 100644 --- a/mm2src/mm2_eth/src/eip712.rs +++ b/mm2src/mm2_eth/src/eip712.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt; use std::str::FromStr; -pub(crate) const EIP712_DOMAIN: &str = "EIP712Domain"; +pub const EIP712_DOMAIN: &str = "EIP712Domain"; pub(crate) type CustomTypes = HashMap>; @@ -36,7 +36,7 @@ pub(crate) type CustomTypes = HashMap>; /// let mut mail_type = ObjectType::new("Mail"); /// mail_type.property("message", PropertyType::String); /// mail_type.property("from", PropertyType::Custom("Person".into())); -/// mail_type.property_array("to", PropertyType::Custom("Person".into())); +/// mail_type.property("to", PropertyType::Custom("Person".into())); /// /// let mut person_type = ObjectType::new("Person"); /// person_type.property("address", PropertyType::Address); @@ -85,6 +85,7 @@ pub enum PropertyType { String, Uint256, Address, + Bytes, Bytes32, Custom(String), } @@ -96,6 +97,7 @@ impl fmt::Display for PropertyType { PropertyType::String => write!(f, "string"), PropertyType::Uint256 => write!(f, "uint256"), PropertyType::Address => write!(f, "address"), + PropertyType::Bytes => write!(f, "bytes"), PropertyType::Bytes32 => write!(f, "bytes32"), PropertyType::Custom(custom) => write!(f, "{custom}"), } @@ -109,6 +111,7 @@ impl FromStr for PropertyType { let property_type = match s { "bool" => PropertyType::Bool, "string" => PropertyType::String, + "bytes" => PropertyType::Bytes, "uint256" => PropertyType::Uint256, "address" => PropertyType::Address, "bytes32" => PropertyType::Bytes32, diff --git a/mm2src/mm2_eth/src/eip712_encode.rs b/mm2src/mm2_eth/src/eip712_encode.rs index a83873e94b..0d9e60df05 100644 --- a/mm2src/mm2_eth/src/eip712_encode.rs +++ b/mm2src/mm2_eth/src/eip712_encode.rs @@ -80,6 +80,7 @@ fn encode_data( match data_type { PropertyType::Bool => encode_bool(data, field_name), PropertyType::String => encode_string(data, field_name), + PropertyType::Bytes => encode_bytes(data, field_name), PropertyType::Uint256 => encode_u256(data, field_name), PropertyType::Address => encode_address(data, field_name), PropertyType::Bytes32 => encode_bytes32(data, field_name), @@ -114,12 +115,13 @@ fn encode_bytes32(value: &Json, field_name: Option<&str>) -> Result> { let string = value .as_str() .ok_or_else(|| expected_type_error("bytes32", value, field_name))?; - check_hex(string, field_name)?; + println!("encode_bytes32 string={}", string); + check_hex_prefix(&string, field_name)?; + check_hex_len(&string[2..], field_name, 32)?; - let bytes = hex::decode(string).map_err(|e| decode_error(e, field_name))?; - let hash = keccak256(&bytes).to_vec(); + let bytes = hex::decode(&string[2..]).map_err(|e| decode_error(e, field_name))?; - Ok(encode(&[Token::FixedBytes(hash)])) + Ok(encode(&[Token::FixedBytes(bytes)])) } fn encode_string(value: &Json, field_name: Option<&str>) -> Result> { @@ -131,6 +133,17 @@ fn encode_string(value: &Json, field_name: Option<&str>) -> Result> { Ok(encode(&[Token::FixedBytes(hash)])) } +fn encode_bytes(value: &Json, field_name: Option<&str>) -> Result> { + let string = value + .as_str() + .ok_or_else(|| expected_type_error("bytes", value, field_name))?; + check_hex_prefix(string, field_name)?; + let bytes = hex::decode(&string[2..]).map_err(|e| decode_error(e, field_name))?; + let hash = keccak256(bytes.as_ref()).to_vec(); + + Ok(encode(&[Token::FixedBytes(hash)])) +} + fn encode_bool(value: &Json, field_name: Option<&str>) -> Result> { let bin = value .as_bool() @@ -154,7 +167,7 @@ fn encode_u256(value: &Json, field_name: Option<&str>) -> Result> { let string = value .as_str() .ok_or_else(|| expected_type_error("str(u256)", value, field_name))?; - check_hex(string, field_name)?; + check_hex_prefix(string, field_name)?; let uint = U256::from_str(&string[2..]).map_err(|e| decode_error(e, field_name))?; Ok(encode(&[Token::Uint(uint)])) @@ -224,7 +237,7 @@ fn build_dependencies<'a>(data_type: &'a str, custom_types: &'a CustomTypes) -> Some(deps) } -fn check_hex(string: &str, field_name: Option<&str>) -> Result<()> { +fn check_hex_prefix(string: &str, field_name: Option<&str>) -> Result<()> { if string.len() >= 2 && &string[..2] == "0x" { return Ok(()); } @@ -238,6 +251,20 @@ fn check_hex(string: &str, field_name: Option<&str>) -> Result<()> { )) } +fn check_hex_len(string: &str, field_name: Option<&str>, len: usize) -> Result<()> { + if string.len() / 2 == len { + return Ok(()); + } + + Err(decode_error( + format!( + "Expected size of {}", + len + ), + field_name, + )) +} + fn expected_type_error(expected: &str, found: &Json, field_name: Option<&str>) -> Error { decode_error(format!("Expected '{expected}' type, found '{found}'"), field_name) } From 4479e443d1ef9efc96b686becb9ac4e46508ce3d Mon Sep 17 00:00:00 2001 From: dimxy Date: Tue, 11 Nov 2025 10:46:35 +0500 Subject: [PATCH 2/4] fix eip4337_rpc --- Cargo.lock | 1 + mm2src/coins/Cargo.toml | 1 + mm2src/coins/eth.rs | 13 ++++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c96971897e..8d65cfbf9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,7 @@ dependencies = [ "mm2_core", "mm2_db", "mm2_err_handle", + "mm2_eth", "mm2_event_stream", "mm2_io", "mm2_metamask", diff --git a/mm2src/coins/Cargo.toml b/mm2src/coins/Cargo.toml index e84ed0e373..5d9da8d638 100644 --- a/mm2src/coins/Cargo.toml +++ b/mm2src/coins/Cargo.toml @@ -79,6 +79,7 @@ mm2_event_stream = { path = "../mm2_event_stream" } mm2_io = { path = "../mm2_io" } mm2_metrics = { path = "../mm2_metrics" } mm2_net = { path = "../mm2_net" } +mm2_eth = { path = "../mm2_eth" } mm2_number = { path = "../mm2_number"} mm2_p2p = { path = "../mm2_p2p", default-features = false } mm2_rpc = { path = "../mm2_rpc" } diff --git a/mm2src/coins/eth.rs b/mm2src/coins/eth.rs index 7261d4a24c..42a01a86c0 100644 --- a/mm2src/coins/eth.rs +++ b/mm2src/coins/eth.rs @@ -152,7 +152,7 @@ pub use eth_utils::{ }; use eth_utils::{ get_conf_param_or_from_plaform_coin, get_function_input_data, get_function_name, ESTIMATE_GAS_MULT, - GAS_PRICE_ADJUST, MAX_ETH_TX_TYPE_SUPPORTED, SWAP_GAS_FEE_POLICY, + GAS_PRICE_ADJUST, MAX_ETH_TX_TYPE_SUPPORTED, SWAP_GAS_FEE_POLICY, EIP4337_URL, }; pub use rlp; @@ -162,6 +162,8 @@ cfg_native! { pub mod eth_balance_events; mod eth_rpc; +mod eip4337_rpc; +use eip4337_rpc::Eip4337Rpc; #[cfg(test)] mod eth_tests; #[cfg(target_arch = "wasm32")] @@ -196,6 +198,7 @@ use eth_swap_v2::{extract_id_from_tx_data, EthPaymentType, PaymentMethod, SpendT pub mod eth_utils; pub mod tron; +mod eth_abstraction; /// Default timeout to wait for eth rpc request to complete pub(crate) const ETH_RPC_REQUEST_TIMEOUT_S: Duration = Duration::from_secs(30); @@ -930,6 +933,7 @@ pub struct EthCoinImpl { fallback_swap_contract: Option
, contract_supports_watchers: bool, web3_instances: AsyncMutex>, + eip4337_rpc: Option, decimals: u8, history_sync_state: Mutex, required_confirmations: AtomicU64, @@ -6769,6 +6773,11 @@ pub async fn eth_coin_from_conf_and_request( get_conf_param_or_from_plaform_coin(ctx, conf, &coin_type, SWAP_GAS_FEE_POLICY)?.unwrap_or_default(); let swap_gas_fee_policy: SwapGasFeePolicy = json::from_value(req["swap_gas_fee_policy"].clone()).unwrap_or(swap_gas_fee_policy_default); + let eip4337_rpc = if let Ok(uri_str) = json::from_value::(req[EIP4337_URL].clone()) { + Some(try_s!(uri_str.parse())) + } else { + None + }; let coin = EthCoinImpl { priv_key_policy: key_pair, @@ -6784,6 +6793,7 @@ pub async fn eth_coin_from_conf_and_request( decimals, ticker: ticker.into(), web3_instances: AsyncMutex::new(web3_instances), + eip4337_rpc, history_sync_state: Mutex::new(initial_history_state), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, @@ -7730,6 +7740,7 @@ impl EthCoin { fallback_swap_contract: self.fallback_swap_contract, contract_supports_watchers: self.contract_supports_watchers, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), + eip4337_rpc: None, decimals: self.decimals, history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), required_confirmations: AtomicU64::new( From e464c58a5b64d229abe400566b30306d84d3b528 Mon Sep 17 00:00:00 2001 From: dimxy Date: Thu, 20 Nov 2025 18:53:27 +0500 Subject: [PATCH 3/4] add missed AA impl code --- mm2src/coins/eth/eip4337_rpc.rs | 60 +++ mm2src/coins/eth/eth_abstraction.rs | 569 ++++++++++++++++++++++++++++ 2 files changed, 629 insertions(+) create mode 100644 mm2src/coins/eth/eip4337_rpc.rs create mode 100644 mm2src/coins/eth/eth_abstraction.rs diff --git a/mm2src/coins/eth/eip4337_rpc.rs b/mm2src/coins/eth/eip4337_rpc.rs new file mode 100644 index 0000000000..e60c84dd9f --- /dev/null +++ b/mm2src/coins/eth/eip4337_rpc.rs @@ -0,0 +1,60 @@ +use super::*; +use crate::eth::web3_transport::http_transport::de_rpc_response; +use crate::hd_wallet::AddrToString; +// use alloy::rpc::types::eth::erc4337::{SendUserOperation, SendUserOperationResponse}; +// use alloy::sol_types::eip712_domain; +use mm2_net::transport::{slurp_post_json, SlurpResult}; +use serde_json::{json, Value}; +//use url::Url; + +pub(crate) struct Eip4337Rpc; + +impl Eip4337Rpc { + + async fn call_api(rpc_uri: &Uri, method: &str, params: &Value) -> SlurpResult { + let req = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1 + }); + println!("Eip4337Rpc req: {}", serde_json::to_string_pretty(&req).unwrap()); + //post_json(self.api_url.as_str(), serde_json::to_string(&req)?).await + slurp_post_json(&rpc_uri.to_string(), serde_json::to_string(&req)?).await + } + + pub(crate) async fn send_user_operation(rpc_uri: &Uri, user_op: Value, entry_point: Address) -> Result { + let result = Self::call_api( + rpc_uri, + "eth_sendUserOperation", + &json!([ + &user_op, + entry_point.addr_to_string() + ]) + ).await; + decode_rpc_result(result, rpc_uri) + } +} + + + +fn decode_rpc_result(result: SlurpResult, uri: &Uri) -> Result { + match result { + Ok((status, _, body)) => { + if !status.is_success() { + return Err(web3::Error::Transport(web3::error::TransportError::Code(status.as_u16()))); + } + + match de_rpc_response(body, &uri.to_string()) { + Ok(val) => Ok(val), + Err(err) => Err(web3::Error::InvalidResponse( + format!("Server: '{}', error: {}", uri, err) + )), + } + }, + Err(err) => Err(web3::Error::Transport( + web3::error::TransportError::Message( + format!("Server: '{}', error: {}", uri, err.get_inner()) + ))), + } +} \ No newline at end of file diff --git a/mm2src/coins/eth/eth_abstraction.rs b/mm2src/coins/eth/eth_abstraction.rs new file mode 100644 index 0000000000..d8b65e8880 --- /dev/null +++ b/mm2src/coins/eth/eth_abstraction.rs @@ -0,0 +1,569 @@ +//? EVM account abstraction support + +use super::*; +use bitcoin_hashes::hex::ToHex; +use ethabi::Function; +use mm2_eth::eip712::{Eip712, EIP712_DOMAIN, ObjectType, PropertyType}; +use mm2_eth::eip712_encode::hash_typed_data; +// use alloy::rpc::types::eth::erc4337::PackedUserOperation; +//use alloy::sol_types::{eip712_domain, sol}; +//use alloy::primitives; +/*use alloy::{ + primitives, //::{address, keccak256, U256}, + rpc::types::eth::erc4337::{PackedUserOperation, SendUserOperation}, + // signers::{local::PrivateKeySigner, Signer}, + sol, + sol_types::{eip712_domain, SolStruct}, +};*/ +use lazy_static::lazy_static; + +const PACKED_USEROP_PRIMARY_TYPE: &str = "PackedUserOperation"; + +const EIP7702_MAGIC: [u8; 1] = [0x05]; + +const EIP7702_INITCODE_MARKER: [u8; 2] = [0x77, 0x02]; + + + +lazy_static! { + + /// Pimlico paymaster contract v0.7 address + static ref PIMLICO_ERC20_PAYMASTER_0_7: Address = + Address::from_str("0x777777777777AeC03fd955926DbF81597e66834C").expect("Address::from_str valid"); + + static ref PIMLICO_ERC20_PAYMASTER_0_8: Address = + Address::from_str("0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402").expect("Address::from_str valid"); + + static ref SAFE_EIP_7702_PROXY: Address = + Address::from_str("0xE60EcE6588DCcFb7373538034963B4D20a280DB0").expect("Address::from_str valid"); // NOTE: experimental, not audited contract + + static ref SIMPLE_EIP_7702_SMART_ACCOUNT: Address = + Address::from_str("0xe6Cae83BdE06E4c305530e199D7217f42808555B").expect("Address::from_str valid"); + + static ref EIP_4337_ENTRY_POINT_7: Address = + Address::from_str("0x0000000071727De22E5E9d8BAf0edAc6f37da032").expect("Address::from_str valid"); + + static ref EIP_4337_ENTRY_POINT_8: Address = + Address::from_str("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108").expect("Address::from_str valid"); + + static ref EIP712_DOMAIN_TYPES: ObjectType = Eip712Domain::build_types(); + + static ref PACKED_USEROP_TYPES: Vec = PackedUserOperation::build_types(); + + //static ref EIP7702_INITCODE_MARKER_PADDED_RIGHT: Vec = [EIP7702_INITCODE_MARKER.as_slice(), [0u8; 18].as_slice()].concat(); + static ref EIP7702_INITCODE_MARKER_PADDED_RIGHT: Address = + Address::from_str("0x7702000000000000000000000000000000000000").expect("Address::from_str valid"); + + //static ref EIP7702_INITCODE_MARKER_PADDED_STR: String = "0x".to_owned() + &hex::encode(EIP7702_INITCODE_MARKER.as_slice()); + static ref EIP7702_INITCODE_MARKER_PADDED: Address = + Address::from_str("0x0000000000000000000000000000000000007702").expect("Address::from_str valid"); + + static ref EIP_4337_GET_NONCE: Function = serde_json::from_value(json!({ + "inputs":[ + {"internalType":"address","name":"sender","type":"address"}, + {"internalType":"uint192","name":"key","type":"uint192"} + ], + "name":"getNonce", + "outputs":[ + {"internalType":"uint256","name":"nonce","type":"uint256"} + ], + "stateMutability":"view", + "type":"function" + })).expect("valid EIP-4337 getNonce ABI"); +} + +/// Struct to build Eip-712 domain separator +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct Eip712Domain { + name: String, + version: String, + #[serde(rename = "chainId")] + chain_id: U256, + #[serde(rename = "verifyingContract")] + verifying_contract: Address, +} + +impl Eip712Domain { + fn build_types() -> ObjectType { + let mut domain_types = ObjectType::new(EIP712_DOMAIN); + domain_types.property("name", PropertyType::String); + domain_types.property("version", PropertyType::String); + domain_types.property("chainId", PropertyType::Uint256); + domain_types.property("verifyingContract", PropertyType::Address); + domain_types + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PackedUserOperationTyped { + sender: Address, + nonce: U256, + init_code: Bytes, + call_data: Bytes, + account_gas_limits: Bytes, + pre_verification_gas: U256, + gas_fees: Bytes, + paymaster_and_data: Bytes, + //eip_7702_auth: Option, +} + +/// PackedUserOperation in the spec: Entry Point V0.7 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PackedUserOperation { + /// The account making the operation. + pub sender: Address, + /// Prevents message replay attacks and serves as a randomizing element for initial user + /// registration. + pub nonce: U256, + /// Deployer contract address: Required exclusively for deploying new accounts that don't yet + /// exist on the blockchain. + pub factory: Option, // Address + /// Factory data for the account creation process, applicable only when using a deployer + /// contract. + pub factory_data: Option, + /// The call data. + pub call_data: Bytes, + /// The gas limit for the call. + pub call_gas_limit: U256, + /// The gas limit for the verification. + pub verification_gas_limit: U256, + /// Prepaid gas fee: Covers the bundler's costs for initial transaction validation and data + /// transmission. + pub pre_verification_gas: U256, + /// The maximum fee per gas. + pub max_fee_per_gas: U256, + /// The maximum priority fee per gas. + pub max_priority_fee_per_gas: U256, + /// Paymaster contract address: Needed if a third party is covering transaction costs; left + /// blank for self-funded accounts. + pub paymaster: Option
, + /// The gas limit for the paymaster verification. + pub paymaster_verification_gas_limit: Option, + /// The gas limit for the paymaster post-operation. + pub paymaster_post_op_gas_limit: Option, + /// The paymaster data. + pub paymaster_data: Option, // TODO: remove? + /// The signature of the transaction. + pub signature: Bytes, + /// Authorization to delegate to smart contract + pub eip_7702_auth: Option, +} + +impl PackedUserOperation { + fn build_types() -> Vec { + let mut userop_type = ObjectType::new(PACKED_USEROP_PRIMARY_TYPE); + userop_type.property("sender", PropertyType::Address); + userop_type.property("nonce", PropertyType::Uint256); + userop_type.property("initCode", PropertyType::Bytes); + userop_type.property("callData", PropertyType::Bytes); + userop_type.property("accountGasLimits", PropertyType::Bytes32); + userop_type.property("preVerificationGas", PropertyType::Uint256); + userop_type.property("gasFees", PropertyType::Bytes32); + userop_type.property("paymasterAndData", PropertyType::Bytes); + /*userop_type.property("eip7702Auth", PropertyType::Custom("eip7702Auth".to_string())); + let mut eip_7702_auth_type = ObjectType::new("eip7702Auth"); + eip_7702_auth_type.property("address", PropertyType::Address); + eip_7702_auth_type.property("chainId", PropertyType::Uint256); + eip_7702_auth_type.property("nonce", PropertyType::Uint256); + eip_7702_auth_type.property("r", PropertyType::Uint256); + eip_7702_auth_type.property("s", PropertyType::Uint256); + eip_7702_auth_type.property("v", PropertyType::Uint256); + eip_7702_auth_type.property("yParity", PropertyType::Uint256);*/ + + vec![userop_type] //, eip_7702_auth_type] + } + + fn build_struct(&self) -> PackedUserOperationTyped { + /*let mut init_code: Vec = self.factory.map(|addr| addr.to_bytes()).unwrap_or_default(); + if let Some(factory_data) = &self.factory_data { + init_code.extend_from_slice(&factory_data.0) + };*/ + PackedUserOperationTyped { + sender: self.sender, + nonce: self.nonce, + init_code: SIMPLE_EIP_7702_SMART_ACCOUNT.as_bytes().into(), // SIMPLE_EIP_7702_SMART_ACCOUNT.as_bytes().into(), //init_code.into(), + call_data: self.call_data.clone(), + account_gas_limits: make_bytes32_from_two(self.verification_gas_limit, self.call_gas_limit), + pre_verification_gas: self.pre_verification_gas, + gas_fees: make_bytes32_from_two(self.max_priority_fee_per_gas, self.max_fee_per_gas), + paymaster_and_data: Default::default(), //PIMLICO_ERC20_PAYMASTER_0_7.as_bytes().into(), + //eip_7702_auth: self.eip_7702_auth.clone(), + } + } +} + +/// Eip-7702 autorisation +#[derive(Debug)] +struct Eip7702Authorization { + address: Address, + chain_id: U256, + nonce: u64, +} + +impl rlp::Encodable for Eip7702Authorization { + fn rlp_append(&self, s: &mut RlpStream) { + s.begin_list(3); + s.append(&self.chain_id); + s.append(&self.address); + s.append(&self.nonce); + } +} + +impl Eip7702Authorization { + fn to_rlp(&self) -> Vec { + let mut stream = RlpStream::new(); + self.rlp_append(&mut stream); + if stream.is_finished() { + Vec::from(stream.out()) + } else { + warn!("RlpStream was not finished; returning an empty Vec as a fail-safe."); + vec![] + } + } + + fn as_msg(&self) -> [u8; 32] { + let mut msg = EIP7702_MAGIC.to_vec(); + msg.append(&mut self.to_rlp()); + keccak256(&msg).take() + } +} + + +/// Eip7702 signed autorisation +/// NOTE: fields must be serialized as hex prefixed with "0x" and be in camelCase, according to the pimlico API docs +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignedAuthorization { + address: Address, + chain_id: U256, + nonce: U256, + r: U256, + s: U256, + //v: U256, + y_parity: U256, +} + +impl SignedAuthorization { + fn new(auth: Eip7702Authorization, sig: Signature) -> Self { + Self { + address: auth.address, + chain_id: auth.chain_id, + nonce: auth.nonce.into(), + r: if sig.r().len() == 32 { U256::from(sig.r()) } else { U256::zero() }, + s: if sig.s().len() == 32 { U256::from(sig.s()) } else { U256::zero() }, + //v: (sig.v() + 27).into(), + y_parity: sig.v().into(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct SendUserOperationResponse { + /// The hash of the user operation. + pub user_op_hash: Bytes, +} + +impl EthCoin { + /// Build, sign and submit a PackedUserOperation that delegates `sender` (this coin's account) + /// to the provided `safe_contract` by calling the Safe contract's delegate/enable method. + /// + /// Notes: + /// - This implementation uses the `PackedUserOperation` type to serialize the userOp. + /// - It attempts to estimate gas for the call via `estimate_gas_for_contract_call_if_conf` and + /// falls back to configured gas limits from this coin. + /// - The method signs the user operation using the local `KeyPair` when available. For external + /// signers (WalletConnect / MetaMask) the call will currently return an Err indicating the + /// external path should be implemented if needed. + async fn delegate_to_safe_account( + &self, + safe_contract: Address, + delegate_call_data: Bytes, + ) -> Result { + + let paymaster = *PIMLICO_ERC20_PAYMASTER_0_7; + let eip4337_rpc = self.eip4337_rpc.as_ref().ok_or("AA RPC not initialized".to_string())?; + // Determine sender address (this coin's account address) + let my_address_str = self.my_address() + .map_err(|e| format!("Failed to get my_address: {e}"))?; + let my_address = Address::from_str(&my_address_str) + .map_err(|e| format!("Failed to parse my_address: {e}"))?; + let address_lock = self.get_address_lock(my_address).await; + let _nonce_lock = address_lock.lock().await; + let (nonce, _) = self + .clone() + .get_addr_nonce(my_address) + .compat() + .await + .map_err(|e| format!("Failed to get nonce: {e}"))?; + drop(_nonce_lock); + + /*let eip7702_address_lock = self.get_address_lock(*SAFE_EIP_7702_PROXY).await; + let eip7702_nonce_lock = address_lock.lock().await; + let (eip7702_nonce, _) = self + .clone() + .get_addr_nonce(*SAFE_EIP_7702_PROXY) + .compat() + .await + .map_err(|e| format!("Failed to get nonce: {e}"))?; + drop(eip7702_nonce_lock);*/ + + let get_nonce_data = EIP_4337_GET_NONCE.encode_input(&[Token::Address(my_address), Token::Uint(U256::from(0))]) + .map_err(|err| err.to_string())?; + let res = self + .call_request(my_address, *EIP_4337_ENTRY_POINT_8, None, Some(get_nonce_data.into()), BlockNumber::Latest) + .await + .map_err(|err| err.to_string())?; + let outputs = EIP_4337_GET_NONCE.decode_output(&res.0).map_err(|err| err.to_string())?; + let eip4337_nonce = match outputs.get(0) { + Some(Token::Uint(val)) => *val, + _ => return Err("could not decode getNonce response".to_string()), + }; + + // Estimate gas for the contract call if allowed by config + /*let call_req = web3::types::CallRequest { + from: Some(sender), + to: Some(safe_contract), + gas: None, + gas_price: None, + value: Some(U256::zero()), + data: Some(delegate_call_data.clone()), + transaction_type: None, + access_list: None, + max_priority_fee_per_gas: None, + max_fee_per_gas: None, + };*/ + + let estimated_gas_opt = U256::from(250_000u64); + /*let estimated_gas_opt = None; + let estimated_gas_opt = match self.estimate_gas_for_contract_call_if_conf(safe_contract, delegate_call_data.0.clone()).await { + Ok(v) => v, + Err(e) => { + // Log and continue with None so we fall back to gas_limit + debug!("estimate_gas_for_contract_call_if_conf failed: {e}"); + None + } + };*/ + + + let verification_gas_limit = U256::from(100_000u64); // conservative default + // Choose callGasLimit: network estimate or configured fallback + let call_gas_limit = U256::zero(); // estimated_gas_opt; + let (max_fee_per_gas, max_priority_fee_per_gas) = (U256::from(100_000_000_000u64), U256::from(2_000_000_000u64)); + //let (max_fee_per_gas, max_priority_fee_per_gas) = (U256::zero(), U256::zero()); + + let chain_id = self.chain_id().ok_or("No chain id".to_string())?.into(); + // Delegate to Safe Proxy experimental smart contract + let eip7702_delegate_auth = Eip7702Authorization { + address: *SIMPLE_EIP_7702_SMART_ACCOUNT, //*SAFE_EIP_7702_PROXY, + chain_id, + nonce: nonce.as_u64(), // TODO: may panic + }; + + // Build EIP-712 domain and hash userOp. + let domain = Eip712Domain { + name: "ERC4337".to_owned(), + version: "1".to_owned(), + chain_id, + verifying_contract: *EIP_4337_ENTRY_POINT_8, //paymaster, + //salt: keccak256(b"test").as_fixed_bytes().into(), + }; + + // Prepare basic PackedUserOperation fields. + // We fill common fields and leave paymasterAndData empty. + let mut user_op_packed = PackedUserOperation { + sender: Address::from_str(&my_address_str).map_err(|e| format!("Failed to parse my_address: {e}"))?, + nonce: eip4337_nonce, + factory: Some([0x77, 0x02].to_vec().into()), // Some(*EIP7702_INITCODE_MARKER_PADDED_RIGHT), + factory_data: None, + call_data: delegate_call_data, + call_gas_limit, + verification_gas_limit, + pre_verification_gas: U256::from(210_000u64), + max_fee_per_gas, + max_priority_fee_per_gas, + paymaster: None, // Some(paymaster), + paymaster_verification_gas_limit: None, //Some(U256::from(21_000u64)), + paymaster_post_op_gas_limit: None, //Some(U256::from(21_000u64)), + paymaster_data: Some(Bytes::default()), + signature: Bytes::default(), + eip_7702_auth: None, + }; + + // Sign the PackedUserOperation according to EIP-712 + match self.priv_key_policy { + EthPrivKeyPolicy::Iguana(ref key_pair) + | EthPrivKeyPolicy::HDWallet { + activated_key: ref key_pair, + .. + } => { + + + /*let packed_to_sign = PackedUserOperationToSign { + sender: packed.sender, + nonce: packed.nonce, + initCode: Bytes::default(), + callData: packed.call_data.clone(), + accountGasLimits: make_byte32_from_two(packed.verification_gas_limit, packed.call_gas_limit), + preVerificationGas: packed.pre_verification_gas, + gasFees: make_byte32_from_two(packed.max_priority_fee_per_gas, packed.max_fee_per_gas), + paymasterAndData: PIMLICO_ERC20_PAYMASTER_0_7.as_bytes().into(), + };*/ + + // NOTE: alloy helpers may expose a method like `PackedUserOperation::hash_eip712`. + // If not, the crate compile will point to the correct API and can be adjusted. + //let userop_hash = packed_to_sign.eip712_signing_hash(&domain); + //.map_err(|e| format!("Failed to compute userop EIP712 hash: {e}"))?; + + let auth_sig = sign(key_pair.secret(), &eip7702_delegate_auth.as_msg().into()).map_err(|e| format!("Signing failed: {e}"))?; + let signed_auth = SignedAuthorization::new(eip7702_delegate_auth, auth_sig); + user_op_packed.eip_7702_auth = Some(signed_auth); + let user_op_typed_data = user_op_hash_eip712_request(domain, user_op_packed.build_struct()); + let user_op_typed_hash = hash_typed_data(user_op_typed_data) + .map_err(|e| format!("Typed hash failed: {e}"))?; + + println!("user_op_typed_hash={}", user_op_typed_hash.to_hex()); + // Sign the hash with KeyPair + let sig = sign(key_pair.secret(), &user_op_typed_hash).map_err(|e| format!("Signing failed: {e}"))?.into_electrum(); + //println!("signature: r={:?} s={:?} v={} is_low_s={}", sig.r(), sig.s(), sig.v(), sig.is_low_s()); + user_op_packed.signature = sig.to_vec().into(); + }, + EthPrivKeyPolicy::Trezor + | EthPrivKeyPolicy::WalletConnect { .. } => return Err("External signers (WalletConnect/Trezor) are not implemented for userOps yet".into()), + #[cfg(target_arch = "wasm32")] + EthPrivKeyPolicy::Metamask(_) => return Err("MetaMask is not implemented for userOps yet".into()), + } + + + + // Submit via eth_sendUserOperation RPC. Many bundlers accept params: [userOp, entryPoint] + // We try to call `eth_sendUserOperation` with the packed userop serialized to JSON. + let userop_j = serde_json::to_value(&user_op_packed).map_err(|e| format!("Failed to serialize userop: {e}"))?; + + // Use configured entry point if present, otherwise omit (many bundlers accept two-arg form) + //let entry_point = helpers::serialize(&self.entry_point_address); + + // Call the RPC and return the result. Wrap web3::Error into String for simplicity. + //let rpc_params = vec![userop_json, serde_json::Value::String(self.entry_point_address.to_string())]; + + let resp_json = Eip4337Rpc::send_user_operation(eip4337_rpc, userop_j, *EIP_4337_ENTRY_POINT_8) + .await + .map_err(|err| format!("RPC error sending userOp: {err}"))?; + /*let user_op_resp = serde_json::from_value::(resp_j) + .map_err(|err| format!("Error parsing userOp RPC response: {err}"))?; + let user_op_hash = H256::from_str(&hex::encode(user_op_resp.user_op_hash.0)) + .map_err(|err| format!("Error parsing userOp hash in RPC response: {err}"))?;*/ + let user_op_resp = resp_json.as_str() + .ok_or(format!("Error parsing userOpHash response: expected string"))?; + let user_op_hash = H256::from_str(user_op_resp) + .map_err(|err| format!("Error parsing userOpHash response: {err}"))?; + Ok(user_op_hash) + } +} + +pub(crate) fn user_op_hash_eip712_request( + domain: Eip712Domain, + req: PackedUserOperationTyped, +) -> Eip712 { + let mut v_types = vec![EIP712_DOMAIN_TYPES.deref()]; + v_types.extend(PACKED_USEROP_TYPES.deref()); + let types = v_types + .iter() + .map(|&object_type| (object_type.name.clone(), object_type.properties.clone())) + .collect(); + Eip712 { + types, + domain, + primary_type: PACKED_USEROP_PRIMARY_TYPE.to_string(), + message: req, + } +} + +fn make_init_code(_delegate_address: Address) -> Bytes { + todo!() +} + +fn make_bytes32_from_two(u1: U256, u2: U256) -> Bytes { + let mut u1_bytes = [0_u8; 32]; + let mut u2_bytes = [0_u8; 32]; + let mut bytes_32 = [0_u8; 32]; + u1.to_big_endian(&mut u1_bytes); + u2.to_big_endian(&mut u2_bytes); + bytes_32[0..16].copy_from_slice(&u1_bytes[16..32]); + bytes_32[16..32].copy_from_slice(&u2_bytes[16..32]); + bytes_32.into() +} + + +mod tests { + use super::*; + use crate::lp_coininit; + use common::block_on; + use ethabi::Address; + use mm2_core::mm_ctx::MmCtxBuilder; + use mm2_test_helpers::for_tests::{ETH_SEPOLIA_CHAIN_ID, ETH_SEPOLIA_NODES}; + + #[test] + fn sepolia_delegate_to_safe() { + const ETH: &str = "ETH"; + const ETH_TOKEN: &str = "JST"; + + let conf = json!({ + "coins": [{ + "coin": "ETH", + "name": "ethereum-sepolia", + "fname": "Ethereum", + "rpcport": 80, + "mm2": 1, + "sign_message_prefix": "Ethereum Signed Message:\n", + "required_confirmations": 3, + "avg_blocktime": 15, + "protocol": { + "type": "ETH", + "protocol_data": { + "chain_id": ETH_SEPOLIA_CHAIN_ID + } + }, + "derivation_path": "m/44'/60'" + },{ + "coin": "JST", + "name": "Just Token", + "fname": "jst", + "rpcport": 80, + "mm2": 1, + "avg_blocktime": 15, + "required_confirmations": 3, + "decimals": 18, + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": "0x948BF5172383F1Bc0Fdf3aBe0630b855694A5D2c" + } + }, + "derivation_path": "m/44'/60'" + }] + }); + + let ctx = MmCtxBuilder::new().with_conf(conf).into_mm_arc(); + CryptoCtx::init_with_iguana_passphrase( + ctx.clone(), + "hen garden proud labor donkey cluster shield jazz worry category pelican immune body letter green badge face more apology smile estate ridge fall armor", // TODO: don't push + ) + .unwrap(); + + let eth_params = json!({ + "urls": ETH_SEPOLIA_NODES, + "eip4337_url": "https://api.pimlico.io/v2/11155111/rpc?apikey=pim_ERGa26ctXC4ZGW419K6uLW", + "swap_contract_address": "0x9130b257d37a52e52f21054c4da3450c72f595ce", + }); + let coin = match block_on(lp_coininit(&ctx, ETH, ð_params)).unwrap() { + MmCoinEnum::EthCoin(coin) => coin, + _ => panic!("incorrect coin type"), + }; + + let res = block_on(coin.delegate_to_safe_account(Address::zero(), Bytes::from(Vec::new()))); + println!("res={:?}", res); + } +} \ No newline at end of file From 51780e3dd7341296e00a263ccabea9fcc95db93b Mon Sep 17 00:00:00 2001 From: dimxy Date: Thu, 20 Nov 2025 19:05:07 +0500 Subject: [PATCH 4/4] remove pimlico test key --- mm2src/coins/eth/eth_abstraction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mm2src/coins/eth/eth_abstraction.rs b/mm2src/coins/eth/eth_abstraction.rs index d8b65e8880..9fcfa1ad7f 100644 --- a/mm2src/coins/eth/eth_abstraction.rs +++ b/mm2src/coins/eth/eth_abstraction.rs @@ -555,7 +555,7 @@ mod tests { let eth_params = json!({ "urls": ETH_SEPOLIA_NODES, - "eip4337_url": "https://api.pimlico.io/v2/11155111/rpc?apikey=pim_ERGa26ctXC4ZGW419K6uLW", + "eip4337_url": "https://api.pimlico.io/v2/11155111/rpc?apikey=PIMLICO-KEY-HERE", "swap_contract_address": "0x9130b257d37a52e52f21054c4da3450c72f595ce", }); let coin = match block_on(lp_coininit(&ctx, ETH, ð_params)).unwrap() {