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 0f1df329..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)] @@ -18,6 +23,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/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index b85b8dfd..7102ba63 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -3,16 +3,17 @@ //! A transaction can be initialized through a `UserOperation` struct. //! -use crate::primitives::contracts::{EncodedSafeOpStruct, UserOperation}; use crate::primitives::{Network, PrimitiveError}; -use crate::smart_account::SafeSmartAccountSigner; -use crate::transaction::rpc::{RpcError, RpcProviderName}; - -use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes}; +use crate::smart_account::{SafeOperation, SafeSmartAccountSigner}; +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. @@ -25,11 +26,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: SafeOperation::Call as u8, + } + .abi_encode() + .into() + } /// Converts the object into a preflight `UserOperation` for use with the `Safe4337Module`. /// @@ -93,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) @@ -114,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(), @@ -147,8 +166,8 @@ mod tests { use super::*; use crate::{ - smart_account::SafeSmartAccount, - transaction::{foreign::UnparsedUserOperation, SponsorUserOperationResponse}, + smart_account::SafeSmartAccount, transaction::foreign::UnparsedUserOperation, + transaction::rpc::SponsorUserOperationResponse, }; #[test] @@ -178,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(); @@ -265,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") @@ -311,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 afece8d2..6b3a1311 100644 --- a/bedrock/src/transaction/contracts/erc20.rs +++ b/bedrock/src/transaction/contracts/erc20.rs @@ -6,11 +6,10 @@ use alloy::{ sol_types::SolCall, }; -use crate::primitives::PrimitiveError; use crate::smart_account::{ - ISafe4337Module, InstructionFlag, Is4337Encodable, NonceKeyV1, SafeOperation, - TransactionTypeId, UserOperation, + InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId, }; +use crate::{primitives::PrimitiveError, transaction::UserOperation}; sol! { /// The ERC20 contract interface. @@ -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( @@ -138,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 166a8881..3e242900 100644 --- a/bedrock/src/transaction/contracts/mod.rs +++ b/bedrock/src/transaction/contracts/mod.rs @@ -1,4 +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 new file mode 100644 index 00000000..29b98271 --- /dev/null +++ b/bedrock/src/transaction/contracts/safe_owner.rs @@ -0,0 +1,96 @@ +//! 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}, + sol, + sol_types::SolCall, +}; + +use crate::{ + primitives::PrimitiveError, + smart_account::{InstructionFlag, Is4337Encodable, NonceKeyV1, TransactionTypeId}, + transaction::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; + } +} + +/// 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, + /// The address of the Safe Smart Account. + wallet_address: Address, +} + +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, + 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 = (); + + fn target_address(&self) -> Address { + self.wallet_address + } + + fn call_data(&self) -> Bytes { + self.call_data.clone().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/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 8063789d..6a7d4b4d 100644 --- a/bedrock/src/transaction/mod.rs +++ b/bedrock/src/transaction/mod.rs @@ -11,6 +11,11 @@ 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}; /// Errors that can occur when interacting with transaction operations. @@ -45,7 +50,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", @@ -62,7 +67,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 +86,51 @@ 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: 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 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/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 7a7ea4d4..28bcd90c 100644 --- a/bedrock/tests/common.rs +++ b/bedrock/tests/common.rs @@ -1,13 +1,21 @@ 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, + }, + transaction::{foreign::UnparsedUserOperation, UserOperation, ENTRYPOINT_4337}, +}; +use serde::Serialize; +use serde_json::json; sol!( #[allow(missing_docs)] @@ -62,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; @@ -75,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; } @@ -110,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(|_| { @@ -120,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, @@ -218,3 +231,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 = + IEntryPointForTests::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_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 new file mode 100644 index 00000000..976e46ba --- /dev/null +++ b/bedrock/tests/test_smart_account_safe_owner_swap.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use alloy::{ + primitives::U256, + providers::{ext::AnvilApi, ProviderBuilder}, + signers::local::PrivateKeySigner, + sol, +}; +use bedrock::{ + primitives::http_client::set_http_client, smart_account::SafeSmartAccount, + transaction::ENTRYPOINT_4337, +}; + +mod common; +use common::{deploy_safe, setup_anvil, AnvilBackedHttpClient, IEntryPointForTests}; + +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; + } +} + +/// 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 = IEntryPointForTests::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() + ); + + // 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(()) +} diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index 81d0fac1..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() @@ -234,11 +239,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(); @@ -259,7 +263,7 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( 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)) @@ -292,11 +296,11 @@ 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 - .transaction_transfer( + .tx_transfer( Network::WorldChain, &wld_token_address.to_string(), &recipient.to_string(), @@ -304,7 +308,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?;