Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bedrock/src/smart_account/nonce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 27 additions & 6 deletions bedrock/src/smart_account/transaction_4337.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +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::smart_account::{SafeOperation, SafeSmartAccountSigner};
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};
Expand All @@ -25,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: SafeOperation::Call as u8,
}
.abi_encode()
.into()
}

/// Converts the object into a preflight `UserOperation` for use with the `Safe4337Module`.
///
Expand Down Expand Up @@ -147,8 +168,8 @@ mod tests {

use super::*;
use crate::{
smart_account::SafeSmartAccount,
transaction::{foreign::UnparsedUserOperation, SponsorUserOperationResponse},
smart_account::SafeSmartAccount, transaction::foreign::UnparsedUserOperation,
transaction::rpc::SponsorUserOperationResponse,
};

#[test]
Expand Down
19 changes: 7 additions & 12 deletions bedrock/src/transaction/contracts/erc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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! {
Expand Down Expand Up @@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to define these fns on the Is4337Encodable instead of duplicating inside each impl?

self.token_address
}

fn call_data(&self) -> Bytes {
self.call_data.clone().into()
}

fn as_preflight_user_operation(
Expand Down
2 changes: 2 additions & 0 deletions bedrock/src/transaction/contracts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
//! that power the common transactions for the crypto wallet.

pub mod erc20;

pub mod safe_owner;
97 changes: 97 additions & 0 deletions bedrock/src/transaction/contracts/safe_owner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! 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, UserOperation,
},
};

const SENTINEL_ADDRESS: Address =
Copy link
Collaborator

@karankurbur karankurbur Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a doc here on what this magic address is

address!("0x0000000000000000000000000000000000000001");

sol! {
///Owner Manager Interface for the Safe
///
/// Reference: <https://github.com/safe-global/safe-smart-account/blob/v1.4.1/contracts/base/OwnerManager.sol>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming there is no difference here between 1.3.0 and 1.4.1 OwnerManager? Just wanted to ask

#[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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment is wrong

call_data: Vec<u8>,
/// 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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets use safe_address, wallet_address is unclear

old_owner: Address,
new_owner: Address,
) -> Self {
Self {
call_data: IOwnerManager::swapOwnerCall {
prev_owner: SENTINEL_ADDRESS,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this always the case? I'm not familiar with this logic in the contract

What if a user rotates for the 2nd/3rd time

old_owner,
new_owner,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Incorrect prev_owner Handling in SafeOwner::new

The SafeOwner::new function hardcodes prev_owner to SENTINEL_ADDRESS (0x1) for the swapOwner call. This is only correct for single-owner Safes or the first owner swap. For multi-owner Safes or subsequent rotations, prev_owner must be the actual preceding owner, causing transactions to fail on-chain.

Fix in Cursor Fix in Web

.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<Self::MetadataArg>,
) -> Result<UserOperation, PrimitiveError> {
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,
))
}
}
51 changes: 48 additions & 3 deletions bedrock/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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.
Expand Down Expand Up @@ -45,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",
Expand All @@ -62,7 +63,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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: lets remove tx_ prefix? Its already on the safe_account object

&self,
network: Network,
token_address: &str,
Expand All @@ -81,7 +82,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).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can fetch this from on-chain instead of having to take in as input

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need anything to enforce that this only done for world app safes? Ie check that threshold/signers length is 1

/// - `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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: swap_owner?

&self,
old_owner: &str,
new_owner: &str,
) -> Result<HexEncodedData, TransactionError> {
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())?)
Expand Down
Loading