August 11, 2025

A Complete Guide to Gas for ERC-4337 Wallets

Introduction

In the ERC‑4337 model, a smart wallet doesn’t submit transactions directly. Instead, it creates a UserOperation and sends it to a Bundler (a node service). The bundler batches UserOperations and calls the EntryPoint contract, which then executes the user’s intended actions on-chain.

If a Paymaster participates, a combination of an off‑chain service and an on‑chain Paymaster contract, it can sponsor the gas so the user doesn’t pay. ERC‑7677standardizes the interface for paymaster sponsorship, defining how wallets request sponsorship and how this information flows so that wallets, paymasters, and bundlers interoperate reliably.

Of course, this is only a simplified overview. You can dig into the proposal documents for more technical details.

Here, however, I’d like to focus on gas fee considerations in this context.

Execute UserOperations

Based on safe smart account infrastructure, I‘m glad the Pimlico team offers permissionless.js which simplifies integrating smart accounts, bundlers, paymasters, and user operations.

It provides a createSmartAccountClient function, allowing developers to compose a bundler client with or without a paymaster client. The returned object is essentially similar to that of createBundlerClient (exported from viem/account-abstraction), with the main difference being that createSmartAccountClient extends additional smart account actions, such as sendTransaction.

Internally, sendTransaction calls sendUserOperation followed by waitForUserOperationReceipt, returning a real transaction hash. This is convenient and abstracts away the concept of UserOperation.

We can send a transaction with a smart wallet in the following way:

js
1import { createSmartAccountClient } from 'permissionless'; 2import { toSafeSmartAccount } from 'permissionless/accounts'; 3import { createPimlicoClient } from 'permissionless/clients/pimlico'; 4import { Address, http } from 'viem'; 5import { entryPoint07Address } from 'viem/account-abstraction'; 6 7// generate a smart account client 8const safeAccount = await toSafeSmartAccount({ 9 client: publicClient, 10 owners: [walletProvider as EthereumProvider], 11 entryPoint: { 12 address: entryPoint07Address, 13 version: '0.7', 14 }, 15 version: '1.4.1', 16}); 17 18// create a custom paymasterClient 19const paymasterClient = createPimlicoClient({ 20 transport: http(PAYMASTER_UTL), 21 entryPoint: { 22 address: entryPoint07Address, 23 version: '0.7', 24 }, 25}); 26 27const smartAccountClient = createSmartAccountClient({ 28 account: safeAccount, 29 chain: CURRENT_CHAIN, 30 bundlerTransport: http(BUNDLER_URL), 31 userOperation: { 32 estimateFeesPerGas: async () => { 33 const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); 34 const enhancedMaxFeePerGas = (maxFeePerGas * 12n) / 10n; // 20% increase 35 const enhancedMaxPriorityFeePerGas = (enhancedMaxFeePerGas * 10n) / 100n; 36 return { 37 maxFeePerGas: enhancedMaxFeePerGas, 38 maxPriorityFeePerGas: enhancedMaxPriorityFeePerGas, 39 }; 40 } 41 } 42}) 43 44 45// send a transaction through smart wallet client 46// the hash is a tx hash instead of a userOp hash 47const hash = await smartWalletClient.sendTransaction({ 48 calls: [{ 49 abi: ERC20ABI, 50 functionName: 'mint', 51 to: USDC_TOKEN.address, 52 args: [smartWalletClient.account.address, parseUnits('100', USDC_TOKEN.decimals)], 53 }], 54 paymaster: paymasterClient, 55 paymasterContext: { policyId: 1 }, 56 57}); 58 59

Thanks to permissionless.js and viem, developers can easily implement smart wallet transactions. On the other hand, they abstract away the communication between the smart wallet account, paymaster, and bundler, making it hard to know which data is exchanged in between.

For users, a thoughtful interaction would be to display the platform’s estimated gas fee, alert them when funds are insufficient, and offer ERC-20 paymaster options. Moreover, if the user chooses, for example, a USDC paymaster, showing the required amount in USDC instead of ETH would make the experience even more friendly.

Also, sendTransaction only returns the transaction hash so developers can't directly retrieve transaction status for display in the UI. In reality, it indeed uses viem’s getUserOperationReceipt to obtain the full UserOperationReceipt. I've submitted a pr to address this.

How EntryPoint Validates Funding for a UserOperation

The entry to executing a UserOperation in the EntryPoint contract is the handleOps function. Within it, the validatePrepayment method is invoked, which calls getRequiredPrefund.

As the comment of this function is "Get the required prefunded gas fee amount for an operation", Yeah, that's what I need.

sol
1function _getRequiredPrefund( 2 MemoryUserOp memory mUserOp 3) internal virtual pure returns (uint256 requiredPrefund) { 4 unchecked { 5 uint256 requiredGas = 6 mUserOp.verificationGasLimit + 7 mUserOp.callGasLimit + 8 mUserOp.paymasterVerificationGasLimit + 9 mUserOp.paymasterPostOpGasLimit + 10 mUserOp.preVerificationGas; 11 12 requiredPrefund = requiredGas * mUserOp.maxFeePerGas; 13 } 14}

We can easily find the definations of the five gas values in ERC-4337 doc.

FieldTypeDescription
callGasLimituint256The amount of gas to allocate the main execution call
verificationGasLimituint256The amount of gas to allocate for the verification step
preVerificationGasuint256Extra gas to pay the bundler
paymasterVerificationGasLimituint256The amount of gas to allocate for the paymaster validation code (only if paymaster exists)
paymasterPostOpGasLimituint256The amount of gas to allocate for the paymaster post-operation code (only if paymaster exists)

Now, the next question turns to where do the five gas values come from? Even, as we all know, when we send a transaction as a sender. the transaction schema looks like below:

js
1const hash = await walletClient.sendTransaction({ 2 data: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", 3 account, 4 to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 5 value: 1000000000000000000n, 6})

How do we generate a UserOperation including fields like verificationGasLimit, callGasLimit , paymasterVerificationGasLimit , paymasterPostOpGasLimit , preVerificationGas and maxFeePerGas ?

Estimate Gas Fee

there is a function called estimateUserOperationGas in Bundles Actions. And it exactly returns the five gas limit values.

ts
1{ 2 callGasLimit: bigint 3 preVerificationGas: bigint 4 verificationGasLimit: bigint 5 paymasterVerificationGasLimit: bigint | undefined 6 paymasterPostOpGasLimit: bigint | undefined 7}

In the raw code, it first calls prepareUserOperation and requests eth_estimateUserOperationGas for gas. The real magic happens inside prepareUserOperation , which is also part of the Bundler Actions.

When calling prepareUserOperation ,we can notice an extra param called parameters .

ts
1await getAction( 2 client, 3 prepareUserOperation, 4 "prepareUserOperation", 5)({ 6 ...parameters, 7 parameters: ["authorization", "factory", "nonce", "paymaster", "signature"], 8} as unknown as PrepareUserOperationParameters)

Compared to a normal sendUserOperation call, the prepareUserOperation call here uses default parameters that omit fees and gas.

In prepareUserOperation.ts#L435 , when parameters include fees, it calls the userOperation.estimateFeesPerGas function you supplied to createSmartAccountClient to fetch maxFeePerGas and maxPriorityFeePerGas. Since eth_estimateUserOperationGas only estimates gas limit, it ignores fees.

Starting from prepareUserOperation.ts#L598, the gas handling logic begins, ultimately calling estimateUserOperationGas to obtain the five components that make up the total gas limit. The estimateUserOperationGas function implements this by sending an eth_estimateUserOperationGas request to the bundler service.

Let’s skip the generation of authorization, factory, and nonce, and dive into paymaster and signature, as these two values deserve more attention when performing gas estimation.

Paymaster service provides two functions, pm_getPaymasterStubData and pm_getPaymasterData.

A formal diagram illustrates the call sequence in the UserOperation flow.

placeholder

Before sending a UserOperation, the client first calls pm_getPaymasterStubData(provided by the paymaster service) to obtain paymaster related gas parameters,mainly paymasterVerificationGasLimit and paymasterPostOpGasLimit. These, along with other parameters, are then passed to eth_estimateUserOperationGas (provided by the bundler service) to get the remaining three gas parameters: callGasLimit, preVerificationGas, and verificationGasLimit. With these gas related parameters assembled, pm_getPaymasterData is called.

The paymaster returns paymasterData(with EntryPoint v7/v8) containing the actual signature . This data is ultimately verified in the EntryPoint contract during handleOps via IPaymaster(paymaster).validatePaymasterUserOp to check the signature is correct.

From this, two things are clear:

  1. pm_getPaymasterStubData is mainly used to estimate the gas required during the paymaster’s execution.
  2. pm_getPaymasterData is used to obtain the signed paymasterData for eth_sendUserOperation when sending a UserOperation.

In ERC-7677, the defination of pm_getPaymasterStubData clarifies that "returns stub values to be used in paymaster-related fields of an unsigned user operation for gas estimation".

Therefore, viem suggests calling account.getStubSignature(request) to get a fake signature and pass it to the paymaster service.

As for pm_getPaymasterData, it is described as "these are not stub values and will be used during user operation submission to a bundler".

The Paymaster service is responsible for generating this signed paymasterData off chain. When sending a UserOperation containing the real paymasterData, the paymasterData will be validated by paymaster contract eventually.

In this case, the user’s quota is often pre-charged at the moment pm_getPaymasterData successfully returns signed paymasterData, this ensures legitimate usage while protecting against abuse.

The estimateUserOperationGas process can be summarized as follows:

  1. Call pm_getPaymasterStubData to obtain the potential gas costs for the paymaster’s execution, paymasterVerificationGasLimit and paymasterPostOpGasLimit.
  2. Fill these into the UserOperation(under construction), then request eth_estimateUserOperationGas to get the EntryPoint execution gas values: callGasLimit, preVerificationGas, and verificationGasLimit.
  3. Call smartWalletClient.userOperation.estimateFeesPerGas() to get maxFeePerGas.
  4. Calculate totalRequiredGas, compute const estimatedGasFee = totalRequiredGas * maxFeePerGas, optionally multiplying by 1.2 for a safety buffer.

estimatedGasFee = ((callGasLimit + preVerificationGas + verificationGasLimit + paymasterVerificationGasLimit + paymasterPostOpGasLimit) * maxFeePerGas) * 12n / 10n

When inspecting viem’s prepareUserOperation, we see that it assumes the paymaster service may return isFinal: true from pm_getPaymasterStubData to skip pm_getPaymasterData call, effectively declaring that no sponsorship will be used, and this run is only for estimation.

ts
1... 2let isPaymasterPopulated = false 3if ( 4 properties.includes('paymaster') && 5 getPaymasterStubData && 6 !paymasterAddress && 7 !parameters.paymasterAndData 8) { 9 const { 10 isFinal = false, 11 sponsor, 12 ...paymasterArgs 13 14 } = await getPaymasterStubData({ 15 chainId: await getChainId(), 16 entryPointAddress: account.entryPoint.address, 17 context: paymasterContext, 18 ...(request as UserOperation), 19 }) 20 21 isPaymasterPopulated = isFinal 22} 23... 24 25if ( 26 properties.includes('paymaster') && 27 getPaymasterData && 28 !paymasterAddress && 29 !parameters.paymasterAndData && 30 !isPaymasterPopulated 31) { 32 33 const paymaster = await getPaymasterData({ 34 chainId: await getChainId(), 35 entryPointAddress: account.entryPoint.address, 36 context: paymasterContext, 37 ...(request as UserOperation), 38 }) 39 40 ... 41 42} 43

It’s great if the paymaster service you’re using supports this mechanism. Since it’s not a required field in GetPaymasterStubDataResult, we need to piece together our own method to obtain the final totalRequiredGas.

ts
1type GetPaymasterStubDataResult = { 2 sponsor?: { name: string; icon?: string } // Sponsor info 3 paymaster?: string // Paymaster address (entrypoint v0.7) 4 paymasterData?: string // Paymaster data (entrypoint v0.7) 5 paymasterVerificationGasLimit?: `0x${string}` // Paymaster validation gas (entrypoint v0.7) 6 paymasterPostOpGasLimit?: `0x${string}` // Paymaster post-op gas (entrypoint v0.7) 7 paymasterAndData?: string // Paymaster and data (entrypoint v0.6) 8 isFinal?: boolean // Indicates that the caller does not need to call pm_getPaymasterData 9}

Build an estimateUserOperation Function

We need a function that, based on a parameter, determines whether the user will use a paymaster and, if so, which type. In theory, there are three cases:

  1. Pass a value indicating sponsorship should be used when the sponsor quota is available.
  2. Pay gas fee with ETH from the wallet, with no paymaster involved, especially when the sponsor quota is exhausted.
  3. Select an ERC-20 paymaster to pay gas in tokens, especially when ETH is insufficient.

In the following code, I use the USDC paymaster as the sole and default ERC-20 paymaster, though the logic can be easily extended.

js
1 2import { isNumber } from 'es-toolkit/compat'; 3import { createPimlicoClient } from 'permissionless/clients/pimlico'; 4import { entryPoint07Address } from 'viem/account-abstraction' 5 6const paymastClient = createPimlicoClient({ 7 transport: http(PAYMASTER_UTL), 8 entryPoint: { 9 address: entryPoint07Address, 10 version: '0.7', 11 }, 12}); 13 14export const genPaymasterOptions = (paymastClient, paymaster?: 'usdc' | number) => { 15 if (isNumber(paymaster)) { 16 return { 17 paymaster: paymastClient, 18 paymasterContext: { policyId: paymaster ?? 1 }, 19 }; 20 } 21 // TODO: in reality, we should keep a ERC-20 paymaster map to cover this 22 if (paymaster === 'usdc') { 23 return { 24 paymaster: paymastClient, 25 paymasterContext: { 26 token: USDC_TOKEN.address, 27 }, 28 }; 29} 30 31 return {}; 32};

The arguments for smartAccountClient.sendTransaction is called sendTransactionParams here. Don’t include "paymaster" when calling prepareUserOperation as they’ll be handled separately and cautiously.

ts
1import { isEmpty } from "es-toolkit/compat" 2import { fromHex, slice } from "viem" 3 4export const parseExchangeRate = (paymasterData: `0x${string}`): bigint => { 5 const fragment = slice(paymasterData, 49, 49 + 32) 6 const exChangeRate = fromHex(fragment, "bigint") 7 return exChangeRate 8} 9 10export const estimateUserOperation = async ( 11 smartWalletClient, 12 sendTransactionParams, 13 paymasterOptions, 14) => { 15 let exChangeRate = 1n 16 let precision = 1n 17 let request = await smartWalletClient.prepareUserOperation({ 18 ...sendTransactionParams, 19 parameters: ["authorization", "factory", "nonce"], 20 }) 21 request = { 22 chainId: smartWalletClient.chain.id, 23 ...request, 24 } 25 let paymasterArgs = {} 26 if (!isEmpty(paymasterOptions)) { 27 const { paymaster, paymasterContext } = paymasterOptions 28 const signature = await smartWalletClient.account.getStubSignature(request) 29 30 request = { 31 ...request, 32 entryPointAddress: smartWalletClient.account.entryPoint.address, 33 context: paymasterContext, 34 signature, 35 } 36 paymasterArgs = await paymaster.getPaymasterStubData(request) 37 38 // token in paymasterContext means using ERC-20 paymaster 39 // no token means using eth paymaster, exchangeRate is 1n 40 exChangeRate = paymasterContext.token 41 ? parseExchangeRate(paymasterArgs.paymasterData) 42 : 1n 43 // exchangeRate is given with 18 decimals of precision 44 precision = paymasterContext.token ? BigInt(10 ** 18) : 1n 45 } 46 47 request = { 48 ...request, 49 ...paymasterArgs, 50 } 51 52 const gas = await smartWalletClient.estimateUserOperationGas({ 53 callGasLimit: 0n, 54 preVerificationGas: 0n, 55 verificationGasLimit: 0n, 56 ...(paymasterOptions 57 ? { paymasterPostOpGasLimit: 0n, paymasterVerificationGasLimit: 0n } 58 : {}), 59 ...request, 60 }) 61 62 const fees = await smartWalletClient.userOperation.estimateFeesPerGas() 63 64 const gasLimitComposition = { 65 callGasLimit: request.callGasLimit ?? gas.callGasLimit, 66 preVerificationGas: request.preVerificationGas ?? gas.preVerificationGas, 67 verificationGasLimit: request.verificationGasLimit ?? gas.verificationGasLimit, 68 paymasterPostOpGasLimit: 69 request.paymasterPostOpGasLimit ?? gas.paymasterPostOpGasLimit, 70 paymasterVerificationGasLimit: 71 request.paymasterVerificationGasLimit ?? gas.paymasterVerificationGasLimit, 72 } 73 74 const requiredGas = 75 gasLimitComposition.verificationGasLimit + 76 gasLimitComposition.callGasLimit + 77 (gasLimitComposition.paymasterVerificationGasLimit ?? 0n) + 78 (gasLimitComposition.paymasterPostOpGasLimit ?? 0n) + 79 gasLimitComposition.preVerificationGas 80 81 const requiredPrefund = (requiredGas * fees.maxFeePerGas) as unknown as bigint 82 const requiredPrefundInToken = (requiredPrefund * exChangeRate) / precision 83 84 return { 85 gasFee: requiredPrefundInToken, 86 } 87}

Basically, it refers to prepareUserOperation and estimateUserOperation in viem. However there’s something to note:

  1. Manually get gas limit for paymaster

    Call prepareUserOperation without "paymaster" and "signature".

    By manually calling getStubSignature and getPaymasterStubData, you can get paymasterVerificationGasLimit and paymasterPostOpGasLimit, then inject those into the request before calling estimateUserOperationGas.

  2. eth_estimateUserOperationGas won’t return paymasterPostOpGasLimit

    The paymasterPostOpGasLimit is not returned by eth_estimateUserOperationGas even if you passed a certain value to it, as stated in ERC-7769.

    So remember to retrieve this value from paymasterArgs. Since the bundler can’t reliably estimate the paymaster’s postOp cost, viem’s estimateUserOperationGas follows the same approach and does not populate this field.

    Though, it does set paymasterPostOpGasLimit from paymasterArgs in prepareUserOperation implementation.

  3. Convert ETH fee to paymaster token amount

    Pimlico team defines a paymasterData format that includes an exchangeRate field for converting between ETH and an ERC-20 token amount.

    Developers can extract exchangeRate from paymasterData to calculate the required ERC-20 token amount when using an ERC-20 paymaster and verify whether the user has enough tokens to cover the gas fee.

    The calculation can follow the approach in SingletonPaymasterV7’s preFundInToken.

    If the paymaster implementation doesn't provide this field, you can also retrieve the token’s fiat price from Coingecko API and derive the corresponding exchange rate.

How Paymaster Participation Affects Gas

Before discovering that isFinal: true can avoid consuming sponsor quota, the quickest way to estimate gas was to exclude the paymaster. However, for the same UserOperation, estimates with and without a paymaster differ.

Below are my test results, with all values expressed as gas limits in ETH.

ETH paymaster: The gas fee is sponsored by the paymaster, which pays in ETH.

Need approve: The user must call approve to authorize the paymaster to spend their USDC.

First tx: In the first UserOperation, the smart wallet contract needs to be deployed.

placeholder

I observed several clear patterns.

  1. For the same UserOperation, not using paymaster costs more gas than using ETH paymaster

    The difference is, without paymaster, the contract wallet needs to initiate a self-funded prefund to the EntryPoint, the on-chain transfer incurs an additional cost.

    In the ETH paymaster flow, the EntryPoint no longer requires the account to top up, since the sponsor has already deposited enough ETH. As a result, top-up logic is skipped, saving that cost.

    Therefore, the paymaster path is actually slightly cheaper overall.

  2. For the same UserOperation, using USDC paymaster costs more gas than using ETH paymaster

    Even without the call that approves to authorize the paymaster to spend their USDC, the paymaster triggers an ERC-20 transfer to pull USDC for reimbursement, which incurs additional gas overhead.

  3. Sending an ERC-20 token costs more gas than sending ETH

    A simple ETH transfer doesn’t trigger contract logic, whereas an ERC-20 transfer relies on contract execution. Updating state variables adds extra gas consumption.

Pay Gas Fee Without Paymaster

In practice, I noticed that, in early few transactions, the ETH balance deducted for gas fee exceeded the actualGasCost reported in UserOperationEvent.

Once the Deposited.totalDeposit roughly surpassed the estimated gas fee, the deducted amount matched actualGasCost.

In validateAccountPrepayment implementation, a check might reveal the truth.

sol
1function _validateAccountPrepayment( 2 uint256 opIndex, 3 PackedUserOperation calldata op, 4 UserOpInfo memory opInfo, 5 uint256 requiredPrefund 6 ) 7 ... 8 address paymaster = mUserOp.paymaster; 9 uint256 missingAccountFunds = 0; 10 if (paymaster == address(0)) { 11 uint256 bal = balanceOf(sender); 12 missingAccountFunds = bal > requiredPrefund 13 ? 0 14 : requiredPrefund - bal; 15 } 16 validationData = _callValidateUserOp(opIndex, op, opInfo, missingAccountFunds); 17 ... 18}

If no paymaster applies, the value of balanceOf(sender) should be larger than requiredPrefund(in other words, estimated gas fee). Otherwise, the shortfall (missingAccountFunds) is computed and the account’s payPrefund is invoked to transfer ETH to EntryPoint contract from user's wallet.

Here, balanceOf doesn’t query the wallet’s ETH balance. It calls StakeManager.balanceOf to read the account’s deposit in the EntryPoint. Each sender has a deposit record in StakeManager.

Back to specification, the last key word in Definitions states that, a sender(or a paymaster) must have transferred enough amount of ether to cover the following gas cost.

Also, we can call withdrawTo to retrieve the deposit.

placeholder

Then, after successful validation, when executing executeUserOp, the difference between the amount prepaid based on the estimated gas fee and the actual gas used is recorded in the StakeManager’s deposit via incrementDeposit.

This explains why I observed extra ETH in earlier transactions and only needed to pay the exact amount once Deposited.totalDeposit matched the estimated gas.

Additionally, you can withdraw your deposit by calling the EntryPoint’s withdrawTo function.

placeholder

Pay Gas Fee With Paymaster

paymaster sponsorship

EntryPoint calls validatePaymasterPrepayment to check that deposit[paymaster].deposit > requiredPreFund.

js
1 2// in validatePaymasterUserOp, this function is called to deduct the Paymaster's deposit in EntryPoint 3function _tryDecrementDeposit(address account, uint256 amount) internal returns(bool) { 4 unchecked { 5 DepositInfo storage info = deposits[account]; 6 uint256 currentDeposit = info.deposit; 7 if (currentDeposit < amount) { 8 return false; 9 } 10 info.deposit = currentDeposit - amount; 11 return true; 12 } 13}

Since the account in this case is the paymaster contract address rather than the user’s account address, it means all transactions using that paymaster share the same deposit.

The value of deposit[paymaster].deposit comes from the developer pre-funding the paymaster contract via its deposit function.

js
1 function deposit() public payable { 2 entryPoint.depositTo{value: msg.value}(address(this)); 3} 4

ERC20 Paymaster

Referencing Pimlico’s ERC20PaymasterV07, the paymaster converts requiredPreFund to the corresponding ERC‑20 token amount and transfers it from the user’s wallet. This can be done either during validatePaymasterUserOp or settled afterward in postOp. If done upfront (in validation), remember to refund the difference based on actualGasCost after execution.

Therefore, when using an ERC-20 paymaster, although the USDC for gas fees still comes from the user’s wallet, deposit verification checks the paymaster’s deposit rather than the user’s wallet address. As a result, you pay exactly for what you use.

Display Gas Fee to Users

In the transaction history, I want to clearly show which transactions were sponsored by the platform and which were paid by the user. For each entry, also display the gas token used and the exact amount (e.g., "0.00012 ETH" or "0.02 USDC").

I’d like the paymaster’s postOp to emit the token address and actualTokenNeeded, similar to Circle Paymaster Events, so we can distinguish ERC‑20 gas payment from sponsorship. Since this path only involves ERC‑20, we can then compute and display gas fees unambiguously.

js
1const fetchGasFee = async (txHash) => { 2 3 const receipt = await publicClient.getTransactionReceipt({ 4 hash: txHash, 5 }); 6 7 const parsedLogs = parseEventLogs({ 8 abi: EndpointPaymasterErrorAndEventABI, 9 logs: receipt.logs, 10 }) as unknown as ChainLog[]; 11 12 const userOperationEvent = parsedLogs.find((log) => log.eventName === 'UserOperationEvent')!; 13 const userOpArgs = userOperationEvent.args as UserOperationEventArgs; 14 let gasFee = `${formatUnits(userOpArgs.actualGasCost, 18)} ETH`; 15 16 const userOperationSponsoredWithERC20 = parsedLogs.find( 17 (log) => log.eventName === 'UserOperationSponsoredWithERC20' 18 ); 19 20 if (userOperationSponsoredWithERC20) { 21 const { token, actualTokenNeeded } = userOperationSponsoredWithERC20.args as UserOperationSponsoredWithERC20Args 22 const { symbol, decimals} = getTokenByAddress(token) 23 gasFee = `${formatUnits(actualTokenNeeded, decimals)} ${symbol}`; 24} 25 const sponsoredByPaymaster = userOpArgs.paymaster === PAYMASTER_ADDRESS; 26 27 let sponsored = false; 28 29 if (sponsoredByPaymaster && !userOperationSponsoredWithERC20) { 30 sponsored = true; 31 } 32 return { 33 sponsored, 34 gasFee, 35 }; 36};

Two key points:

  1. Every transaction emits UserOperationEvent. If its paymaster field is address(0), no paymaster was used. Otherwise, it shows the paymaster contract address.
  2. A sponsored UserOperation always uses a paymaster, but a paymaster‑used UserOperation isn’t necessarily gas free. When the paymaster pays in ETH directly, it won’t emit UserOperationSponsoredWithERC20.

Wrapping up

In ERC-4337, gas cost behavior varies significantly depending on whether you use a paymaster, what kind of paymaster it is, and the token used for payment.

As developers, providing a clear UI that shows who paid the gas fee, in which token, and how much, is a great way to make users feel welcome.

© 2025 Yvaine