From 497c9f2d65ece535ae243b2b09aea5dd220a5269 Mon Sep 17 00:00:00 2001 From: Sim Date: Fri, 10 Apr 2026 12:02:47 -0700 Subject: [PATCH] Add ERC20 approve transaction helper Add a generic ERC20 approve constructor in the contract transaction layer and expose a matching SafeSmartAccount transaction_erc20_approve API. This lets native clients submit a standard token approve(spender, amount) call through the existing 4337 transaction pipeline. World Card onboarding needs this to grant Permit2 spending approval before submitting the separate Permit2 allowance for the funding contract. --- bedrock/src/smart_account/nonce.rs | 2 + bedrock/src/transactions/contracts/erc20.rs | 108 ++++++++++++++++++ bedrock/src/transactions/mod.rs | 40 ++++++- .../tests/test_smart_account_erc20_approve.rs | 86 ++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 bedrock/tests/test_smart_account_erc20_approve.rs 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(()) +}