Skip to content
LogoLogo

How to batch an approve and a swap into one transaction

This guide shows you how to send a token approval and a swap as one atomic transaction, so the user signs once and the approve and the swap either both execute or both revert. It applies when your users have an ERC-4337 smart account, or an existing EOA that you upgrade with EIP-7702, and works with any DEX router or aggregator. If the user connects their own wallet such as a browser extension, see Send through the user's wallet with EIP-5792 instead.

Prerequisites

  • A Pimlico API key. Create one.
  • A DEX router or aggregator API that returns swap calldata for a given sell amount and taker address.

Steps

Create the smart account client

Create a public client, a Pimlico client and a smart account client. To sponsor the user's gas from your Pimlico balance, pass the Pimlico client as paymaster. If the account holds ETH and pays its own gas, remove paymaster. This example uses a Simple account on Base; any supported smart account and chain works the same way.

import { createSmartAccountClient } from "permissionless"
import { toSimpleSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { createPublicClient, type Hex, http } from "viem"
import { entryPoint07Address } from "viem/account-abstraction"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
 
const pimlicoUrl = `https://api.pimlico.io/v2/${base.id}/rpc?apikey=YOUR_PIMLICO_API_KEY`
 
const publicClient = createPublicClient({
	chain: base,
	transport: http("https://mainnet.base.org"),
})
 
const pimlicoClient = createPimlicoClient({
	chain: base,
	transport: http(pimlicoUrl),
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
})
 
// The user's signer, for example an embedded wallet or passkey signer
const owner = privateKeyToAccount(process.env.PRIVATE_KEY as Hex)
 
const account = await toSimpleSmartAccount({
	client: publicClient,
	owner,
	entryPoint: {
		address: entryPoint07Address,
		version: "0.7",
	},
})
 
const smartAccountClient = createSmartAccountClient({
	account,
	chain: base,
	bundlerTransport: http(pimlicoUrl),
	// Remove `paymaster` if the account pays its own gas in ETH
	paymaster: pimlicoClient,
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient.getUserOperationGasPrice()).fast
		},
	},
})

Get the swap calldata

Request an exact-input quote from your router or aggregator, with the smart account address as the taker. Set the slippage limit in the quote: the minimum output is encoded in the router calldata, and batching doesn't change it.

import type { Address } from "viem"
 
// The fields most swap routers and aggregator APIs return for an exact-input quote.
// Map your provider's response onto this shape.
type SwapQuote = {
	to: Address // router contract to call
	data: Hex // swap calldata, with the minimum output (slippage limit) encoded in it
	value: bigint // native value to send, 0n when selling an ERC-20
	allowanceTarget: Address // contract that pulls the sell token
	sellAmount: bigint
}
 
declare function getSwapQuote(params: {
	chainId: number
	sellToken: Address
	buyToken: Address
	sellAmount: bigint
	taker: Address // the address that executes the swap: the smart account
	slippageBps: number
}): Promise<SwapQuote>
 
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" // USDC on Base
const WETH = "0x4200000000000000000000000000000000000006" // WETH on Base
 
const quote = await getSwapQuote({
	chainId: base.id,
	sellToken: USDC,
	buyToken: WETH,
	sellAmount: 100_000_000n, // 100 USDC
	taker: account.address,
	slippageBps: 50,
})

Build the approve and swap calls

Put the approval first and the swap second. Approve exactly the sell amount for the address that pulls the token (the router, or the allowance target your aggregator returns), not an unlimited allowance.

import { erc20Abi } from "viem"
 
const calls = [
	{
		to: USDC,
		abi: erc20Abi,
		functionName: "approve",
		args: [quote.allowanceTarget, quote.sellAmount],
	},
	{
		to: quote.to,
		value: quote.value,
		data: quote.data,
	},
] as const

Send both calls as one user operation

Pass both calls in calls. The smart account executes them in order inside one user operation, and one onchain transaction includes it.

const userOpHash = await smartAccountClient.sendUserOperation({ calls })
 
const receipt = await smartAccountClient.waitForUserOperationReceipt({
	hash: userOpHash,
})
 
if (!receipt.success) {
	throw new Error(`Swap reverted: ${receipt.reason ?? "unknown reason"}`)
}
 
console.log(`Approve + swap included: https://basescan.org/tx/${receipt.receipt.transactionHash}`)

Check receipt.success. If the price moves past the slippage limit, the swap reverts and so does the approval. To get a transaction hash in viem's sendTransaction style instead, call smartAccountClient.sendTransaction({ calls }); it waits for inclusion but doesn't throw if the batch reverts, so check the receipt yourself.

Send from an existing EOA with EIP-7702

To batch for users who already have an EOA with funds in it, keep their address by delegating the EOA to a smart account implementation with EIP-7702. Use to7702SimpleSmartAccount with EntryPoint 0.8, request the quote with the EOA address as the taker, and attach a signed authorization to the first user operation on each chain. The bundler submits the authorization in the same transaction as the batch, so the user needs no ETH for the upgrade either.

import { to7702SimpleSmartAccount } from "permissionless/accounts"
import { entryPoint08Address } from "viem/account-abstraction"
 
// EIP-7702 accounts use EntryPoint 0.8
const pimlicoClient08 = createPimlicoClient({
	chain: base,
	transport: http(pimlicoUrl),
	entryPoint: {
		address: entryPoint08Address,
		version: "0.8",
	},
})
 
const eoa = privateKeyToAccount(process.env.EOA_PRIVATE_KEY as Hex)
 
// The account address is the EOA's own address
const eoaAccount = await to7702SimpleSmartAccount({
	client: publicClient,
	owner: eoa,
})
 
const eoaAccountClient = createSmartAccountClient({
	account: eoaAccount,
	chain: base,
	bundlerTransport: http(pimlicoUrl),
	paymaster: pimlicoClient08,
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient08.getUserOperationGasPrice()).fast
		},
	},
})
 
// Implementation that to7702SimpleSmartAccount expects the EOA to delegate to
const SIMPLE_7702_IMPLEMENTATION = "0xe6Cae83BdE06E4c305530e199D7217f42808555B"
 
// Fetch the quote with `taker: eoa.address`, then build `calls` exactly as above
const eoaUserOpHash = await eoaAccountClient.sendUserOperation({
	calls,
	// Only needed until the delegation is set on this chain
	authorization: (await eoaAccount.isDeployed())
		? undefined
		: await eoa.signAuthorization({
				address: SIMPLE_7702_IMPLEMENTATION,
				chainId: base.id,
				nonce: await publicClient.getTransactionCount({ address: eoa.address }),
			}),
})

Check the chain supports EIP-7702 and EntryPoint 0.8 on the supported chains page first.

Let the user pay gas in the token they sell

If you don't want to sponsor the swap, have the user pay gas in USDC through the ERC-20 Paymaster: add prepareUserOperationForErc20Paymaster and pass the token in paymasterContext. The paymaster's approval is added to the same batch when needed.

import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico"
 
const erc20AccountClient = createSmartAccountClient({
	account,
	chain: base,
	bundlerTransport: http(pimlicoUrl),
	paymaster: pimlicoClient,
	userOperation: {
		estimateFeesPerGas: async () => {
			return (await pimlicoClient.getUserOperationGasPrice()).fast
		},
		// Adds the paymaster's USDC approval to the batch when needed
		prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
	},
})
 
const erc20UserOpHash = await erc20AccountClient.sendUserOperation({
	calls,
	paymasterContext: { token: USDC },
})

The fee is collected in the token after the swap runs, so leave room for it in the sell amount. To deduct it from the amount the user enters, follow How to pay gas from the token being swapped.

Send through the user's wallet with EIP-5792

If the user connects an external wallet, your app can't sign an EIP-7702 authorization for it. Request the quote with the wallet's address as the taker, then send the same calls with wallet_sendCalls (EIP-5792) through viem's sendCalls. Set forceAtomic: true so the wallet rejects the batch instead of splitting it into two transactions, and pass a Pimlico paymaster URL only if the wallet reports paymasterService support for the chain.

import { createWalletClient, custom, type EIP1193Provider } from "viem"
 
declare const provider: EIP1193Provider // the user's injected or WalletConnect provider
 
const walletClient = createWalletClient({
	chain: base,
	transport: custom(provider),
})
 
const [address] = await walletClient.requestAddresses()
 
const { id } = await walletClient.sendCalls({
	account: address,
	calls,
	// Fail instead of falling back to separate transactions
	forceAtomic: true,
	// Only if the wallet reports `paymasterService` support for this chain
	capabilities: {
		paymasterService: { url: pimlicoUrl },
	},
})
 
const { status, receipts } = await walletClient.waitForCallsStatus({ id })

To check those capabilities first, see sponsoring transactions for external wallets.

Notes

  • If your users stay on plain EOAs and you only want to avoid a separate approval per swap, use Uniswap's Permit2 instead: it replaces later approvals with signatures, but still needs one approval transaction per token and ETH for gas.
  • If you ship this in a client app, proxy Pimlico requests through your backend so your API key isn't exposed (guide).
  • To cap sponsored spend per user or sponsor only calls to your router, pass a sponsorshipPolicyId (sponsorship policies).