How to let users swap on Base without ETH
This guide shows you how to let users swap tokens on Base without holding ETH, by sending the token approval and the swap as one ERC-4337 user operation with gas paid in USDC through Pimlico's ERC-20 Paymaster, or sponsored by your app. The user signs once, and the approval and the swap either both execute or both revert. Get the swap itself from the DEX aggregator or router you already use; Pimlico doesn't quote or route swaps.
Installation
npm install permissionless viemSteps
Create the clients
If your users have an EOA with funds in it, use to7702SimpleSmartAccount (EntryPoint 0.8), as this example does, so the account keeps the user's EOA address. If they have ERC-4337 smart accounts, create them with toSafeSmartAccount or toSimpleSmartAccount instead, and skip the authorization in the send step. On chains other than Base, check EIP-7702 and EntryPoint 0.8 support on the supported chains page.
import { createSmartAccountClient } from "permissionless"
import { to7702SimpleSmartAccount } from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico"
import { createPublicClient, type Hex, http } from "viem"
import { entryPoint08Address } from "viem/account-abstraction"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" // USDC on Base
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: entryPoint08Address,
version: "0.8",
},
})
const eoa = privateKeyToAccount(process.env.EOA_PRIVATE_KEY as Hex)
const account = await to7702SimpleSmartAccount({
client: publicClient,
owner: eoa,
})
const smartAccountClient = createSmartAccountClient({
account,
chain: base,
bundlerTransport: http(pimlicoUrl),
paymaster: pimlicoClient,
userOperation: {
estimateFeesPerGas: async () => {
return (await pimlicoClient.getUserOperationGasPrice()).fast
},
prepareUserOperation: prepareUserOperationForErc20Paymaster(pimlicoClient),
},
})Get a quote from your aggregator
Most aggregator APIs return a target contract, calldata, a native value, and the address that needs an allowance for the token being sold. Pass the account address as the taker so the quote is built for it. Map your aggregator's response onto a small type like this one:
import type { Address } from "viem"
// The fields most swap aggregator APIs return for an "exact input" quote.
// Map your aggregator's response onto this shape.
type SwapQuote = {
to: Address // router contract to call
data: Hex // swap calldata
value: bigint // native value to send, 0n when selling an ERC-20
allowanceTarget: Address // contract that must be approved to pull sellToken
sellAmount: bigint
}
declare function getSwapQuote(params: {
chainId: number
sellToken: Address
buyToken: Address
sellAmount: bigint
taker: Address // the smart account / 7702 EOA address
slippageBps: number
}): Promise<SwapQuote>Build the approve + swap calls
Approve exactly the sell amount rather than an unlimited allowance, so the router can't pull more than this swap needs.
import { encodeFunctionData, erc20Abi, parseUnits } from "viem"
const WETH = "0x4200000000000000000000000000000000000006" // WETH on Base
async function buildSwapCalls(
sellAmount: bigint,
): Promise<{ to: Address; value: bigint; data: Hex }[]> {
const quote = await getSwapQuote({
chainId: base.id,
sellToken: USDC,
buyToken: WETH,
sellAmount,
taker: account.address,
slippageBps: 50,
})
return [
{
to: USDC,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [quote.allowanceTarget, quote.sellAmount],
}),
},
{
to: quote.to,
value: quote.value,
data: quote.data,
},
]
}WETH here is Base's canonical WETH at 0x4200000000000000000000000000000000000006. Use whatever buy token the user selected.
Show the network fee and check the balance
Prepare the user operation once to estimate the fee in USDC with estimateErc20PaymasterCost. The estimate is the maximum the paymaster can charge; the actual charge is based on the gas used and is usually lower.
import { formatUnits } from "viem"
const requestedSell = parseUnits("100", 6) // user wants to sell 100 USDC
const usdcBalance = await publicClient.readContract({
address: USDC,
abi: erc20Abi,
functionName: "balanceOf",
args: [account.address],
})
const draft = await smartAccountClient.prepareUserOperation({
calls: await buildSwapCalls(requestedSell),
paymasterContext: { token: USDC },
})
const { costInToken } = await pimlicoClient.estimateErc20PaymasterCost({
userOperation: draft,
token: USDC,
})
// The fee is collected in USDC after the swap executes, so the balance must cover both
if (requestedSell + costInToken > usdcBalance) {
throw new Error(
`Not enough USDC: max sellable is ${formatUnits(usdcBalance - costInToken, 6)} USDC`,
)
}
const sellAmount = requestedSell
console.log(`Network fee (max): ${formatUnits(costInToken, 6)} USDC`)If you offer a "sell max" button, cap the sell amount at the balance minus this fee. The ERC-20 Paymaster collects its fee after the calls execute, so a quote for the full balance fails simulation: nothing is left to pay the fee. If the allowance is too low, the user operation also approves the paymaster's maximum fee before the swap, in the same batch.
Send the user operation
Attach a signed EIP-7702 authorization to the first user operation only. Once the EOA is delegated, account.isDeployed() returns true and you can omit it.
Fetch a fresh quote right before sending, since aggregator quotes expire quickly.
const SIMPLE_7702_IMPLEMENTATION = "0xe6Cae83BdE06E4c305530e199D7217f42808555B"
// The EOA only has code once its EIP-7702 delegation has landed
const isDelegated = await account.isDeployed()
// Fetch a fresh quote right before sending: quotes expire quickly
const userOpHash = await smartAccountClient.sendUserOperation({
calls: await buildSwapCalls(sellAmount),
paymasterContext: { token: USDC },
authorization: isDelegated
? undefined
: await eoa.signAuthorization({
address: SIMPLE_7702_IMPLEMENTATION,
chainId: base.id,
nonce: await publicClient.getTransactionCount({ address: eoa.address }),
}),
})
const receipt = await smartAccountClient.waitForUserOperationReceipt({ hash: userOpHash })
if (!receipt.success) {
throw new Error(`Swap reverted: ${receipt.reason ?? "unknown reason"}`)
}
console.log(`Swap included: https://basescan.org/tx/${receipt.receipt.transactionHash}`)Check receipt.success. If the price moves past the slippage limit encoded in the quote, the router call reverts and so does the whole batch, approvals included.
Sponsor the swap instead
To make swaps free for users, leave the token out of paymasterContext. The verifying paymaster then sponsors the swap from your Pimlico balance, and no paymaster approval is added.
// No token in paymasterContext: the verifying paymaster sponsors it
const sponsoredHash = await smartAccountClient.sendUserOperation({
calls: await buildSwapCalls(requestedSell),
})To cap spend per user or sponsor only calls to your router, pass a sponsorshipPolicyId (sponsorship policies).
Notes
- Slippage: set it in your aggregator's quote. The minimum output amount is encoded in the router calldata, and batching doesn't change it.
- Other chains or EntryPoints: check the token is listed in supported tokens before you pass it as
paymasterContext.token. - Paying gas in another token: if the user is selling a different supported token, pass that token in
paymasterContext.tokeninstead. The same balance rule applies. - External wallets such as browser extensions usually won't let your app sign an EIP-7702 authorization for them. If the wallet supports EIP-5792
wallet_sendCallswith a paymaster service, you can send the same approve + swap calls through it. See sponsoring transactions for external wallets. - API keys: don't ship your Pimlico API key in a client app; proxy requests through your backend (guide).