How to send a userOperation from a EOA using EIP-7702
This guide showcases a simple demo that uses ERC-4337 and EIP-7702 to send a sponsored userOperation from a EOA. We will use Safe as our smart account implementation of choice, but the same applies for any ERC-4337 compatible smart account.
For a high level overview of EIP-7702, checkout our EIP-7702 conceptual guide and for a more technical overview, please refer to the EIP-7702 proposal.
Steps
This guide is divided into two parts. The first part will walk you through how to turn your EOA into a ERC-4337 compatible smart account using EIP-7702. The second part will show you how to send a sponsored userOperation originating from your EOA.
Part 1: Sending a EIP-7702 set code transaction
Setup
This demo will be ran on Odyssey Testnet, which already implements EIP-7702.
To get started, you can bridge funds from Sepolia to Odyssey through Conduit's SuperBridge.
You can confirm the bridge transfer by checking the Odyssey blockchain explorer.
Confirming the EOA has no code
Before starting the demo, we can quickly confirm that our EOA has no code attached to it by running the following command:
cast code $YOUR_EOA_ADDRESS --rpc-url https://odyssey.ithaca.xyz
If should return back the following:
0x
Signing the Authorization Request
We first need to prepare our EOA by signing a authorization request to set the Safe Singleton contract as our designated delegator. To do this, we extend our wallet client with Viem's experimental EIP-7702 actions.
import { createWalletClient, Hex, http, zeroAddress } from "viem"
import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"
import { odysseyTestnet } from "viem/chains"
import { eip7702Actions } from "viem/experimental"
import { safeAbiImplementation } from "./safeAbi"
import { getSafeModuleSetupData } from "./setupData"
import dotenv from "dotenv"
dotenv.config()
const eoaPrivateKey = process.env.EOA_PRIVATE_KEY as Hex
if (!eoaPrivateKey) throw new Error("EOA_PRIVATE_KEY is required")
const account = privateKeyToAccount(eoaPrivateKey)
const walletClient = createWalletClient({
account,
chain: odysseyTestnet,
transport: http("https://odyssey.ithaca.xyz"),
}).extend(eip7702Actions())
const SAFE_SINGLETON_ADDRESS = "0x41675C099F32341bf84BFc5382aF534df5C7461a"
const authorization = await walletClient.signAuthorization({
contractAddress: SAFE_SINGLETON_ADDRESS,
})
Sending the Authorization Request
Before we can interact with our smart account, we also need to initialize it by populating it's storage. With Safe, this is done by calling the setup
function.
We can make a slight optimization by sending both the Authorization and setup
call in one transaction which would both set our EOA's code and setup our smart account.
const SAFE_MULTISEND_ADDRESS = "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526"
const SAFE_4337_MODULE_ADDRESS = "0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226"
const safePrivateKey = process.env.SAFE_PRIVATE_KEY as Hex | undefined
if (!safePrivateKey) throw new Error("SAFE_PRIVATE_KEY is required")
// Parameters for Safe's setup call.
const owners = [privateKeyToAddress(safePrivateKey)]
const signerThreshold = 1n
const setupAddress = SAFE_MULTISEND_ADDRESS
const setupData = getSafeModuleSetupData()
const fallbackHandler = SAFE_4337_MODULE_ADDRESS
const paymentToken = zeroAddress
const paymentValue = 0n
const paymentReceiver = zeroAddress
const txHash = await walletClient.writeContract({
address: account.address,
abi: safeAbiImplementation,
functionName: "setup",
args: [
owners,
signerThreshold,
setupAddress,
setupData,
fallbackHandler,
paymentToken,
paymentValue,
paymentReceiver,
],
authorizationList: [authorization],
})
console.log(`Submitted: https://odyssey-explorer.ithaca.xyz/tx/${txHash}`)
Confirming the EOA has code
Now that the authorization request has been sent, we can confirm that our EOA has code attached to it by running the following command:
cast code $YOUR_EOA_ADDRESS --rpc-url https://odyssey.ithaca.xyz
If should return back the following:
0xef010041675c099f32341bf84bfc5382af534df5c7461a
Here the EOA's code is in the format of (0xef0100 ++ address)
where 0xef0100
are magic bytes that indicate the EOA has a active delegation designator. The remaining bytes 0x41675c099f32341bf84bfc5382af534df5c7461a
is the Safe Singleton's address.
Part 2: Sending the UserOperation
Preparing the clients
The setup process follows the typical flow of sending a userOperation. The only difference is that when creating the Safe smart account instance, we set the sender address as our EOA's address.
import { toSafeSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { createPublicClient, Hex, http, zeroAddress } from "viem"
import { odysseyTestnet } from "viem/chains"
import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"
import dotenv from "dotenv"
import { createSmartAccountClient } from "permissionless"
dotenv.config()
const eoaPrivateKey = process.env.EOA_PRIVATE_KEY as Hex | undefined
if (!eoaPrivateKey) throw new Error("EOA_PRIVATE_KEY is required")
const safePrivateKey = process.env.SAFE_PRIVATE_KEY as Hex | undefined
if (!safePrivateKey) throw new Error("SAFE_PRIVATE_KEY is required")
const pimlicoApiKey = process.env.PIMLICO_API_KEY as Hex | undefined
if (!pimlicoApiKey) throw new Error("PIMLICO_API_KEY is required")
const pimlicoUrl = `https://api.pimlico.io/v2/${odysseyTestnet.id}/rpc?apikey=${pimlicoApiKey}`
const pimlicoClient = createPimlicoClient({
transport: http(pimlicoUrl),
})
const publicClient = createPublicClient({
chain: odysseyTestnet,
transport: http("https://odyssey.ithaca.xyz"),
})
const safeAccount = await toSafeSmartAccount({
address: privateKeyToAddress(eoaPrivateKey),
owners: [privateKeyToAccount(safePrivateKey)],
client: publicClient,
version: "1.4.1",
})
const smartAccountClient = createSmartAccountClient({
account: safeAccount,
paymaster: pimlicoClient,
bundlerTransport: http(pimlicoUrl),
userOperation: {
estimateFeesPerGas: async () => (await pimlicoClient.getUserOperationGasPrice()).fast,
},
})
Sending the UserOperation
We can now send the userOperation as usual.
const userOperationHash = await smartAccountClient.sendUserOperation({
calls: [
{
to: zeroAddress,
value: 0n,
data: "0x",
},
],
})
const { receipt } = await smartAccountClient.waitForUserOperationReceipt({
hash: userOperationHash,
})
console.log(
`UserOperation included: https://odyssey-explorer.ithaca.xyz/tx/${receipt.transactionHash}`,
)
Review
Congratulations! You have successfully sent a sponsored userOperation from your EOA, if you review the transaction on the blockchain explorer, you will see that the userOperation's sender address is equal to your EOA's address.
Combined code
If you want to see the complete code that combines all of the previous steps, we uploaded it to a separate repository. If you're looking to run it, remember to replace the API key with your own!