diff --git a/bedrock/src/smart_account/nonce.rs b/bedrock/src/smart_account/nonce.rs index 6df363cc..0a43bd08 100644 --- a/bedrock/src/smart_account/nonce.rs +++ b/bedrock/src/smart_account/nonce.rs @@ -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 { diff --git a/bedrock/src/transactions/contracts/erc20.rs b/bedrock/src/transactions/contracts/erc20.rs index dfba8410..64e90ca9 100644 --- a/bedrock/src/transactions/contracts/erc20.rs +++ b/bedrock/src/transactions/contracts/erc20.rs @@ -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, + /// 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, + ) -> Result { + 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; @@ -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 = diff --git a/bedrock/src/transactions/mod.rs b/bedrock/src/transactions/mod.rs index 61592f75..caf8dcf0 100644 --- a/bedrock/src/transactions/mod.rs +++ b/bedrock/src/transactions/mod.rs @@ -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, }, @@ -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 { + 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, diff --git a/bedrock/tests/test_smart_account_erc20_approve.rs b/bedrock/tests/test_smart_account_erc20_approve.rs new file mode 100644 index 00000000..8b25254e --- /dev/null +++ b/bedrock/tests/test_smart_account_erc20_approve.rs @@ -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(()) +}