Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -48,6 +48,8 @@ pub enum TransactionTypeId {
WLDVaultMigration = 139,
/// USD Vault migration to ERC-4626 vault
USDVaultMigration = 140,
/// Standard ERC-20 approve
Erc20Approve = 141,
}

impl TransactionTypeId {
Expand Down
108 changes: 108 additions & 0 deletions bedrock/src/transactions/contracts/erc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,76 @@ impl Is4337Encodable for Erc20 {
}
}

// ---------------------------------------------------------------------------
// Erc20Approve (standalone ERC-20 approve via 4337)
// ---------------------------------------------------------------------------

/// Represents a standard ERC-20 `approve(spender, value)` call executed as a
/// 4337 `UserOperation`.
///
/// This is distinct from [`Erc20`] (which is for transfers) and from
/// [`super::permit2::Permit2Approve`] (which calls the Permit2 contract).
pub struct Erc20Approve {
/// The ABI-encoded calldata for `IERC20.approve(spender, value)`.
call_data: Vec<u8>,
/// The address of the ERC-20 token contract.
token_address: Address,
}

impl Erc20Approve {
/// Creates a new ERC-20 approve operation.
///
/// # Arguments
/// * `token_address` - The address of the ERC-20 token contract.
/// * `spender` - The spender being approved.
/// * `value` - The approval amount.
#[must_use]
pub fn new(token_address: Address, spender: Address, value: U256) -> Self {
let call_data = IErc20::approveCall { spender, value }.abi_encode();

Self {
call_data,
token_address,
}
}
}

impl Is4337Encodable for Erc20Approve {
type MetadataArg = ();

fn build_execute_user_op_call_data(&self) -> Bytes {
ISafe4337Module::executeUserOpCall {
to: self.token_address,
value: U256::ZERO,
data: self.call_data.clone().into(),
operation: SafeOperation::Call as u8,
}
.abi_encode()
.into()
}

fn build_preflight_user_operation(
&self,
wallet_address: Address,
_metadata: Option<Self::MetadataArg>,
) -> Result<UserOperation, PrimitiveError> {
let call_data = self.build_execute_user_op_call_data();

let key = NonceKeyV1::new(
TransactionTypeId::Erc20Approve,
InstructionFlag::Default,
[0u8; 10],
);
let nonce = key.encode_with_sequence(0);

Ok(UserOperation::new_with_defaults(
wallet_address,
nonce,
call_data,
))
}
}

#[cfg(test)]
mod tests {
use alloy::primitives::bytes;
Expand All @@ -224,6 +294,44 @@ mod tests {
assert_eq!(execute_user_op_call_data, expected_call_data);
}

#[test]
fn test_erc20_approve_call_data() {
let approve = Erc20Approve::new(
Address::from_str("0x2cFc85d8E48F8EAB294be644d9E25C3030863003").unwrap(),
Address::from_str("0x1234567890123456789012345678901234567890").unwrap(),
U256::from(1),
);

let execute_user_op_call_data = approve.build_execute_user_op_call_data();

let expected_call_data = bytes!("0x7bb374280000000000000000000000002cfc85d8e48f8eab294be644d9e25c30308630030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000");

assert_eq!(execute_user_op_call_data, expected_call_data);
}

#[test]
fn test_erc20_approve_preflight_nonce_type_id() {
let token =
Address::from_str("0x2cFc85d8E48F8EAB294be644d9E25C3030863003").unwrap();
let spender =
Address::from_str("0x1234567890123456789012345678901234567890").unwrap();
let approve = Erc20Approve::new(token, spender, U256::MAX);

let wallet =
Address::from_str("0x4564420674EA68fcc61b463C0494807C759d47e6").unwrap();
let user_op = approve
.build_preflight_user_operation(wallet, None)
.unwrap();

let be: [u8; 32] = user_op.nonce.to_be_bytes();

assert_eq!(&be[0..=4], BEDROCK_NONCE_PREFIX_CONST);
assert_eq!(be[5], TransactionTypeId::Erc20Approve as u8);
assert_eq!(be[6], 0u8);
assert_eq!(&be[7..=16], &[0u8; 10]);
assert_eq!(&be[24..32], &[0u8; 8]);
}

#[test]
fn test_erc20_preflight_user_operation_nonce_v1_no_metadata() {
let token =
Expand Down
40 changes: 39 additions & 1 deletion bedrock/src/transactions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
},
transactions::{
contracts::{
erc20::{Erc20, MetadataArg, TransferAssociation},
erc20::{Erc20, Erc20Approve, MetadataArg, TransferAssociation},
usd_legacy_vault::Permit2Data,
world_gift_manager::WorldGiftManager,
},
Expand Down Expand Up @@ -119,6 +119,44 @@ impl SafeSmartAccount {
Ok(HexEncodedData::new(&user_op_hash.to_string())?)
}

/// Sets a standard ERC-20 allowance for a spender on a specific token.
///
/// This calls the token contract's `approve(spender, amount)` function.
///
/// # Arguments
/// - `token_address`: The ERC-20 token address to set the allowance for.
/// - `spender_address`: The address being granted permission to spend the token.
/// - `amount`: The maximum amount of tokens the spender can transfer, as a stringified `uint256`.
///
/// # Errors
/// - 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_erc20_approve(
&self,
token_address: &str,
spender_address: &str,
amount: &str,
) -> Result<HexEncodedData, TransactionError> {
let token_address = Address::parse_from_ffi(token_address, "token_address")?;
let spender_address =
Address::parse_from_ffi(spender_address, "spender_address")?;
let amount = U256::parse_from_ffi(amount, "amount")?;

let transaction = Erc20Approve::new(token_address, spender_address, amount);

let provider = RpcProviderName::Any;

let user_op_hash = transaction
.sign_and_execute(self, Network::WorldChain, None, None, provider)
.await
.map_err(|e| TransactionError::Generic {
error_message: format!("Failed to execute ERC-20 approve: {e}"),
})?;

Ok(HexEncodedData::new(&user_op_hash.to_string())?)
}

/// Sets a Permit2 allowance for a spender on a specific token via the `IAllowanceTransfer.approve` method.
///
/// This calls the Permit2 contract's `approve(token, spender, amount, expiration)` function,
Expand Down
86 changes: 86 additions & 0 deletions bedrock/tests/test_smart_account_erc20_approve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::sync::Arc;

mod common;
use alloy::{
primitives::{address, U256},
providers::{ext::AnvilApi, ProviderBuilder},
signers::local::PrivateKeySigner,
};
use bedrock::{
primitives::http_client::set_http_client,
smart_account::{SafeSmartAccount, ENTRYPOINT_4337},
test_utils::{AnvilBackedHttpClient, IEntryPoint},
};
use common::{deploy_safe, set_erc20_balance_for_safe, setup_anvil, IERC20};

#[tokio::test]
async fn test_transaction_erc20_approve_full_flow_sets_allowance() -> anyhow::Result<()>
{
// 1) Spin up anvil fork
let anvil = setup_anvil();

// 2) Owner signer and provider
let owner_signer = PrivateKeySigner::random();
let owner_key_hex = hex::encode(owner_signer.to_bytes());
let owner = owner_signer.address();

let provider = ProviderBuilder::new()
.wallet(owner_signer.clone())
.connect_http(anvil.endpoint_url());

provider
.anvil_set_balance(owner, U256::from(1e18 as u64))
.await?;

// 3) Deploy Safe with 4337 module enabled
let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?;

// 4) Fund EntryPoint deposit for Safe
let entry_point = IEntryPoint::new(*ENTRYPOINT_4337, &provider);
entry_point
.depositTo(safe_address)
.value(U256::from(1e18 as u64))
.send()
.await?
.get_receipt()
.await?;

// 5) Give Safe some ERC-20 balance to match real app usage
let token_address = address!("0x2cFc85d8E48F8EAB294be644d9E25C3030863003");
set_erc20_balance_for_safe(
&provider,
token_address,
safe_address,
U256::from(10u128.pow(18) * 10),
)
.await?;

let token = IERC20::new(token_address, &provider);
let spender = PrivateKeySigner::random().address();
let amount = U256::from(10u128.pow(18));

assert_eq!(
token.allowance(safe_address, spender).call().await?,
U256::ZERO
);

// 6) Install mocked HTTP client that routes sponsorship and send calls to Anvil
let client = AnvilBackedHttpClient::new(provider.clone());
set_http_client(Arc::new(client));

// 7) Execute high-level approve via transaction_erc20_approve
let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?;
let _user_op_hash = safe_account
.transaction_erc20_approve(
&token_address.to_string(),
&spender.to_string(),
&amount.to_string(),
)
.await
.expect("transaction_erc20_approve failed");

// 8) Verify allowance updated on-chain
assert_eq!(token.allowance(safe_address, spender).call().await?, amount);

Ok(())
}
Loading