Web3: Using EIP-7702 To Empower Embedded EOAs
In this article, we will provide code examples (and explanations) of using EIP-7702 to give Smart Wallet capabilities to Privy’s embedded EOA.
What Are Embedded EOAs?
Web3 onboarding SDKs, like Web3Auth and Privy, often allow email, phone, and social login. When logged in, the SDK will use the login info, your DApp info, and other variables to generate a private key for the user. These accounts are called embedded EOAs and are usually only used within the context of the DApp.
Private keys are non-custodially managed by splitting up the private key into multiple parts and kept with independent parties. More here.
Why are we focused on embedded EOAs? Currently, DApps cannot prompt standard browser wallet accounts (called JSON-RPC accounts) to sign EIP-7702 “authorizations”. DApp developers can only prompt embedded EOAs (and Local Accounts, which are rarely used from a DApp perspective) to sign authorizations. So, for now, we focus on embedded EOAs.
Browser Wallets and EIP-7702
It appears JSON-RPC accounts created in browser wallets, like MetaMask, will likely only allow EOA delegation from the wallet end and not from the DApp frontend (https://support.metamask.io/configure/accounts/switch-to-or-revert-from-a-smart-account/). In fact, since MetaMask deprecated eth_sign, DApps can no longer prompt MetaMask to sign arbitrary hex strings, which is needed to create Authorizations.
Get brianonchain’s stories in your inbox
Join Medium for free to get updates from this writer.
When MetaMask releases its new Smart Wallet APIs (no Docs yet), I speculate that the flow will be like how DApps uses the injected Provider to call eth_sendTransaction on the Browser Wallet. So, for example, via the injected Provider, DApps will call a hypothetical endpoint eth_sendUnsignedUserOperation (payload=UnsignedUserOp). MetaMask will then sign UnsignedUserOp. Concurrently, MetaMask will also sign a Authorization, with the “delegator” being a whitelisted singleton Smart Account contract. MetaMask will then call a Bundler’s eth_sendUserOperation endpoint, as detailed in ERC-4337, with the userOperation’s eip7702Auth field containing the Authorization. These are my speculations so far.
Why Are Smart Wallet Capabilities Useful?
With Smart Wallet Capabilities, an EOA can pay for gas with ERC-20 tokens in the EOA. Other functionalities such as gas sponsorship, batch transactions, creating “caveats”, and enabling MPC accounts are also useful capabilities of Smart Wallets. But, in this article, we will focus on paying gas with ERC-20 tokens, as I believe this is a hugely significant functionality.
Example Code: Privy Embedded EOA Transferring USDC, Paying Gas with USDC
The code below uses a Privy embedded EOA to transfer USDC, paying gas with USDC using Pimlico’s ERC-20 Paymaster.
1. Define a few constants to enable a multichain setup (testnets included):
// @/utils/web3Constants.ts
import { base, arbitrum, optimism, polygon, sepolia, baseSepolia } from "viem/chains";
import type { Hex, Chain } from "viem";
export const chainNameToUsdcAddress: { [key: string]: Hex } = {
[base.name]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
[arbitrum.name]: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
[optimism.name]: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
[polygon.name]: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
[sepolia.name]: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
[baseSepolia.name]: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
};
export const chainNameToChain: { [key: string]: Chain } = {
[base.name]: base,
[arbitrum.name]: arbitrum,
[optimism.name]: optimism,
[polygon.name]: polygon,
[sepolia.name]: sepolia,
[baseSepolia.name]: baseSepolia,
};
export const chainNameToRpcUrl: { [key: string]: string } = {
[base.name]: process.env.NEXT_PUBLIC_ALCHEMY_BASE_URL!,
[arbitrum.name]: process.env.NEXT_PUBLIC_ALCHEMY_ARBITRUM_URL!,
[optimism.name]: process.env.NEXT_PUBLIC_ALCHEMY_OP_URL!,
[polygon.name]: process.env.NEXT_PUBLIC_ALCHEMY_POLYGON_URL!,
[sepolia.name]: process.env.NEXT_PUBLIC_ALCHEMY_SEPOLIA_URL!,
[baseSepolia.name]: process.env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_URL!,
};
export const chainNameToBundlerUrl: { [key: string]: string } = {
[base.name]: process.env.NEXT_PUBLIC_PIMLICO_BASE_URL!,
[arbitrum.name]: process.env.NEXT_PUBLIC_PIMLICO_ARBITRUM_URL!,
[optimism.name]: process.env.NEXT_PUBLIC_PIMLICO_OP_URL!,
[polygon.name]: process.env.NEXT_PUBLIC_PIMLICO_POLYGON_URL!,
[sepolia.name]: process.env.NEXT_PUBLIC_PIMLICO_SEPOLIA_URL!,
[baseSepolia.name]: process.env.NEXT_PUBLIC_PIMLICO_BASE_SEPOLIA_URL!,
};
export const chainNameToPaymasterUrl: { [key: string]: string } = {
[base.name]: process.env.NEXT_PUBLIC_PIMLICO_BASE_URL!,
[arbitrum.name]: process.env.NEXT_PUBLIC_PIMLICO_ARBITRUM_URL!,
[optimism.name]: process.env.NEXT_PUBLIC_PIMLICO_OP_URL!,
[polygon.name]: process.env.NEXT_PUBLIC_PIMLICO_POLYGON_URL!,
[sepolia.name]: process.env.NEXT_PUBLIC_PIMLICO_SEPOLIA_URL!,
[baseSepolia.name]: process.env.NEXT_PUBLIC_PIMLICO_BASE_SEPOLIA_URL!,
};2. Setup a React Context so bundlerClient can be accessible in any React component
"use client";
import { useEffect, useState, createContext, useContext } from "react";
// privy
import { usePrivy } from "@privy-io/react-auth";
// viem
import { entryPoint08Address, createBundlerClient, BundlerClient } from "viem/account-abstraction";
import { createPublicClient, http } from "viem";
// wagmi
import { useAccount } from "wagmi";
import { useWalletClient } from "wagmi";
// permissionless
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { toSimpleSmartAccount } from "permissionless/accounts";
// utils
import { chainNameToRpcUrl, chainNameToBundlerUrl, chainNameToUsdcAddress } from "@/utils/web3Constants";
// Create context & custom hook to use it
const BundlerContext = createContext<BundlerClient | undefined>(undefined);
export const useBundlerClient = () => useContext(BundlerContext);
export default function BundlerProvider({ children }: { children: React.ReactNode }) {
// hooks
const { ready: privyReady, authenticated } = usePrivy();
const { data: walletClient } = useWalletClient();
const { chain } = useAccount();
// states
const [bundlerClient, setBundlerClient] = useState<BundlerClient | undefined>(undefined);
// set smartAccountClient on mount
useEffect(() => {
(async () => {
if (privyReady && authenticated && walletClient && chain) {
console.log("setting bundlerClient...");
try {
// create public client
const publicClient = createPublicClient({ chain, transport: http(chainNameToRpcUrl[chain.name]) });
// create Pimlico client (a Viem paymasterClient)
const pimlicoClient = createPimlicoClient({ transport: http(chainNameToBundlerUrl[chain.name]) });
// create simpleSmartAccount
const simpleSmartAccount = await toSimpleSmartAccount({
client: publicClient,
owner: walletClient,
entryPoint: {
address: entryPoint08Address,
version: "0.8",
},
address: walletClient.account.address,
});
const bundlerClient = createBundlerClient({
account: simpleSmartAccount,
chain,
transport: http(chainNameToBundlerUrl[chain.name]),
paymaster: pimlicoClient,
paymasterContext: {
token: chainNameToUsdcAddress[chain.name],
},
userOperation: {
estimateFeesPerGas: async () => {
return (await pimlicoClient.getUserOperationGasPrice()).fast;
},
},
});
setBundlerClient(bundlerClient);
} catch (error) {
console.error("Error creating bundler client:", error);
setBundlerClient(undefined);
}
} else {
setBundlerClient(undefined);
}
})();
}, [privyReady, authenticated, walletClient, chain]);
return <BundlerContext.Provider value={bundlerClient}>{children}</BundlerContext.Provider>;
}- The above code is fairly straightforward except one part. Why are we creating a Smart Account (
toSimpleSmartAccount()) when we are using an EOA and that the Smart Account the EOA will set its code to is already deployed (as a singleton contract)? I think it’s because when ERC-4337 was released, Viem designedbundlerClientto take in asmartAccount.bunderClient.sendUseOperationthen usessmartAccountto fill insender,factory,factoryArgs, andsignaturefields ofUserOperation. For EIP-7702, sincefactoryandfactoryArgsare overwritten with default values, onlysenderandsignatureis required. Using awalletClient, instead of asmartAccount, to fill in these fields would suffice. But, I think Viem wanted to follow the original flow of ERC-4337, so creating this “placeholder”smartAccountis needed. - I will need to research these singleton Smart Account contracts more
3. Create Providers.tsx and wrap layout.tsx with it
// Providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PrivyProvider, PrivyClientConfig } from "@privy-io/react-auth";
import { WagmiProvider, createConfig } from "@privy-io/wagmi";
import { http } from "viem";
import { sepolia, polygon, base, baseSepolia } from "viem/chains";
// components
import BundlerProvider from "./BundlerProvider";
export const privyConfig: PrivyClientConfig = {
embeddedWallets: {
createOnLogin: "all-users",
showWalletUIs: false,
},
supportedChains: [sepolia, baseSepolia, base, polygon],
loginMethods: ["google"],
externalWallets: {
disableAllExternalWallets: true,
},
};
const queryClient = new QueryClient();
export const wagmiConfig = createConfig({
chains: [sepolia, baseSepolia, base, polygon],
transports: {
[sepolia.id]: http(process.env.NEXT_PUBLIC_ALCHEMY_SEPOLIA_URL!),
[baseSepolia.id]: http(process.env.NEXT_PUBLIC_ALCHEMY_BASE_SEPOLIA_URL!),
[base.id]: http(process.env.NEXT_PUBLIC_ALCHEMY_BASE_URL!),
[polygon.id]: http(process.env.NEXT_PUBLIC_ALCHEMY_POLYGON_URL!),
},
});
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!} clientId={process.env.NEXT_PUBLIC_PRIVY_CLIENT_ID!} config={privyConfig}>
<QueryClientProvider client={queryClient}>
<WagmiProvider config={wagmiConfig}>
<BundlerProvider>{children}</BundlerProvider>
</WagmiProvider>
</QueryClientProvider>
</PrivyProvider>
);
}// layout.tsx
import Providers from "./_components/Providers";
export default async function layout({ children }: Readonly<{ children: React.ReactNode }>) {
return <Providers>{children}</Providers>;
}4. Send Transaction
Finally, in our App, we use send() to transfer USDC from our embedded EOA any address. Gas will be paid by USDC in the embedded EOA account.
import { useBundlerClient } from "./BundlerProvider";
import { useSign7702Authorization } from "@privy-io/react-auth";
export default function App() {
const bundlerClient = useBundlerClient();
const { signAuthorization } = useSign7702Authorization();
async function send() {
// some verification here...
// usdcBalance and toAddress are input states
// sign authorization
const publicClient = createPublicClient({ chain, transport: http(chainNameToRpcUrl[chain.name]) });
const authorization = await signAuthorization({
contractAddress: "0xe6Cae83BdE06E4c305530e199D7217f42808555B", // Simple account implementation address (same for all chains)
chainId: chain.id,
nonce: await publicClient.getTransactionCount({ address: walletClient.account.address }),
});
// send transaction
const userOpHash = await bundlerClient.sendUserOperation({
calls: [
{
to: usdcAddress,
abi: parseAbi(["function approve(address,uint)"]),
functionName: "approve",
args: [paymasterAddress, parseUnits("0.2", 6)],
},
{
to: usdcAddress,
abi: parseAbi(["function transfer(address,uint)"]),
functionName: "transfer",
args: [toAddress, parseUnits(usdcAmount, 6)],
},
],
factory: "0x7702",
factoryData: "0x",
authorization,
});
// get txn hash...
}
...Let’s explain the code above:
send()shown is an abridged version, only the key parts are shown- we created a
publicClientjust to get the embedded EOA nonce insignAuthorization() - You must use Privy’s
signAuthorization, notwalletClient.signAuthorization(), to sign the authorization. It seems Privy’s Wagmi integration is not perfect, but it’s a minor issue.signAuthorizationreturns:
{
address: "0xe6Cae83BdE06E4c305530e199D7217f42808555B",
chainId: 8453,
nonce: 4,
r: "0xc65d0569fdedb92a579474710515c05d2f53158cccd1976a3babdd3b56b9604c",
s: "0x069f8fec04784ad3a3d9811747b4cd59aae81715bda21509136fa3e7c4b4e629",
v: 28n,
yParity: 1,
}- Like ERC-4337,
sendUserOperation()takes incalls(an array of transactions you want executed). However, with EIP-7702, you will have to fill in 3 additional fields:factory,factoryData, andauthorization.factoryandfactoryDatawill overridesendUserOperation()’s internal logic that callssmartAccount.isDeployedandgetFactoryArgs, which are used only for Smart Wallets, not EIP-7702 EOAs (more details ERC-4337 Deep Dive) - In the
sendUserOperation()logic,bundlerClientandpaymasterClient(pimlicoClientin this case) are first used to make a few JSON-RPC requests (eth_getUserOperationGas,pm_getPaymasterStubData,pm_getPaymasterData) to fetch data needed to build a validuserOperation(see ERC-4337 Deep Dive). In the end, a JSON-RPC request (method:eth_sendUserOperation, params:[userOperation, entryPointAddress]) will be made to the Bundler URL. An exampleuserOperationis:
// body
{
id : 46
jsonrpc: "2.0"
method: "eth_sendUserOperation"
params: [ userOperation, "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"]
}
// userOperation
{
sender: "0xb1Aa2dBb69d56a9a136571A2A1139c7de56e769a"
callData: "0x34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000888888888888ec68a58ab8094cc1ad20ba3d24020000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000a206df5844da81470c82d07ae1b797d139be58c20000000000000000000000000000000000000000000000000000000000004e2000000000000000000000000000000000000000000000000000000000"
maxFeePerGas: "0x8b35b"
maxPriorityFeePerGas: "0x3c"
nonce: "0x197d732d2dd0000000000000000"
eip7702Auth: {
address: "0xe6Cae83BdE06E4c305530e199D7217f42808555B"
chainId: "0x2105"
nonce: "0x7"
r: "0x5ecdca7f4a8e3134c46ed1fea51dd3d0bbaa73336c3c5b61f8fcf2b05110b7bc"
s: "0x0985ed78eb2ccb5363c86cc5200ac35e2f28dc3aee8a7cc413aae3f3ad87b21c"
yParity: "0x01"
}
factory: "0x7702"
factoryData: "0x"
paymaster: "0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402"
paymasterData: "0x0300000068684083000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000004a2e00000000000000000000000000000000000000000000000000000000a37e5ce000000000000000000000000000007424d8baa107006c93a030d1455a2ef43261b384f21cc487d60aea1044782b89c8d24a3a7156057a94a07e66b476e53500f7d7e26a59591324ee34bf29481c2f9d23b64c5373ea6d6b8b5f6d424b8dd1f439ddc84a061b"
verificationGasLimit: "0xe7a8"
callGasLimit: "0xa48c"
paymasterPostOpGasLimit: "0x10d7e"
paymasterVerificationGasLimit: "0x912d"
preVerificationGas: "0x19d1c"
signature: "0x8e10ae51f2c29bebbd190a81916dadca4c407194aaaf6b443835d6a55f829f24077de4211992a007141656905f70602e6c95225d7c1a450b37cdd64767fb3cab1d"
}- The Bundler takes
userOperationand transforms it to apackedUserOperation(renamed asops), as shown in this decoded input data:
0 ops.sender address
0xb1Aa2dBb69d56a9a136571A2A1139c7de56e769a
0 ops.nonce uint256
32312500909961410346947618799616
0 ops.initCode bytes
0x7702000000000000000000000000000000000000
0 ops.callData bytes
0x34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000888888888888ec68a58ab8094cc1ad20ba3d24020000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000a206df5844da81470c82d07ae1b797d139be58c20000000000000000000000000000000000000000000000000000000000004e2000000000000000000000000000000000000000000000000000000000
0 ops.accountGasLimits bytes32
0x0000000000000000000000000000e7a80000000000000000000000000000a48c
0 ops.preVerificationGas uint256
96547
0 ops.gasFees bytes32
0x0000000000000000000000000000003d00000000000000000000000000094b11
0 ops.paymasterAndData bytes
0x888888888888ec68a58ab8094cc1ad20ba3d24020000000000000000000000000000912d00000000000000000000000000010d7e0300000068684892000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000004a2e00000000000000000000000000000000000000000000000000000000a357502100000000000000000000000000007424d8baa107006c93a030d1455a2ef43261b384f21c7ea5dfd9ee47e1d78c5d15c504e04ccb8c83b7b0b938f3838752ee2d2ebb2aa53deb12468f26884f1b0dfe0bd602407ef4630aa2ae14ec1c64a576544718d10c1b
0 ops.signature bytes
0x1779b49428a76fa9f1ee4f0067a4500b046365a6a90722d243517f7b2bc0d244779a6ab379ff9f2a550550d3bf85f4d81afc47a00782c8a5adf8a91a2b208dad1c- The Bundler then makes a type 0x04 transaction to EntryPoint’s
handleOps(ops[], beneficiary)and pays for gas. Type 0x04 txns are essentially the same as type 0x02 txns, except 1) they’re prefixed with0x04and 2) they have aauthorizationList: []field. - The rest of the flow follows what happens in a ERC-4337 user operation. I have not dived deeply into EntryPoint v0.8 (which supports EIP 7702) or Pimlico’s v0.8 Paymaster. I plan to analyze differences compared v0.7 and add it in here later.
Summary
The frontend code to make an ERC-4337 Smart Account vs. a EIP-7702 EOA to send a gasless transaction (using ERC-20 to pay for gas) is very similar (7702 is built on 4337 infra). But, a few differences are:
- 7702 EOA doesn’t use an Account Factory to create Smart Accounts. Therefore, in
sendUserOperation(), you must overwritefactoryandfactoryArgswith preset default values - For 7702 EOAs, in
sendUserOperation(), you must includeauthorizations - 7702 EOA set its code to a singleton Smart Account used by everyone. A 4437 Smart Account is individualistic, with the Smart Account owner usually being the embedded EOA.
- When you create the “placeholder”
smartAccountwithtoSimpleSmartAccount(), you must manually override the address withwalletClient.account.address





