From 08dadd024379e26658516d4fd477d0e746733e7c Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 26 Aug 2025 16:47:36 -0700 Subject: [PATCH 1/8] first I --- bedrock/src/smart_account/nonce.rs | 2 + bedrock/src/transaction/contracts/mod.rs | 2 + .../src/transaction/contracts/safe_owner.rs | 94 +++++++++++++++++++ bedrock/src/transaction/mod.rs | 48 +++++++++- bedrock/tests/test_smart_account_transfer.rs | 2 +- 5 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 bedrock/src/transaction/contracts/safe_owner.rs diff --git a/bedrock/src/smart_account/nonce.rs b/bedrock/src/smart_account/nonce.rs index 0f1df329..8700bc77 100644 --- a/bedrock/src/smart_account/nonce.rs +++ b/bedrock/src/smart_account/nonce.rs @@ -18,6 +18,8 @@ use crate::primitives::BEDROCK_NONCE_PREFIX_CONST; pub enum TransactionTypeId { /// ERC-20 transfer Transfer = 1, + /// Swap Safe Owner + SwapOwner = 188, } impl TransactionTypeId { diff --git a/bedrock/src/transaction/contracts/mod.rs b/bedrock/src/transaction/contracts/mod.rs index 166a8881..628ab230 100644 --- a/bedrock/src/transaction/contracts/mod.rs +++ b/bedrock/src/transaction/contracts/mod.rs @@ -2,3 +2,5 @@ //! that power the common transactions for the crypto wallet. pub mod erc20; + +pub mod safe_owner; diff --git a/bedrock/src/transaction/contracts/safe_owner.rs b/bedrock/src/transaction/contracts/safe_owner.rs new file mode 100644 index 00000000..9c357d57 --- /dev/null +++ b/bedrock/src/transaction/contracts/safe_owner.rs @@ -0,0 +1,94 @@ +//! This module introduces the contract interface for the Safe contract. +//! +//! Explicitly this only allows management of the Safe Smart Account. Executing transactions with the Safe Smart Account +//! is done via the `SafeSmartAccount` module. + +use alloy::{ + primitives::{address, Address, Bytes, U256}, + sol, + sol_types::SolCall, +}; + +use crate::{ + primitives::PrimitiveError, + smart_account::{ + ISafe4337Module, InstructionFlag, Is4337Encodable, NonceKeyV1, SafeOperation, + TransactionTypeId, UserOperation, + }, +}; + +const SENTINEL_ADDRESS: Address = + address!("0x0000000000000000000000000000000000000001"); + +sol! { + ///Owner Manager Interface for the Safe + /// + /// Reference: + #[derive(serde::Serialize)] + #[sol(rename_all = "camelcase")] + interface IOwnerManager { + function swapOwner(address prev_owner, address old_owner, address new_owner) public; + } +} + +pub struct SafeOwner { + /// The inner call data for the ERC-20 `transferCall` function. + call_data: Vec, + /// The address of the Safe Smart Account. + wallet_address: Address, +} + +impl SafeOwner { + pub fn new( + wallet_address: Address, + old_owner: Address, + new_owner: Address, + ) -> Self { + Self { + call_data: IOwnerManager::swapOwnerCall { + prev_owner: SENTINEL_ADDRESS, + old_owner, + new_owner, + } + .abi_encode(), + wallet_address, + } + } +} + +impl Is4337Encodable for SafeOwner { + type MetadataArg = (); + + // TODO: Make this the default in Is4337Encodable trait, it's a sensible default. + fn as_execute_user_op_call_data(&self) -> Bytes { + ISafe4337Module::executeUserOpCall { + to: self.wallet_address, + value: U256::ZERO, + data: self.call_data.clone().into(), + operation: SafeOperation::Call as u8, + } + .abi_encode() + .into() + } + + fn as_preflight_user_operation( + &self, + wallet_address: Address, + _metadata: Option, + ) -> Result { + let call_data = self.as_execute_user_op_call_data(); + + let key = NonceKeyV1::new( + TransactionTypeId::SwapOwner, + InstructionFlag::Default, + [0u8; 10], + ); + let nonce = key.encode_with_sequence(0); + + Ok(UserOperation::new_with_defaults( + wallet_address, + nonce, + call_data, + )) + } +} diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index 8063789d..a863e74c 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -4,7 +4,7 @@ use bedrock_macros::bedrock_export; use crate::{ primitives::{HexEncodedData, Network, ParseFromForeignBinding}, smart_account::{Is4337Encodable, SafeSmartAccount}, - transaction::contracts::erc20::Erc20, + transaction::contracts::{erc20::Erc20, safe_owner::SafeOwner}, }; mod contracts; @@ -62,7 +62,7 @@ impl SafeSmartAccount { /// - Will throw a parsing error if any of the provided attributes are invalid. /// - Will throw an RPC error if the transaction submission fails. /// - Will throw an error if the global HTTP client has not been initialized. - pub async fn transaction_transfer( + pub async fn tx_transfer( &self, network: Network, token_address: &str, @@ -81,7 +81,49 @@ impl SafeSmartAccount { .sign_and_execute(network, self, None, None, provider) .await .map_err(|e| TransactionError::Generic { - message: format!("Failed to execute transaction: {e}"), + message: format!("Failed to execute ERC-20 transfer: {e}"), + })?; + + Ok(HexEncodedData::new(&user_op_hash.to_string())?) + } + + /// Allows swapping the owner of a Safe Smart Account. + /// + /// This is used to allow key rotation. The EOA signer that can act on behalf of the Safe is rotated. + /// + /// # Arguments + /// - `old_owner`: The EOA of the old owner (address). + /// - `new_owner`: The EOA of the new owner (address). + /// + /// # Errors + /// - Will throw a parsing error if any of the provided attributes are invalid. + /// - Will throw an RPC error if the transaction submission fails. + pub async fn tx_swap_safe_owner( + &self, + old_owner: &str, + new_owner: &str, + ) -> Result { + let old_owner = Address::parse_from_ffi(old_owner, "old_owner")?; + let new_owner = Address::parse_from_ffi(new_owner, "new_owner")?; + + // TODO: RPC call to check if the old owner is the current owner. + + // TODO: Check if we derive new_owner through key derivation directly in Bedrock. + + let transaction = SafeOwner::new(self.wallet_address, old_owner, new_owner); + + // TODO: Check if rotation on Optimism is also necessary. + let user_op_hash = transaction + .sign_and_execute( + Network::WorldChain, + self, + None, + None, + RpcProviderName::Alchemy, + ) + .await + .map_err(|e| TransactionError::Generic { + message: format!("Failed to execute swapOwner: {e}"), })?; Ok(HexEncodedData::new(&user_op_hash.to_string())?) diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index d21c9529..96acbf56 100644 --- a/bedrock/tests/test_smart_account_transfer.rs +++ b/bedrock/tests/test_smart_account_transfer.rs @@ -291,7 +291,7 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; let amount = "1000000000000000000"; // 1 WLD let _user_op_hash = safe_account - .transaction_transfer( + .tx_transfer( Network::WorldChain, &wld_token_address.to_string(), &recipient.to_string(), From 69a923ea0305b8cba414ef01642aa385542de5af Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 26 Aug 2025 17:11:25 -0700 Subject: [PATCH 2/8] nice refactor --- bedrock/src/smart_account/transaction_4337.rs | 32 +++++++++++++++---- bedrock/src/transaction/contracts/erc20.rs | 19 ++++------- .../src/transaction/contracts/safe_owner.rs | 21 +++++------- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/bedrock/src/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index 991cc6a3..335b144f 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -3,14 +3,15 @@ //! A transaction can be initialized through a `UserOperation` struct. //! -use crate::primitives::contracts::{EncodedSafeOpStruct, UserOperation}; +use crate::primitives::contracts::{ + EncodedSafeOpStruct, ISafe4337Module, UserOperation, +}; use crate::primitives::{Network, PrimitiveError}; use crate::smart_account::SafeSmartAccountSigner; -use crate::transaction::rpc::{ - RpcError, RpcProviderName, SponsorUserOperationResponse, -}; +use crate::transaction::rpc::{RpcError, RpcProviderName}; -use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes}; +use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes, U256}; +use alloy::sol_types::SolCall; use chrono::{Duration, Utc}; use crate::primitives::contracts::{ENTRYPOINT_4337, GNOSIS_SAFE_4337_MODULE}; @@ -27,11 +28,29 @@ pub trait Is4337Encodable { /// constructing a preflight `UserOperation`. type MetadataArg; + /// Returns the target address to which the inner transaction will be executed against. + /// For example, for a token transfer, the transfer operation is executed against the token contract address. + fn target_address(&self) -> Address; + + /// Returns the call data for the transaction. + fn call_data(&self) -> Bytes; + /// Converts the object into a `callData` for the `executeUserOp` method. This is the inner-most `calldata`. /// + /// This is a sensible default implementation that should work for most use cases. + /// /// # Errors /// - Will throw a parsing error if any of the provided attributes are invalid. - fn as_execute_user_op_call_data(&self) -> Bytes; + fn as_execute_user_op_call_data(&self) -> Bytes { + ISafe4337Module::executeUserOpCall { + to: self.target_address(), + value: U256::ZERO, + data: self.call_data(), + operation: 0, // SafeOperation::Call as u8 + } + .abi_encode() + .into() + } /// Converts the object into a preflight `UserOperation` for use with the `Safe4337Module`. /// @@ -150,6 +169,7 @@ mod tests { use super::*; use crate::{ smart_account::SafeSmartAccount, transaction::foreign::UnparsedUserOperation, + transaction::rpc::SponsorUserOperationResponse, }; #[test] diff --git a/bedrock/src/transaction/contracts/erc20.rs b/bedrock/src/transaction/contracts/erc20.rs index afece8d2..bab67776 100644 --- a/bedrock/src/transaction/contracts/erc20.rs +++ b/bedrock/src/transaction/contracts/erc20.rs @@ -8,8 +8,7 @@ use alloy::{ use crate::primitives::PrimitiveError; use crate::smart_account::{ - ISafe4337Module, InstructionFlag, Is4337Encodable, NonceKeyV1, SafeOperation, - TransactionTypeId, UserOperation, + InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, UserOperation, }; sol! { @@ -87,16 +86,12 @@ pub struct MetadataArg { impl Is4337Encodable for Erc20 { type MetadataArg = MetadataArg; - fn as_execute_user_op_call_data(&self) -> Bytes { - ISafe4337Module::executeUserOpCall { - // The token address - to: self.token_address, - value: U256::ZERO, - data: self.call_data.clone().into(), - operation: SafeOperation::Call as u8, - } - .abi_encode() - .into() + fn target_address(&self) -> Address { + self.token_address + } + + fn call_data(&self) -> Bytes { + self.call_data.clone().into() } fn as_preflight_user_operation( diff --git a/bedrock/src/transaction/contracts/safe_owner.rs b/bedrock/src/transaction/contracts/safe_owner.rs index 9c357d57..80efacef 100644 --- a/bedrock/src/transaction/contracts/safe_owner.rs +++ b/bedrock/src/transaction/contracts/safe_owner.rs @@ -4,7 +4,7 @@ //! is done via the `SafeSmartAccount` module. use alloy::{ - primitives::{address, Address, Bytes, U256}, + primitives::{address, Address, Bytes}, sol, sol_types::SolCall, }; @@ -12,8 +12,7 @@ use alloy::{ use crate::{ primitives::PrimitiveError, smart_account::{ - ISafe4337Module, InstructionFlag, Is4337Encodable, NonceKeyV1, SafeOperation, - TransactionTypeId, UserOperation, + InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, UserOperation, }, }; @@ -59,16 +58,12 @@ impl SafeOwner { impl Is4337Encodable for SafeOwner { type MetadataArg = (); - // TODO: Make this the default in Is4337Encodable trait, it's a sensible default. - fn as_execute_user_op_call_data(&self) -> Bytes { - ISafe4337Module::executeUserOpCall { - to: self.wallet_address, - value: U256::ZERO, - data: self.call_data.clone().into(), - operation: SafeOperation::Call as u8, - } - .abi_encode() - .into() + fn target_address(&self) -> Address { + self.wallet_address + } + + fn call_data(&self) -> Bytes { + self.call_data.clone().into() } fn as_preflight_user_operation( From 72f84d4bccf213ef31e00e27fc0f7a949c6284ea Mon Sep 17 00:00:00 2001 From: pd Date: Wed, 27 Aug 2025 10:11:30 -0700 Subject: [PATCH 3/8] tests stash --- .../src/transaction/contracts/safe_owner.rs | 8 + bedrock/src/transaction/mod.rs | 5 +- .../test_smart_account_safe_owner_swap.rs | 308 ++++++++++++++++++ 3 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 bedrock/tests/test_smart_account_safe_owner_swap.rs diff --git a/bedrock/src/transaction/contracts/safe_owner.rs b/bedrock/src/transaction/contracts/safe_owner.rs index 80efacef..5e24c556 100644 --- a/bedrock/src/transaction/contracts/safe_owner.rs +++ b/bedrock/src/transaction/contracts/safe_owner.rs @@ -30,6 +30,7 @@ sol! { } } +/// Represents a Safe owner swap transaction for key rotation. pub struct SafeOwner { /// The inner call data for the ERC-20 `transferCall` function. call_data: Vec, @@ -38,6 +39,13 @@ pub struct SafeOwner { } impl SafeOwner { + /// Creates a new `SafeOwner` transaction for swapping Safe owners. + /// + /// # Arguments + /// - `wallet_address`: The address of the Safe Smart Account + /// - `old_owner`: The current owner to be replaced + /// - `new_owner`: The new owner to replace the old owner + #[must_use] pub fn new( wallet_address: Address, old_owner: Address, diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index a863e74c..3fa657b4 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -4,13 +4,14 @@ use bedrock_macros::bedrock_export; use crate::{ primitives::{HexEncodedData, Network, ParseFromForeignBinding}, smart_account::{Is4337Encodable, SafeSmartAccount}, - transaction::contracts::{erc20::Erc20, safe_owner::SafeOwner}, + transaction::contracts::erc20::Erc20, }; mod contracts; pub mod foreign; pub mod rpc; +pub use contracts::safe_owner::SafeOwner; pub use rpc::{RpcClient, RpcError, RpcProviderName, SponsorUserOperationResponse}; /// Errors that can occur when interacting with transaction operations. @@ -110,7 +111,7 @@ impl SafeSmartAccount { // TODO: Check if we derive new_owner through key derivation directly in Bedrock. - let transaction = SafeOwner::new(self.wallet_address, old_owner, new_owner); + let transaction = crate::transaction::SafeOwner::new(self.wallet_address, old_owner, new_owner); // TODO: Check if rotation on Optimism is also necessary. let user_op_hash = transaction diff --git a/bedrock/tests/test_smart_account_safe_owner_swap.rs b/bedrock/tests/test_smart_account_safe_owner_swap.rs new file mode 100644 index 00000000..a8abe5a5 --- /dev/null +++ b/bedrock/tests/test_smart_account_safe_owner_swap.rs @@ -0,0 +1,308 @@ +use std::sync::Arc; + +use alloy::{ + network::Ethereum, + primitives::{keccak256, U256}, + providers::{ext::AnvilApi, Provider, ProviderBuilder}, + signers::local::PrivateKeySigner, + sol, + sol_types::SolValue, +}; +use bedrock::{ + primitives::{ + http_client::{set_http_client, AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, + }, + smart_account::{SafeSmartAccount, ENTRYPOINT_4337}, + transaction::foreign::UnparsedUserOperation, +}; +use serde::Serialize; +use serde_json::json; + +mod common; +use common::{deploy_safe, setup_anvil, IEntryPoint, PackedUserOperation}; + +sol! { + /// Safe owner management interface + #[sol(rpc)] + interface IOwnerManager { + function getOwners() external view returns (address[] memory); + function isOwner(address owner) external view returns (bool); + function swapOwner(address prevOwner, address oldOwner, address newOwner) external; + } +} + +// ------------------ Mock HTTP client that intercepts sponsor and executes on Anvil ------------------ +#[derive(Clone)] +struct AnvilBackedHttpClient

+where + P: Provider + Clone + Send + Sync + 'static, +{ + provider: P, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SponsorUserOperationResponseLite<'a> { + paymaster: &'a str, + paymaster_data: &'a str, + pre_verification_gas: String, + verification_gas_limit: String, + call_gas_limit: String, + paymaster_verification_gas_limit: String, + paymaster_post_op_gas_limit: String, + max_priority_fee_per_gas: String, + max_fee_per_gas: String, +} + +#[async_trait::async_trait] +impl

AuthenticatedHttpClient for AnvilBackedHttpClient

+where + P: Provider + Clone + Send + Sync + 'static, +{ + async fn fetch_from_app_backend( + &self, + _url: String, + method: HttpMethod, + _headers: Vec, + body: Option>, + ) -> Result, HttpError> { + if method != HttpMethod::Post { + return Err(HttpError::Generic { + message: "unsupported method".into(), + }); + } + + let body = body.ok_or(HttpError::Generic { + message: "missing body".into(), + })?; + + let root: serde_json::Value = + serde_json::from_slice(&body).map_err(|_| HttpError::Generic { + message: "invalid json".into(), + })?; + + let method = + root.get("method") + .and_then(|m| m.as_str()) + .ok_or(HttpError::Generic { + message: "invalid json".into(), + })?; + let id = root.get("id").cloned().unwrap_or(serde_json::Value::Null); + let params = root + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + + match method { + // Intercept sponsor request and return minimal gas values with no paymaster + "wa_sponsorUserOperation" => { + let result = SponsorUserOperationResponseLite { + paymaster: "0x0000000000000000000000000000000000000000", + paymaster_data: "0x", + pre_verification_gas: "0x20000".into(), + verification_gas_limit: "0x20000".into(), + call_gas_limit: "0x20000".into(), + paymaster_verification_gas_limit: "0x0".into(), + paymaster_post_op_gas_limit: "0x0".into(), + max_priority_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei + max_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei + }; + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + + Ok(serde_json::to_vec(&response).unwrap()) + } + // Execute the user operation on Anvil + "eth_sendUserOperation" => { + let params = params.as_array().ok_or(HttpError::Generic { + message: "invalid params".into(), + })?; + let user_op_val = params.first().ok_or(HttpError::Generic { + message: "missing userOp param".into(), + })?; + let _entry_point_str = params.get(1).and_then(|v| v.as_str()).ok_or( + HttpError::Generic { + message: "missing entryPoint param".into(), + }, + )?; + + // Build UnparsedUserOperation from JSON (which uses hex strings), then convert + let obj = user_op_val.as_object().ok_or(HttpError::Generic { + message: "userOp param must be an object".into(), + })?; + + let get_opt = |k: &str| -> Option { + obj.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()) + }; + let get_or_zero = |k: &str| -> String { + get_opt(k).unwrap_or_else(|| "0x0".to_string()) + }; + let get_required = |k: &str| -> Result { + get_opt(k).ok_or(HttpError::Generic { + message: format!("missing or invalid {k}"), + }) + }; + + let unparsed = UnparsedUserOperation { + sender: get_required("sender")?, + nonce: get_required("nonce")?, + call_data: get_required("callData")?, + call_gas_limit: get_or_zero("callGasLimit"), + verification_gas_limit: get_or_zero("verificationGasLimit"), + pre_verification_gas: get_or_zero("preVerificationGas"), + max_fee_per_gas: get_or_zero("maxFeePerGas"), + max_priority_fee_per_gas: get_or_zero("maxPriorityFeePerGas"), + paymaster: get_opt("paymaster"), + paymaster_verification_gas_limit: get_or_zero( + "paymasterVerificationGasLimit", + ), + paymaster_post_op_gas_limit: get_or_zero("paymasterPostOpGasLimit"), + paymaster_data: get_opt("paymasterData"), + signature: get_required("signature")?, + factory: get_opt("factory"), + factory_data: get_opt("factoryData"), + }; + + let parsed_op: bedrock::smart_account::UserOperation = + unparsed.try_into().map_err(|e| HttpError::Generic { + message: format!("invalid userOp: {e}"), + })?; + + // Execute on Anvil via EntryPoint + let entry_point_contract = IEntryPoint::new(*ENTRYPOINT_4337, &self.provider); + let packed_op = PackedUserOperation::try_from(&parsed_op) + .map_err(|e| HttpError::Generic { + message: format!("failed to pack user operation: {e}"), + })?; + let handle_ops = entry_point_contract + .handleOps(vec![packed_op], parsed_op.sender) + .gas(5_000_000) + .send() + .await + .map_err(|e| HttpError::Generic { + message: format!("failed to execute user operation: {e}"), + })?; + + let _receipt = handle_ops.get_receipt().await.map_err(|e| HttpError::Generic { + message: format!("failed to get receipt: {e}"), + })?; + + // Create a user operation hash + let user_op_hash = keccak256(parsed_op.abi_encode()); + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": format!("0x{}", hex::encode(user_op_hash)), + }); + + Ok(serde_json::to_vec(&response).unwrap()) + } + _ => Err(HttpError::Generic { + message: format!("unsupported method: {method}"), + }), + } + } +} + +/// End-to-end integration test for swapping Safe owners using tx_swap_safe_owner. +/// +/// This test: +/// 1. Deploys a Safe with an initial owner +/// 2. Sets up a custom RPC client that intercepts sponsor requests +/// 3. Executes the owner swap using tx_swap_safe_owner +/// 4. Verifies the swap was executed successfully on-chain +#[tokio::test] +async fn test_safe_owner_swap_e2e() -> anyhow::Result<()> { + let anvil = setup_anvil(); + + // Setup initial and new owners + let initial_owner_signer = PrivateKeySigner::random(); + let initial_owner = initial_owner_signer.address(); + let initial_owner_key_hex = hex::encode(initial_owner_signer.to_bytes()); + + let new_owner_signer = PrivateKeySigner::random(); + let new_owner = new_owner_signer.address(); + + println!("✓ Initial owner address: {initial_owner}"); + println!("✓ New owner address: {new_owner}"); + + let provider = ProviderBuilder::new() + .wallet(initial_owner_signer.clone()) + .connect_http(anvil.endpoint_url()); + + // Deploy Safe with initial owner + let safe_address = deploy_safe(&provider, initial_owner, U256::ZERO).await?; + println!("✓ Deployed Safe at: {safe_address}"); + + // Fund the Safe for gas + provider + .anvil_set_balance(safe_address, U256::from(1e18)) + .await?; + + // Fund EntryPoint deposit for the Safe + let entry_point = IEntryPoint::new(*ENTRYPOINT_4337, &provider); + let _deposit_tx = entry_point + .depositTo(safe_address) + .value(U256::from(1e18)) + .send() + .await?; + println!("✓ Funded Safe and EntryPoint deposit"); + + // Verify initial owner + let safe_contract = IOwnerManager::new(safe_address, &provider); + let initial_owners = safe_contract.getOwners().call().await?; + assert_eq!(initial_owners.len(), 1); + assert_eq!(initial_owners[0], initial_owner); + assert!(safe_contract.isOwner(initial_owner).call().await?); + assert!(!safe_contract.isOwner(new_owner).call().await?); + println!("✓ Verified initial owner"); + + // Set up custom HTTP client that intercepts sponsor requests and executes on Anvil + let anvil_http_client = AnvilBackedHttpClient { + provider: provider.clone(), + }; + set_http_client(Arc::new(anvil_http_client)); + + // Create SafeSmartAccount instance + let safe_account = + SafeSmartAccount::new(initial_owner_key_hex, &safe_address.to_string()) + .expect("Failed to create SafeSmartAccount"); + + // Execute the owner swap using tx_swap_safe_owner + println!("→ Executing tx_swap_safe_owner to swap owners..."); + let tx_hash = safe_account + .tx_swap_safe_owner( + &initial_owner.to_string(), + &new_owner.to_string(), + ) + .await?; + + println!("✓ Executed owner swap transaction: {}", tx_hash.to_hex_string()); + + // Wait a bit for the transaction to be processed + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + // Verify the owner swap was successful + let final_owners = safe_contract.getOwners().call().await?; + assert_eq!(final_owners.len(), 1, "Should still have exactly 1 owner"); + assert_eq!(final_owners[0], new_owner, "Owner should be the new owner"); + + // Verify ownership status + assert!( + !safe_contract.isOwner(initial_owner).call().await?, + "Initial owner should no longer be an owner" + ); + assert!( + safe_contract.isOwner(new_owner).call().await?, + "New owner should be an owner" + ); + + println!("✅ Successfully swapped Safe owner from {initial_owner} to {new_owner}"); + + Ok(()) +} From a9472ba008531011359f8916090ea8770f34f1de Mon Sep 17 00:00:00 2001 From: pd Date: Thu, 28 Aug 2025 14:04:59 -0700 Subject: [PATCH 4/8] e2e tests --- bedrock/src/transaction/mod.rs | 9 +- bedrock/tests/common.rs | 202 ++++++++++++++++- .../test_smart_account_safe_owner_swap.rs | 206 +----------------- 3 files changed, 214 insertions(+), 203 deletions(-) diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index 3fa657b4..0ed6ea2f 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -108,12 +108,15 @@ impl SafeSmartAccount { let new_owner = Address::parse_from_ffi(new_owner, "new_owner")?; // TODO: RPC call to check if the old owner is the current owner. - // TODO: Check if we derive new_owner through key derivation directly in Bedrock. + // TODO: Check if rotation on Optimism is also necessary. - let transaction = crate::transaction::SafeOwner::new(self.wallet_address, old_owner, new_owner); + let transaction = crate::transaction::SafeOwner::new( + self.wallet_address, + old_owner, + new_owner, + ); - // TODO: Check if rotation on Optimism is also necessary. let user_op_hash = transaction .sign_and_execute( Network::WorldChain, diff --git a/bedrock/tests/common.rs b/bedrock/tests/common.rs index 7a7ea4d4..400e996f 100644 --- a/bedrock/tests/common.rs +++ b/bedrock/tests/common.rs @@ -1,13 +1,22 @@ use alloy::{ network::Ethereum, node_bindings::AnvilInstance, - primitives::{address, Address, FixedBytes, Log, U256}, + primitives::{address, keccak256, Address, FixedBytes, Log, U256}, providers::{ext::AnvilApi, Provider}, sol, - sol_types::{SolCall, SolEvent}, + sol_types::{SolCall, SolEvent, SolValue}, }; -use bedrock::{primitives::PrimitiveError, smart_account::UserOperation}; +use bedrock::{ + primitives::{ + http_client::{AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, + PrimitiveError, + }, + smart_account::{UserOperation, ENTRYPOINT_4337}, + transaction::foreign::UnparsedUserOperation, +}; +use serde::Serialize; +use serde_json::json; sol!( #[allow(missing_docs)] @@ -218,3 +227,190 @@ impl TryFrom<&UserOperation> for PackedUserOperation { }) } } + +/// A mock HTTP client that intercepts 4337 RPC calls for testing. +/// - `wa_sponsorUserOperation`: Will mock a response with default gas values and no paymaster. +/// - `eth_sendUserOperation`: Executes the user operation on Anvil via the `EntryPoint` contract +#[derive(Clone)] +pub struct AnvilBackedHttpClient

+where + P: Provider + Clone + Send + Sync + 'static, +{ + pub provider: P, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SponsorUserOperationResponseLite<'a> { + paymaster: &'a str, + paymaster_data: &'a str, + pre_verification_gas: String, + verification_gas_limit: String, + call_gas_limit: String, + paymaster_verification_gas_limit: String, + paymaster_post_op_gas_limit: String, + max_priority_fee_per_gas: String, + max_fee_per_gas: String, +} + +#[async_trait::async_trait] +impl

AuthenticatedHttpClient for AnvilBackedHttpClient

+where + P: Provider + Clone + Send + Sync + 'static, +{ + async fn fetch_from_app_backend( + &self, + _url: String, + method: HttpMethod, + _headers: Vec, + body: Option>, + ) -> Result, HttpError> { + if method != HttpMethod::Post { + return Err(HttpError::Generic { + message: "unsupported method".into(), + }); + } + + let body = body.ok_or(HttpError::Generic { + message: "missing body".into(), + })?; + + let root: serde_json::Value = + serde_json::from_slice(&body).map_err(|_| HttpError::Generic { + message: "invalid json".into(), + })?; + + let method = + root.get("method") + .and_then(|m| m.as_str()) + .ok_or(HttpError::Generic { + message: "invalid json".into(), + })?; + let id = root.get("id").cloned().unwrap_or(serde_json::Value::Null); + let params = root + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + + match method { + // Intercept sponsor request and return minimal gas values with no paymaster + "wa_sponsorUserOperation" => { + let result = SponsorUserOperationResponseLite { + paymaster: "0x0000000000000000000000000000000000000000", + paymaster_data: "0x", + pre_verification_gas: "0x20000".into(), + verification_gas_limit: "0x20000".into(), + call_gas_limit: "0x20000".into(), + paymaster_verification_gas_limit: "0x0".into(), + paymaster_post_op_gas_limit: "0x0".into(), + max_priority_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei + max_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei + }; + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + + Ok(serde_json::to_vec(&response).unwrap()) + } + // Execute the user operation on Anvil + "eth_sendUserOperation" => { + let params = params.as_array().ok_or(HttpError::Generic { + message: "invalid params".into(), + })?; + let user_op_val = params.first().ok_or(HttpError::Generic { + message: "missing userOp param".into(), + })?; + let _entry_point_str = params.get(1).and_then(|v| v.as_str()).ok_or( + HttpError::Generic { + message: "missing entryPoint param".into(), + }, + )?; + + // Build UnparsedUserOperation from JSON (which uses hex strings), then convert + let obj = user_op_val.as_object().ok_or(HttpError::Generic { + message: "userOp param must be an object".into(), + })?; + + let get_opt = |k: &str| -> Option { + obj.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()) + }; + let get_or_zero = |k: &str| -> String { + get_opt(k).unwrap_or_else(|| "0x0".to_string()) + }; + let get_required = |k: &str| -> Result { + get_opt(k).ok_or(HttpError::Generic { + message: format!("missing or invalid {k}"), + }) + }; + + let unparsed = UnparsedUserOperation { + sender: get_required("sender")?, + nonce: get_required("nonce")?, + call_data: get_required("callData")?, + call_gas_limit: get_or_zero("callGasLimit"), + verification_gas_limit: get_or_zero("verificationGasLimit"), + pre_verification_gas: get_or_zero("preVerificationGas"), + max_fee_per_gas: get_or_zero("maxFeePerGas"), + max_priority_fee_per_gas: get_or_zero("maxPriorityFeePerGas"), + paymaster: get_opt("paymaster"), + paymaster_verification_gas_limit: get_or_zero( + "paymasterVerificationGasLimit", + ), + paymaster_post_op_gas_limit: get_or_zero("paymasterPostOpGasLimit"), + paymaster_data: get_opt("paymasterData"), + signature: get_required("signature")?, + factory: get_opt("factory"), + factory_data: get_opt("factoryData"), + }; + + let parsed_op: UserOperation = + unparsed.try_into().map_err(|e| HttpError::Generic { + message: format!("invalid userOp: {e}"), + })?; + + // Execute on Anvil via EntryPoint + let entry_point_contract = + IEntryPoint::new(*ENTRYPOINT_4337, &self.provider); + let packed_op = + PackedUserOperation::try_from(&parsed_op).map_err(|e| { + HttpError::Generic { + message: format!("failed to pack user operation: {e}"), + } + })?; + let handle_ops = entry_point_contract + .handleOps(vec![packed_op], parsed_op.sender) + .gas(5_000_000) + .send() + .await + .map_err(|e| HttpError::Generic { + message: format!("failed to execute user operation: {e}"), + })?; + + let _receipt = + handle_ops + .get_receipt() + .await + .map_err(|e| HttpError::Generic { + message: format!("failed to get receipt: {e}"), + })?; + + // Create a user operation hash + let user_op_hash = keccak256(parsed_op.abi_encode()); + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": format!("0x{}", hex::encode(user_op_hash)), + }); + + Ok(serde_json::to_vec(&response).unwrap()) + } + _ => Err(HttpError::Generic { + message: format!("unsupported method: {method}"), + }), + } + } +} diff --git a/bedrock/tests/test_smart_account_safe_owner_swap.rs b/bedrock/tests/test_smart_account_safe_owner_swap.rs index a8abe5a5..616c896c 100644 --- a/bedrock/tests/test_smart_account_safe_owner_swap.rs +++ b/bedrock/tests/test_smart_account_safe_owner_swap.rs @@ -1,25 +1,18 @@ use std::sync::Arc; use alloy::{ - network::Ethereum, - primitives::{keccak256, U256}, - providers::{ext::AnvilApi, Provider, ProviderBuilder}, + primitives::U256, + providers::{ext::AnvilApi, ProviderBuilder}, signers::local::PrivateKeySigner, sol, - sol_types::SolValue, }; use bedrock::{ - primitives::{ - http_client::{set_http_client, AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, - }, + primitives::http_client::set_http_client, smart_account::{SafeSmartAccount, ENTRYPOINT_4337}, - transaction::foreign::UnparsedUserOperation, }; -use serde::Serialize; -use serde_json::json; mod common; -use common::{deploy_safe, setup_anvil, IEntryPoint, PackedUserOperation}; +use common::{deploy_safe, setup_anvil, AnvilBackedHttpClient, IEntryPoint}; sol! { /// Safe owner management interface @@ -31,184 +24,6 @@ sol! { } } -// ------------------ Mock HTTP client that intercepts sponsor and executes on Anvil ------------------ -#[derive(Clone)] -struct AnvilBackedHttpClient

-where - P: Provider + Clone + Send + Sync + 'static, -{ - provider: P, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SponsorUserOperationResponseLite<'a> { - paymaster: &'a str, - paymaster_data: &'a str, - pre_verification_gas: String, - verification_gas_limit: String, - call_gas_limit: String, - paymaster_verification_gas_limit: String, - paymaster_post_op_gas_limit: String, - max_priority_fee_per_gas: String, - max_fee_per_gas: String, -} - -#[async_trait::async_trait] -impl

AuthenticatedHttpClient for AnvilBackedHttpClient

-where - P: Provider + Clone + Send + Sync + 'static, -{ - async fn fetch_from_app_backend( - &self, - _url: String, - method: HttpMethod, - _headers: Vec, - body: Option>, - ) -> Result, HttpError> { - if method != HttpMethod::Post { - return Err(HttpError::Generic { - message: "unsupported method".into(), - }); - } - - let body = body.ok_or(HttpError::Generic { - message: "missing body".into(), - })?; - - let root: serde_json::Value = - serde_json::from_slice(&body).map_err(|_| HttpError::Generic { - message: "invalid json".into(), - })?; - - let method = - root.get("method") - .and_then(|m| m.as_str()) - .ok_or(HttpError::Generic { - message: "invalid json".into(), - })?; - let id = root.get("id").cloned().unwrap_or(serde_json::Value::Null); - let params = root - .get("params") - .cloned() - .unwrap_or(serde_json::Value::Null); - - match method { - // Intercept sponsor request and return minimal gas values with no paymaster - "wa_sponsorUserOperation" => { - let result = SponsorUserOperationResponseLite { - paymaster: "0x0000000000000000000000000000000000000000", - paymaster_data: "0x", - pre_verification_gas: "0x20000".into(), - verification_gas_limit: "0x20000".into(), - call_gas_limit: "0x20000".into(), - paymaster_verification_gas_limit: "0x0".into(), - paymaster_post_op_gas_limit: "0x0".into(), - max_priority_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei - max_fee_per_gas: "0x3B9ACA00".into(), // 1 gwei - }; - - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - }); - - Ok(serde_json::to_vec(&response).unwrap()) - } - // Execute the user operation on Anvil - "eth_sendUserOperation" => { - let params = params.as_array().ok_or(HttpError::Generic { - message: "invalid params".into(), - })?; - let user_op_val = params.first().ok_or(HttpError::Generic { - message: "missing userOp param".into(), - })?; - let _entry_point_str = params.get(1).and_then(|v| v.as_str()).ok_or( - HttpError::Generic { - message: "missing entryPoint param".into(), - }, - )?; - - // Build UnparsedUserOperation from JSON (which uses hex strings), then convert - let obj = user_op_val.as_object().ok_or(HttpError::Generic { - message: "userOp param must be an object".into(), - })?; - - let get_opt = |k: &str| -> Option { - obj.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()) - }; - let get_or_zero = |k: &str| -> String { - get_opt(k).unwrap_or_else(|| "0x0".to_string()) - }; - let get_required = |k: &str| -> Result { - get_opt(k).ok_or(HttpError::Generic { - message: format!("missing or invalid {k}"), - }) - }; - - let unparsed = UnparsedUserOperation { - sender: get_required("sender")?, - nonce: get_required("nonce")?, - call_data: get_required("callData")?, - call_gas_limit: get_or_zero("callGasLimit"), - verification_gas_limit: get_or_zero("verificationGasLimit"), - pre_verification_gas: get_or_zero("preVerificationGas"), - max_fee_per_gas: get_or_zero("maxFeePerGas"), - max_priority_fee_per_gas: get_or_zero("maxPriorityFeePerGas"), - paymaster: get_opt("paymaster"), - paymaster_verification_gas_limit: get_or_zero( - "paymasterVerificationGasLimit", - ), - paymaster_post_op_gas_limit: get_or_zero("paymasterPostOpGasLimit"), - paymaster_data: get_opt("paymasterData"), - signature: get_required("signature")?, - factory: get_opt("factory"), - factory_data: get_opt("factoryData"), - }; - - let parsed_op: bedrock::smart_account::UserOperation = - unparsed.try_into().map_err(|e| HttpError::Generic { - message: format!("invalid userOp: {e}"), - })?; - - // Execute on Anvil via EntryPoint - let entry_point_contract = IEntryPoint::new(*ENTRYPOINT_4337, &self.provider); - let packed_op = PackedUserOperation::try_from(&parsed_op) - .map_err(|e| HttpError::Generic { - message: format!("failed to pack user operation: {e}"), - })?; - let handle_ops = entry_point_contract - .handleOps(vec![packed_op], parsed_op.sender) - .gas(5_000_000) - .send() - .await - .map_err(|e| HttpError::Generic { - message: format!("failed to execute user operation: {e}"), - })?; - - let _receipt = handle_ops.get_receipt().await.map_err(|e| HttpError::Generic { - message: format!("failed to get receipt: {e}"), - })?; - - // Create a user operation hash - let user_op_hash = keccak256(parsed_op.abi_encode()); - - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": format!("0x{}", hex::encode(user_op_hash)), - }); - - Ok(serde_json::to_vec(&response).unwrap()) - } - _ => Err(HttpError::Generic { - message: format!("unsupported method: {method}"), - }), - } - } -} - /// End-to-end integration test for swapping Safe owners using tx_swap_safe_owner. /// /// This test: @@ -276,16 +91,13 @@ async fn test_safe_owner_swap_e2e() -> anyhow::Result<()> { // Execute the owner swap using tx_swap_safe_owner println!("→ Executing tx_swap_safe_owner to swap owners..."); let tx_hash = safe_account - .tx_swap_safe_owner( - &initial_owner.to_string(), - &new_owner.to_string(), - ) + .tx_swap_safe_owner(&initial_owner.to_string(), &new_owner.to_string()) .await?; - println!("✓ Executed owner swap transaction: {}", tx_hash.to_hex_string()); - - // Wait a bit for the transaction to be processed - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + println!( + "✓ Executed owner swap transaction: {}", + tx_hash.to_hex_string() + ); // Verify the owner swap was successful let final_owners = safe_contract.getOwners().call().await?; From 9aff5fce016ed351d489b42f18ad20f2b4d59b73 Mon Sep 17 00:00:00 2001 From: pd Date: Thu, 28 Aug 2025 14:15:30 -0700 Subject: [PATCH 5/8] fix doctests --- bedrock/src/transaction/mod.rs | 2 +- bedrock/tests/test_smart_account_transfer.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index 0ed6ea2f..614ae5c2 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -46,7 +46,7 @@ impl SafeSmartAccount { /// # let safe_account = SafeSmartAccount::new("test_key".to_string(), "0x1234567890123456789012345678901234567890").unwrap(); /// /// // Transfer USDC on World Chain - /// let tx_hash = safe_account.transaction_transfer( + /// let tx_hash = safe_account.tx_transfer( /// Network::WorldChain, /// "0x79A02482A880BCE3F13E09Da970dC34DB4cD24d1", // USDC on World Chain /// "0x1234567890123456789012345678901234567890", diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index 06e42b3f..0a83bfa2 100644 --- a/bedrock/tests/test_smart_account_transfer.rs +++ b/bedrock/tests/test_smart_account_transfer.rs @@ -234,11 +234,10 @@ where } } -// ------------------ The test for the full transaction_transfer flow ------------------ +// ------------------ The test for the full tx_transfer flow ------------------ #[tokio::test] -async fn test_transaction_transfer_full_flow_executes_user_operation( -) -> anyhow::Result<()> { +async fn test_tx_transfer_full_flow_executes_user_operation() -> anyhow::Result<()> { // 1) Spin up anvil fork let anvil = setup_anvil(); @@ -292,7 +291,7 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( }; let _ = set_http_client(Arc::new(client)); - // 8) Execute high-level transfer via transaction_transfer + // 8) Execute high-level transfer via tx_transfer let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; let amount = "1000000000000000000"; // 1 WLD let _user_op_hash = safe_account @@ -304,7 +303,7 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( RpcProviderName::Alchemy, ) .await - .expect("transaction_transfer failed"); + .expect("tx_transfer failed"); // 9) Verify balances updated let after_recipient = wld.balanceOf(recipient).call().await?; From 08c403ddff4dbefeb92a3ce8cff4d25aed474e10 Mon Sep 17 00:00:00 2001 From: pd Date: Thu, 28 Aug 2025 14:20:32 -0700 Subject: [PATCH 6/8] Update mod.rs --- bedrock/src/transaction/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index 614ae5c2..4676c4cb 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -107,7 +107,6 @@ impl SafeSmartAccount { let old_owner = Address::parse_from_ffi(old_owner, "old_owner")?; let new_owner = Address::parse_from_ffi(new_owner, "new_owner")?; - // TODO: RPC call to check if the old owner is the current owner. // TODO: Check if we derive new_owner through key derivation directly in Bedrock. // TODO: Check if rotation on Optimism is also necessary. From eaafadcbd541c676a98447f46dbb35205e8faec5 Mon Sep 17 00:00:00 2001 From: pd Date: Thu, 28 Aug 2025 14:38:24 -0700 Subject: [PATCH 7/8] oops --- bedrock/src/smart_account/transaction_4337.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bedrock/src/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index 335b144f..a45d5bd4 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -7,7 +7,7 @@ use crate::primitives::contracts::{ EncodedSafeOpStruct, ISafe4337Module, UserOperation, }; use crate::primitives::{Network, PrimitiveError}; -use crate::smart_account::SafeSmartAccountSigner; +use crate::smart_account::{SafeOperation, SafeSmartAccountSigner}; use crate::transaction::rpc::{RpcError, RpcProviderName}; use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes, U256}; @@ -46,7 +46,7 @@ pub trait Is4337Encodable { to: self.target_address(), value: U256::ZERO, data: self.call_data(), - operation: 0, // SafeOperation::Call as u8 + operation: SafeOperation::Call as u8, } .abi_encode() .into() From 82ba7abd35576d65db109868e3e9fce34ab19492 Mon Sep 17 00:00:00 2001 From: Paolo D'Amico Date: Thu, 28 Aug 2025 18:23:05 -0700 Subject: [PATCH 8/8] chore: refactor entrypoint & related contracts (#92) * refactors * improvements * clarify for tests --- bedrock/src/primitives/mod.rs | 12 +- bedrock/src/smart_account/mod.rs | 17 +-- bedrock/src/smart_account/nonce.rs | 7 +- bedrock/src/smart_account/transaction_4337.rs | 26 ++-- .../contracts/entrypoint.rs} | 135 ++++++++++-------- bedrock/src/transaction/contracts/erc20.rs | 6 +- bedrock/src/transaction/contracts/mod.rs | 2 +- .../src/transaction/contracts/safe_owner.rs | 5 +- bedrock/src/transaction/foreign.rs | 6 +- bedrock/src/transaction/mod.rs | 4 + bedrock/src/transaction/rpc.rs | 5 +- bedrock/tests/common.rs | 12 +- ...t_account_erc4337_transaction_execution.rs | 11 +- bedrock/tests/test_smart_account_nonce.rs | 5 +- .../test_smart_account_safe_owner_swap.rs | 8 +- bedrock/tests/test_smart_account_transfer.rs | 17 ++- 16 files changed, 144 insertions(+), 134 deletions(-) rename bedrock/src/{primitives/contracts.rs => transaction/contracts/entrypoint.rs} (88%) diff --git a/bedrock/src/primitives/mod.rs b/bedrock/src/primitives/mod.rs index 00821997..6edb2745 100644 --- a/bedrock/src/primitives/mod.rs +++ b/bedrock/src/primitives/mod.rs @@ -9,14 +9,7 @@ use std::str::FromStr; // Re-export HTTP client types for external use pub use http_client::{AuthenticatedHttpClient, HttpError, HttpMethod}; -/// The prefix for Bedrock-generated transactions. -pub static BEDROCK_NONCE_PREFIX_CONST: &[u8; 5] = b"bdrck"; - -/// The prefix for PBHTX-generated transactions. -#[allow(dead_code)] -pub static PBH_NONCE_PREFIX_CONST: &[u8; 5] = b"pbhtx"; - -// Serde helper functions for skip_serializing_if +// ---- Serde helper functions for `skip_serializing_if` ---- /// Helper function to check if an `Address` is zero for serde `skip_serializing_if` #[must_use] @@ -59,9 +52,6 @@ pub mod http_client; #[cfg(feature = "tooling_tests")] pub mod tooling_tests; -/// Contract interfaces and data structures for ERC-4337 account abstraction -pub mod contracts; - /// Supported blockchain networks for Bedrock operations #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[repr(u32)] diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index b840537d..2d1f104a 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -11,8 +11,12 @@ pub use transaction_4337::Is4337Encodable; #[cfg(any(test, doc))] use crate::primitives::Network; use crate::{ - bedrock_export, debug, error, primitives::HexEncodedData, - transaction::foreign::UnparsedUserOperation, + bedrock_export, debug, error, + primitives::HexEncodedData, + transaction::{ + foreign::UnparsedUserOperation, EncodedSafeOpStruct, UserOperation, + GNOSIS_SAFE_4337_MODULE, + }, }; /// Enables signing of messages and EIP-712 typed data for Safe Smart Accounts. @@ -32,13 +36,10 @@ mod transaction; /// Reference: mod permit2; -pub use crate::primitives::contracts::{ - EncodedSafeOpStruct, ISafe4337Module, UserOperation, ENTRYPOINT_4337, - GNOSIS_SAFE_4337_MODULE, +pub use nonce::{ + InstructionFlag, NonceKeyV1, TransactionTypeId, BEDROCK_NONCE_PREFIX_CONST, }; -pub use nonce::{InstructionFlag, NonceKeyV1, TransactionTypeId}; - // Import the generated types from permit2 module pub use permit2::{ UnparsedPermitTransferFrom, UnparsedTokenPermissions, PERMIT2_ADDRESS, @@ -234,7 +235,7 @@ impl SafeSmartAccount { &user_op, valid_after, valid_until, - )?; + ); let signature = self.sign_digest( encoded_safe_op_struct.into_transaction_hash(), diff --git a/bedrock/src/smart_account/nonce.rs b/bedrock/src/smart_account/nonce.rs index 8700bc77..3692823e 100644 --- a/bedrock/src/smart_account/nonce.rs +++ b/bedrock/src/smart_account/nonce.rs @@ -10,7 +10,12 @@ use ruint::aliases::U256; -use crate::primitives::BEDROCK_NONCE_PREFIX_CONST; +/// The prefix for Bedrock-generated transactions. +pub static BEDROCK_NONCE_PREFIX_CONST: &[u8; 5] = b"bdrck"; + +/// The prefix for PBHTX-generated transactions. +#[allow(dead_code)] +pub static PBH_NONCE_PREFIX_CONST: &[u8; 5] = b"pbhtx"; /// Stable, never-reordered identifiers for transaction classes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/bedrock/src/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index a45d5bd4..7102ba63 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -3,19 +3,17 @@ //! A transaction can be initialized through a `UserOperation` struct. //! -use crate::primitives::contracts::{ - EncodedSafeOpStruct, ISafe4337Module, UserOperation, -}; use crate::primitives::{Network, PrimitiveError}; use crate::smart_account::{SafeOperation, SafeSmartAccountSigner}; -use crate::transaction::rpc::{RpcError, RpcProviderName}; +use crate::transaction::{ + EncodedSafeOpStruct, ISafe4337Module, RpcError, RpcProviderName, UserOperation, + ENTRYPOINT_4337, GNOSIS_SAFE_4337_MODULE, +}; use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes, U256}; use alloy::sol_types::SolCall; use chrono::{Duration, Utc}; -use crate::primitives::contracts::{ENTRYPOINT_4337, GNOSIS_SAFE_4337_MODULE}; - /// The default validity duration for 4337 `UserOperation` signatures. /// /// Operations are valid for this duration from the time they are signed. @@ -114,7 +112,7 @@ pub trait Is4337Encodable { .await?; // 3. Merge paymaster data - user_operation = user_operation.with_paymaster_data(sponsor_response)?; + user_operation = user_operation.with_paymaster_data(sponsor_response); // 4. Compute validity timestamps // validAfter = 0 (immediately valid) @@ -135,7 +133,7 @@ pub trait Is4337Encodable { &user_operation, valid_after_u48, valid_until_u48, - )?; + ); let signature = safe_account.sign_digest( encoded_safe_op.into_transaction_hash(), @@ -199,8 +197,7 @@ mod tests { &user_op, valid_after, valid_until, - ) - .unwrap(); + ); let hash = encoded_safe_op.into_transaction_hash(); let smart_account = SafeSmartAccount::random(); @@ -286,10 +283,8 @@ mod tests { max_fee_per_gas: U128::from(900), }; - let result = user_op.with_paymaster_data(sponsor_response); - assert!(result.is_ok()); + let updated_user_op = user_op.with_paymaster_data(sponsor_response); - let updated_user_op = result.unwrap(); assert_eq!( updated_user_op.paymaster, address!("0x2222222222222222222222222222222222222222") @@ -332,10 +327,7 @@ mod tests { max_fee_per_gas: U128::from(900), }; - let result = user_op.with_paymaster_data(sponsor_response); - assert!(result.is_ok()); - - let updated_user_op = result.unwrap(); + let updated_user_op = user_op.with_paymaster_data(sponsor_response); // Paymaster fields should always be updated assert_eq!( diff --git a/bedrock/src/primitives/contracts.rs b/bedrock/src/transaction/contracts/entrypoint.rs similarity index 88% rename from bedrock/src/primitives/contracts.rs rename to bedrock/src/transaction/contracts/entrypoint.rs index 9b983c89..1fba989e 100644 --- a/bedrock/src/primitives/contracts.rs +++ b/bedrock/src/transaction/contracts/entrypoint.rs @@ -1,4 +1,8 @@ -use crate::primitives::{HttpError, PrimitiveError}; +//! This module introduces the contract interface for: +//! - the `EntryPoint` contract, including support for ERC-4337 in the Safe Smart Account +//! - the `PBHEntryPoint` contract, which is to execute Priority Blockspace for Humans transactions + +use crate::primitives::PrimitiveError; use crate::transaction::rpc::SponsorUserOperationResponse; use alloy::hex::FromHex; use alloy::primitives::{aliases::U48, keccak256, Address, Bytes, FixedBytes}; @@ -53,6 +57,67 @@ fn serialize_u256_as_hex( } sol! { + /// `EntryPoint` contract (0.7.0) + /// Reference: + interface IEntryPoint { + #[derive(Default, serde::Serialize, serde::Deserialize, Debug)] + #[sol(rename_all = "camelCase")] + struct PackedUserOperation { + address sender; + uint256 nonce; + bytes init_code; + bytes call_data; + bytes32 account_gas_limits; + uint256 pre_verification_gas; + bytes32 gas_fees; + bytes paymaster_and_data; + bytes signature; + } + + #[derive(Default)] + struct UserOpsPerAggregator { + PackedUserOperation[] userOps; + address aggregator; + bytes signature; + } + } + + /// `Multicall3` contract. Aggregates results from multiple calls in a single transaction. + /// + /// Reference: + /// Reference: + interface IMulticall3 { + #[derive(Default)] + struct Call3 { + address target; + bool allowFailure; + bytes callData; + } + } + + // FIXME: Currently PBHEntryPoint is not in use. Depending on how it ends up being used, some of these functions might not be needed (e.g. `PackedUserOperation`). + /// `PBHEntryPoint` contract. An entry point contract that supports Priority Blockspace for Humans (PBH) transactions. + /// + /// Reference: + interface IPBHEntryPoint { + #[derive(Default)] + struct PBHPayload { + uint256 root; + uint256 pbhExternalNullifier; + uint256 nullifierHash; + uint256[8] proof; + } + + function handleAggregatedOps( + IEntryPoint.UserOpsPerAggregator[] calldata, + address payable + ) external; + + function pbhMulticall( + IMulticall3.Call3[] calls, + PBHPayload payload, + ) external; + } /// Interface for the `Safe4337Module` contract. /// @@ -223,14 +288,15 @@ impl UserOperation { out.into() } - /// Merges paymaster data from sponsorship response into the `UserOperation` + /// Merges paymaster data from sponsorship response into the existing `UserOperation` /// /// # Errors - /// Returns an error if any U128 to u128 conversion fails + /// Returns an error if any parameter conversion fails + #[must_use] pub fn with_paymaster_data( mut self, sponsor_response: SponsorUserOperationResponse, - ) -> Result { + ) -> Self { self.paymaster = sponsor_response.paymaster; self.paymaster_data = sponsor_response.paymaster_data; self.paymaster_verification_gas_limit = sponsor_response @@ -267,7 +333,7 @@ impl UserOperation { .unwrap_or(0); } - Ok(self) + self } } @@ -282,8 +348,8 @@ impl EncodedSafeOpStruct { user_op: &UserOperation, valid_after: U48, valid_until: U48, - ) -> Result { - Ok(Self { + ) -> Self { + Self { type_hash: *SAFE_OP_TYPEHASH, safe: user_op.sender, nonce: user_op.nonce, @@ -298,7 +364,7 @@ impl EncodedSafeOpStruct { valid_after, valid_until, entry_point: *ENTRYPOINT_4337, - }) + } } /// computes the hash of the userOp @@ -307,56 +373,3 @@ impl EncodedSafeOpStruct { keccak256(self.abi_encode()) } } - -sol! { - contract IMulticall3 { - #[derive(Default)] - struct Call3 { - address target; - bool allowFailure; - bytes callData; - } - } - - contract IEntryPoint { - #[derive(Default, serde::Serialize, serde::Deserialize, Debug)] - struct PackedUserOperation { - address sender; - uint256 nonce; - bytes initCode; - bytes callData; - bytes32 accountGasLimits; - uint256 preVerificationGas; - bytes32 gasFees; - bytes paymasterAndData; - bytes signature; - } - - #[derive(Default)] - struct UserOpsPerAggregator { - PackedUserOperation[] userOps; - address aggregator; - bytes signature; - } - } - - contract IPBHEntryPoint { - #[derive(Default)] - struct PBHPayload { - uint256 root; - uint256 pbhExternalNullifier; - uint256 nullifierHash; - uint256[8] proof; - } - - function handleAggregatedOps( - IEntryPoint.UserOpsPerAggregator[] calldata, - address payable - ) external; - - function pbhMulticall( - IMulticall3.Call3[] calls, - PBHPayload payload, - ) external; - } -} diff --git a/bedrock/src/transaction/contracts/erc20.rs b/bedrock/src/transaction/contracts/erc20.rs index bab67776..6b3a1311 100644 --- a/bedrock/src/transaction/contracts/erc20.rs +++ b/bedrock/src/transaction/contracts/erc20.rs @@ -6,10 +6,10 @@ use alloy::{ sol_types::SolCall, }; -use crate::primitives::PrimitiveError; use crate::smart_account::{ - InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, UserOperation, + InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, }; +use crate::{primitives::PrimitiveError, transaction::UserOperation}; sol! { /// The ERC20 contract interface. @@ -133,7 +133,7 @@ mod tests { use alloy::primitives::bytes; use std::str::FromStr; - use crate::primitives::BEDROCK_NONCE_PREFIX_CONST; + use crate::smart_account::BEDROCK_NONCE_PREFIX_CONST; use super::*; diff --git a/bedrock/src/transaction/contracts/mod.rs b/bedrock/src/transaction/contracts/mod.rs index 628ab230..3e242900 100644 --- a/bedrock/src/transaction/contracts/mod.rs +++ b/bedrock/src/transaction/contracts/mod.rs @@ -1,6 +1,6 @@ //! This module introduces contract definitions for all the smart contracts //! that power the common transactions for the crypto wallet. +pub mod entrypoint; pub mod erc20; - pub mod safe_owner; diff --git a/bedrock/src/transaction/contracts/safe_owner.rs b/bedrock/src/transaction/contracts/safe_owner.rs index 5e24c556..29b98271 100644 --- a/bedrock/src/transaction/contracts/safe_owner.rs +++ b/bedrock/src/transaction/contracts/safe_owner.rs @@ -11,9 +11,8 @@ use alloy::{ use crate::{ primitives::PrimitiveError, - smart_account::{ - InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, UserOperation, - }, + smart_account::{InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId}, + transaction::UserOperation, }; const SENTINEL_ADDRESS: Address = diff --git a/bedrock/src/transaction/foreign.rs b/bedrock/src/transaction/foreign.rs index 9e976b5a..6f1faf09 100644 --- a/bedrock/src/transaction/foreign.rs +++ b/bedrock/src/transaction/foreign.rs @@ -3,10 +3,8 @@ use alloy::primitives::{Address, Bytes, U256}; -use crate::{ - primitives::{ParseFromForeignBinding, PrimitiveError}, - smart_account::UserOperation, -}; +use crate::primitives::{ParseFromForeignBinding, PrimitiveError}; +use crate::transaction::UserOperation; /// A pseudo-transaction object for EIP-4337. Used to execute transactions through the Safe Smart Account. /// diff --git a/bedrock/src/transaction/mod.rs b/bedrock/src/transaction/mod.rs index 4676c4cb..6a7d4b4d 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -11,6 +11,10 @@ mod contracts; pub mod foreign; pub mod rpc; +pub use contracts::entrypoint::{ + EncodedSafeOpStruct, ISafe4337Module, UserOperation, ENTRYPOINT_4337, + GNOSIS_SAFE_4337_MODULE, +}; pub use contracts::safe_owner::SafeOwner; pub use rpc::{RpcClient, RpcError, RpcProviderName, SponsorUserOperationResponse}; diff --git a/bedrock/src/transaction/rpc.rs b/bedrock/src/transaction/rpc.rs index 6f8749ba..9877ffef 100644 --- a/bedrock/src/transaction/rpc.rs +++ b/bedrock/src/transaction/rpc.rs @@ -5,11 +5,12 @@ //! - Submit signed `UserOperations` via `eth_sendUserOperation` use crate::{ - primitives::http_client::{get_http_client, HttpHeader}, primitives::{ + http_client::{get_http_client, HttpHeader}, AuthenticatedHttpClient, HttpError, HttpMethod, Network, PrimitiveError, }, - smart_account::{SafeSmartAccountError, UserOperation}, + smart_account::SafeSmartAccountError, + transaction::UserOperation, }; use alloy::hex::FromHex; use alloy::primitives::{Address, Bytes, FixedBytes, U128, U256}; diff --git a/bedrock/tests/common.rs b/bedrock/tests/common.rs index 400e996f..28bcd90c 100644 --- a/bedrock/tests/common.rs +++ b/bedrock/tests/common.rs @@ -12,8 +12,7 @@ use bedrock::{ http_client::{AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, PrimitiveError, }, - smart_account::{UserOperation, ENTRYPOINT_4337}, - transaction::foreign::UnparsedUserOperation, + transaction::{foreign::UnparsedUserOperation, UserOperation, ENTRYPOINT_4337}, }; use serde::Serialize; use serde_json::json; @@ -71,6 +70,7 @@ sol!( sol! { /// Packed user operation for EntryPoint + /// This is duplicated from the main codebase because the sol! macro does not support using external types. #[sol(rename_all = "camelCase")] struct PackedUserOperation { address sender; @@ -84,8 +84,10 @@ sol! { bytes signature; } + /// The `EntryPoint` contract interface for testing. + /// Note this exposes different functions that should only be used in tests. #[sol(rpc)] - interface IEntryPoint { + interface IEntryPointForTests { function depositTo(address account) external payable; function handleOps(PackedUserOperation[] calldata ops, address payable beneficiary) external; } @@ -119,6 +121,7 @@ pub const SAFE_4337_MODULE_ADDRESS: Address = pub const SAFE_MODULE_SETUP_ADDRESS: Address = address!("2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47"); +#[allow(dead_code)] // this is used across integration tests pub fn setup_anvil() -> AnvilInstance { dotenvy::dotenv().ok(); let rpc_url = std::env::var("WORLDCHAIN_RPC_URL").unwrap_or_else(|_| { @@ -129,6 +132,7 @@ pub fn setup_anvil() -> AnvilInstance { alloy::node_bindings::Anvil::new().fork(rpc_url).spawn() } +#[allow(dead_code)] // this is used across integration tests pub async fn deploy_safe

( provider: &P, owner: Address, @@ -373,7 +377,7 @@ where // Execute on Anvil via EntryPoint let entry_point_contract = - IEntryPoint::new(*ENTRYPOINT_4337, &self.provider); + IEntryPointForTests::new(*ENTRYPOINT_4337, &self.provider); let packed_op = PackedUserOperation::try_from(&parsed_op).map_err(|e| { HttpError::Generic { diff --git a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs index 52b969d6..6714f862 100644 --- a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs +++ b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs @@ -6,16 +6,16 @@ use alloy::{ }; use bedrock::{ primitives::Network, - smart_account::{ - EncodedSafeOpStruct, SafeSmartAccount, SafeSmartAccountSigner, UserOperation, + smart_account::{SafeSmartAccount, SafeSmartAccountSigner}, + transaction::{ + foreign::UnparsedUserOperation, EncodedSafeOpStruct, UserOperation, ENTRYPOINT_4337, GNOSIS_SAFE_4337_MODULE, }, - transaction::foreign::UnparsedUserOperation, }; mod common; use common::{ - deploy_safe, setup_anvil, IEntryPoint, ISafe4337Module, PackedUserOperation, + deploy_safe, setup_anvil, IEntryPointForTests, ISafe4337Module, PackedUserOperation, }; /// Integration test for the encoding, signing and execution of a 4337 transaction. @@ -48,7 +48,7 @@ async fn test_integration_erc4337_transaction_execution() -> anyhow::Result<()> let before_balance = provider.get_balance(safe_address2).await?; // Fund EntryPoint deposit for the Safe - let entry_point = IEntryPoint::new(*ENTRYPOINT_4337, &provider); + let entry_point = IEntryPointForTests::new(*ENTRYPOINT_4337, &provider); let _ = entry_point .depositTo(safe_address) .value(U256::from(1e18)) @@ -99,7 +99,6 @@ async fn test_integration_erc4337_transaction_execution() -> anyhow::Result<()> .expect("Failed to create SafeSmartAccount"); let (va, vu) = user_op.extract_validity_timestamps()?; let op_hash = EncodedSafeOpStruct::from_user_op_with_validity(&user_op, va, vu) - .unwrap() .into_transaction_hash(); let worldchain_chain_id = Network::WorldChain as u32; diff --git a/bedrock/tests/test_smart_account_nonce.rs b/bedrock/tests/test_smart_account_nonce.rs index 50722b48..4ebd1914 100644 --- a/bedrock/tests/test_smart_account_nonce.rs +++ b/bedrock/tests/test_smart_account_nonce.rs @@ -8,9 +8,8 @@ use alloy::{ sol, }; -use bedrock::{ - primitives::BEDROCK_NONCE_PREFIX_CONST, - smart_account::{InstructionFlag, NonceKeyV1, TransactionTypeId}, +use bedrock::smart_account::{ + InstructionFlag, NonceKeyV1, TransactionTypeId, BEDROCK_NONCE_PREFIX_CONST, }; mod common; diff --git a/bedrock/tests/test_smart_account_safe_owner_swap.rs b/bedrock/tests/test_smart_account_safe_owner_swap.rs index 616c896c..976e46ba 100644 --- a/bedrock/tests/test_smart_account_safe_owner_swap.rs +++ b/bedrock/tests/test_smart_account_safe_owner_swap.rs @@ -7,12 +7,12 @@ use alloy::{ sol, }; use bedrock::{ - primitives::http_client::set_http_client, - smart_account::{SafeSmartAccount, ENTRYPOINT_4337}, + primitives::http_client::set_http_client, smart_account::SafeSmartAccount, + transaction::ENTRYPOINT_4337, }; mod common; -use common::{deploy_safe, setup_anvil, AnvilBackedHttpClient, IEntryPoint}; +use common::{deploy_safe, setup_anvil, AnvilBackedHttpClient, IEntryPointForTests}; sol! { /// Safe owner management interface @@ -60,7 +60,7 @@ async fn test_safe_owner_swap_e2e() -> anyhow::Result<()> { .await?; // Fund EntryPoint deposit for the Safe - let entry_point = IEntryPoint::new(*ENTRYPOINT_4337, &provider); + let entry_point = IEntryPointForTests::new(*ENTRYPOINT_4337, &provider); let _deposit_tx = entry_point .depositTo(safe_address) .value(U256::from(1e18)) diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index 0a83bfa2..48019323 100644 --- a/bedrock/tests/test_smart_account_transfer.rs +++ b/bedrock/tests/test_smart_account_transfer.rs @@ -15,15 +15,19 @@ use bedrock::{ }, Network, }, - smart_account::{SafeSmartAccount, ENTRYPOINT_4337}, - transaction::{foreign::UnparsedUserOperation, RpcProviderName}, + smart_account::SafeSmartAccount, + transaction::{ + foreign::UnparsedUserOperation, RpcProviderName, UserOperation, ENTRYPOINT_4337, + }, }; use serde::Serialize; use serde_json::json; mod common; -use common::{deploy_safe, setup_anvil, IEntryPoint, PackedUserOperation, IERC20}; +use common::{ + deploy_safe, setup_anvil, IEntryPointForTests, PackedUserOperation, IERC20, +}; // ------------------ Mock HTTP client that actually executes the op on Anvil ------------------ #[derive(Clone)] @@ -158,7 +162,7 @@ where factory_data: get_opt("factoryData"), }; - let user_op: bedrock::smart_account::UserOperation = + let user_op: UserOperation = unparsed.try_into().map_err(|e| HttpError::Generic { message: format!("invalid userOp: {e}"), })?; @@ -202,7 +206,8 @@ where message: "invalid entryPoint".into(), } })?; - let entry_point = IEntryPoint::new(entry_point_addr, &self.provider); + let entry_point = + IEntryPointForTests::new(entry_point_addr, &self.provider); let tx = entry_point .handleOps(vec![packed], user_op.sender) .send() @@ -258,7 +263,7 @@ async fn test_tx_transfer_full_flow_executes_user_operation() -> anyhow::Result< let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; // 4) Fund EntryPoint deposit for Safe - let entry_point = IEntryPoint::new(*ENTRYPOINT_4337, &provider); + let entry_point = IEntryPointForTests::new(*ENTRYPOINT_4337, &provider); let deposit_tx = entry_point .depositTo(safe_address) .value(U256::from(1e18 as u64))