Sending User Operations
This walks you through the complete process of preparing and sending UserOperations to the EntryPoint contract. You’ll learn how to create, sign, and execute them, as well as handle their results.
UserOperations are a powerful abstraction layer that enable more sophisticated transaction capabilities compared to traditional Ethereum transactions. To get started, you’ll need to an account, which you can get by deploying a factory for your implementation.
Consider funding your account with native currency (i.e. ETH) if you’re not using a paymaster |
Bundle a UserOperation
To bundle a UserOperation
, you’ll need to prepare the operation with all required fields and then package it into a format that can be sent to the EntryPoint contract. This process involves creating a properly structured operation with fields like sender address, nonce, gas limits, and calldata. The operation must be signed before it can be executed, and you may optionally include paymaster data for transaction sponsorship.
Preparing a UserOp
A UserOperation is a struct that contains all the necessary information for the EntryPoint to execute your transaction. You’ll need the sender
, nonce
, accountGasLimits
and callData
fields to construct a PackedUserOperation
that can be signed later (to populate the signature
field).
Specify paymasterAndData with the address of a paymaster contract concatenated to data that will be passed to the paymaster’s validatePaymasterUserOp function to support sponsorship as part of your user operation.
|
Here’s how to prepare one using viem:
import { getContract, createWalletClient, http, Hex } from 'viem';
const walletClient = createWalletClient({
account, // See Viem's `privateKeyToAccount`
chain, // import { ... } from 'viem/chains';
transport: http(),
})
const entrypoint = getContract({
abi: [/* ENTRYPOINT ABI */],
address: '0x<ENTRYPOINT_ADDRESS>',
client: walletClient,
});
const userOp = {
sender: '0x<YOUR_ACCOUNT_ADDRESS>',
nonce: await entrypoint.read.getNonce([sender, 0n]),
initCode: "0x" as Hex,
callData: '0x<CALLDATA_TO_EXECUTE_IN_THE_ACCOUNT>',
accountGasLimits: encodePacked(
["uint128", "uint128"],
[
100_000n, // verificationGasLimit
300_000n, // callGasLimit
]
),
preVerificationGas: 50_000n,
gasFees: encodePacked(
["uint128", "uint128"],
[
0n, // maxPriorityFeePerGas
0n, // maxFeePerGas
]
),
paymasterAndData: "0x" as Hex,
signature: "0x" as Hex,
};
In case your account hasn’t been deployed yet, make sure to provide the initCode
field as abi.encodePacked(factory, factoryData)
to deploy the account within the same UserOp:
const deployed = await publicClient.getCode({ address: predictedAddress });
if (!deployed) {
userOp.initCode = encodePacked(
["address", "bytes"],
[
'0x<ACCOUNT_FACTORY_ADDRESS>',
encodeFunctionData({
abi: [/* ACCOUNT ABI */],
functionName: "<FUNCTION NAME>",
args: [...],
}),
]
);
}
Estimating gas
To calculate gas parameters of a UserOperation
, developers should carefully consider the following fields:
-
verificationGasLimit
: This covers the gas costs for signature verification, paymaster validation (if used), and account validation logic. While a typical value is around 100,000 gas units, this can vary significantly based on the complexity of your signature validation scheme in both the account and paymaster contracts. -
callGasLimit
: This parameter accounts for the actual execution of your account’s logic. It’s recommended to useeth_estimateGas
for each subcall and add additional buffer for computational overhead. -
preVerificationGas
: This compensates for the EntryPoint’s execution overhead. While 50,000 gas is a reasonable starting point, you may need to increase this value based on your UserOperation’s size and specific bundler requirements.
The maxFeePerGas and maxPriorityFeePerGas values are typically provided by your bundler service, either through their SDK or a custom RPC method.
|
A penalty of 10% (UNUSED_GAS_PENALTY_PERCENT ) is applied on the amounts of callGasLimit and paymasterPostOpGasLimit gas that remains unused if the amount of remaining unused gas is greater than or equal to 40,000 (PENALTY_GAS_THRESHOLD ).
|
Signing the UserOp
To sign a UserOperation, you’ll need to first calculate its hash using the EntryPoint’s getUserOpHash
function, then sign this hash using your account’s signature scheme, and finally encode the resulting signature in the format that your account contract expects for verification.
const userOpHash = await entrypoint.read.getUserOpHash([userOp]);
userOp.signature = await eoa.sign({ hash: userOpHash });
The previous example assumes that the account is owned by a single ECDSA signer without any particular signature format. |
Sending the UserOp
Finally, to send the user operation you can call handleOps
on the Entrypoint contract and set yourself as the beneficiary
.
// Send the UserOperation
const userOpReceipt = await walletClient
.writeContract({
abi: [/* ENTRYPOINT ABI */],
address: '0x<ENTRYPOINT_ADDRESS>',
functionName: "handleOps",
args: [[userOp], eoa.address],
})
.then((txHash) =>
publicClient.waitForTransactionReceipt({
hash: txHash,
})
);
// Print receipt
console.log(userOpReceipt);
Since you’re bundling your user operations yourself, you can safely specify preVerificationGas and maxFeePerGas in 0.
|
Using a Bundler
For better reliability, consider using a bundler service. Bundlers provide several key benefits: they automatically handle gas estimation, manage transaction ordering, support bundling multiple operations together, and generally offer higher transaction success rates compared to self-bundling.