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:
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.
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.
Field | Type | Description |
---|---|---|
callGasLimit | uint256 | The amount of gas to allocate the main execution call |
verificationGasLimit | uint256 | The amount of gas to allocate for the verification step |
preVerificationGas | uint256 | Extra gas to pay the bundler |
paymasterVerificationGasLimit | uint256 | The amount of gas to allocate for the paymaster validation code (only if paymaster exists) |
paymasterPostOpGasLimit | uint256 | The 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:
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.
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
.
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.
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:
pm_getPaymasterStubData
is mainly used to estimate the gas required during the paymaster’s execution.pm_getPaymasterData
is used to obtain the signed paymasterData foreth_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, thepaymasterData
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:
- Call
pm_getPaymasterStubData
to obtain the potential gas costs for the paymaster’s execution,paymasterVerificationGasLimit
andpaymasterPostOpGasLimit
. - Fill these into the UserOperation(under construction), then request
eth_estimateUserOperationGas
to get the EntryPoint execution gas values:callGasLimit
,preVerificationGas
, andverificationGasLimit
. - Call
smartWalletClient.userOperation.estimateFeesPerGas()
to get maxFeePerGas. - 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.
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.
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:
- Pass a value indicating sponsorship should be used when the sponsor quota is available.
- Pay gas fee with ETH from the wallet, with no paymaster involved, especially when the sponsor quota is exhausted.
- 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.
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.
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:
-
Manually get gas limit for paymaster
Call prepareUserOperation without "paymaster" and "signature".
By manually calling
getStubSignature
andgetPaymasterStubData
, you can getpaymasterVerificationGasLimit
andpaymasterPostOpGasLimit
, then inject those into the request before callingestimateUserOperationGas
. -
eth_estimateUserOperationGas
won’t returnpaymasterPostOpGasLimit
The
paymasterPostOpGasLimit
is not returned byeth_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
frompaymasterArgs
inprepareUserOperation
implementation. -
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.
I observed several clear patterns.
-
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.
-
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.
-
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.
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.


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.
Pay Gas Fee With Paymaster
paymaster sponsorship
EntryPoint calls validatePaymasterPrepayment
to check that deposit[paymaster].deposit > requiredPreFund
.
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.
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.
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:
- Every transaction emits UserOperationEvent. If its paymaster field is
address(0)
, no paymaster was used. Otherwise, it shows the paymaster contract address. - 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.