Skip to content
Alchemy Logo

Pay gas with any ERC-20 token

Use the Gas Manager API to let a smart account pay gas with an ERC-20 token.

Use the Gas Manager API to let a smart account pay gas with an ERC-20 token instead of the network's native token. This guide shows the low-level post-operation flow for an EntryPoint v0.7 UserOperation using Viem actions or raw JSON-RPC calls.

Wallet APIs are the recommended integration path. They greatly simplify this flow by reducing it to three API calls. Follow the Wallet APIs guide to get started.

Gas is fronted in the network's native token. Depending on the transfer mode configured in the policy, the paymaster collects the selected ERC-20 token from the smart account either before or after the operation executes. The native gas cost and admin fee are added to the policy owner's monthly invoice.

The smart account at UserOperation.sender must hold the payment token and approve the paymaster to spend it. Funding or approving only the owner EOA does not fund a separate smart account. For an EIP-7702 delegated account, the EOA and UserOperation.sender use the same address.

This guide uses post-operation mode, which collects payment after execution and lets you batch the approval with the application call. If that batch reverts, its new approval also reverts, so the paymaster cannot collect the token payment even though the policy owner still pays the native gas cost. Pre-operation mode collects payment before execution, so the paymaster must already have an allowance or compatible permit; an approval inside the operation executes too late. Compare the token gas payment modes before configuring the policy.

Before you begin:

  • Create an API key in the dashboard and enable Base Sepolia.
  • Create and activate an ERC-20 Payments policy in the Gas Manager dashboard. Enable Base Sepolia USDC and select the post-operation transfer mode.
  • Fund UserOperation.sender with Base Sepolia USDC at 0x036CbD53842c5426634e7929541eC2318f3dCF7e. For a standard smart contract account, this is the separate smart account address, not its owner EOA. For an EIP-7702 delegated account, UserOperation.sender and the EOA are the same address. You can get test USDC from the Circle faucet.
  • Install the dependencies with npm install viem @alchemy/common @alchemy/aa-infra @alchemy/smart-accounts.
  • Set ALCHEMY_API_KEY, ALCHEMY_POLICY_ID, and a test-only OWNER_PRIVATE_KEY in your environment for the Modular Account V2 example.

Use an API key from the same app as the Gas Manager policy. For other network and token combinations, check the supported chains and enable the token on the policy.

Use Viem's account abstraction actions for the shortest low-level integration. This complete example creates a Modular Account V2, gives the paymaster a reusable 5 USDC allowance when the remaining allowance is below the payment cap, and limits each estimated payment to 0.5 USDC.

The application call sends 0 ETH to the zero address. Replace it with the call your application needs, and choose allowance and cap values appropriate for your application.

pay-gas-with-usdc.ts
import { estimateFeesPerGas } from "@alchemy/aa-infra";
import { alchemyTransport } from "@alchemy/common";
import { toModularAccountV2 } from "@alchemy/smart-accounts";
import {
  createPublicClient,
  encodeFunctionData,
  erc20Abi,
  formatUnits,
  getAddress,
  http,
  parseUnits,
  type Hex,
} from "viem";
import {
  createBundlerClient,
  createPaymasterClient,
} from "viem/account-abstraction";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
 
const {
  ALCHEMY_API_KEY: apiKey,
  ALCHEMY_POLICY_ID: policyId,
  OWNER_PRIVATE_KEY: ownerPrivateKey,
} = process.env;
if (!apiKey || !policyId || !ownerPrivateKey) {
  throw new Error(
    "Set ALCHEMY_API_KEY, ALCHEMY_POLICY_ID, and OWNER_PRIVATE_KEY",
  );
}
const owner = privateKeyToAccount(ownerPrivateKey as Hex);
 
const paymentToken = getAddress(
  "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
);
// This address is specific to Base Sepolia and EntryPoint v0.7.
const paymaster = getAddress("0x2cc0c7981D846b9F2a16276556f6e8cb52BfB633");
const approvalAmount = parseUnits("5", 6);
const maxTokenAmount = parseUnits("0.5", 6);
 
const transport = alchemyTransport({ apiKey });
const rpcClient = createPublicClient({ chain: baseSepolia, transport });
const account = await toModularAccountV2({ client: rpcClient, owner });
 
const [balance, allowance] = await Promise.all([
  rpcClient.readContract({
    address: paymentToken,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [account.address],
  }),
  rpcClient.readContract({
    address: paymentToken,
    abi: erc20Abi,
    functionName: "allowance",
    args: [account.address, paymaster],
  }),
]);
 
console.log({
  smartAccount: account.address,
  usdcBalance: formatUnits(balance, 6),
  currentAllowance: formatUnits(allowance, 6),
});
if (balance < maxTokenAmount) {
  throw new Error(`Fund ${account.address} with Base Sepolia USDC and retry`);
}
 
const paymasterClient = createPaymasterClient({
  transport: http(`https://base-sepolia.g.alchemy.com/v2/${apiKey}`),
});
const bundlerClient = createBundlerClient({
  account,
  chain: baseSepolia,
  client: rpcClient,
  transport,
  paymaster: paymasterClient,
  paymasterContext: {
    policyId,
    erc20Context: {
      tokenAddress: paymentToken,
      maxTokenAmount: maxTokenAmount.toString(),
    },
  },
  userOperation: { estimateFeesPerGas },
});
 
const applicationCall = {
  to: "0x0000000000000000000000000000000000000000",
  data: "0x",
  value: 0n,
} as const;
 
const hash = await bundlerClient.sendUserOperation({
  calls: [
    ...(allowance < maxTokenAmount
      ? [
          {
            to: paymentToken,
            data: encodeFunctionData({
              abi: erc20Abi,
              functionName: "approve",
              args: [paymaster, approvalAmount],
            }),
            value: 0n,
          },
        ]
      : []),
    applicationCall,
  ],
});
 
const receipt = await bundlerClient.waitForUserOperationReceipt({ hash });
if (!receipt.success) throw new Error(`UserOperation reverted: ${hash}`);
 
console.log({
  userOperationHash: hash,
  transactionHash: receipt.receipt.transactionHash,
});

createPaymasterClient does not receive a chain, so its transport uses an explicit Base Sepolia RPC URL. The bundler client uses paymasterContext for the policy, payment token, and raw-unit payment cap, then handles estimation, signing, submission, and receipt polling.

Some tokens do not let you change an allowance directly from one non-zero amount to another. For those tokens, include approve(0) before the new approval. The Base Sepolia USDC used here does not require this extra call.

Use the raw flow when you need to request an exact token quote before choosing the approval amount, or when you are integrating an existing smart account implementation. The example deliberately leaves account creation, batch encoding, and signing behind three hooks so you can keep your account's nonce, factory, encoding, and signature logic.

Set ALCHEMY_API_KEY and ALCHEMY_POLICY_ID in your environment, then add the following setup to your integration:

pay-gas-with-usdc.ts
import {
  createPublicClient,
  getAddress,
  http,
  maxUint256,
  rpcSchema,
  toHex,
  type Hex,
} from "viem";
import { entryPoint07Address } from "viem/account-abstraction";
import { baseSepolia } from "viem/chains";
import { approvalCalls, decimalTokenAmountToRawUnitsCeil } from "./helpers";
import type {
  Alchemy4337RpcSchema,
  Call,
  PartialUserOperationV07,
  UnsignedUserOperationV07,
} from "./types";
 
const apiKey = process.env.ALCHEMY_API_KEY!;
const policyId = process.env.ALCHEMY_POLICY_ID!;
const rpcUrl = `https://base-sepolia.g.alchemy.com/v2/${apiKey}`;
const paymentToken = getAddress(
  "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
);
// Set this to true for tokens that require approve(0) before changing a
// non-zero allowance. Base Sepolia USDC does not require it.
const requiresAllowanceReset = false;
 
const client = createPublicClient({
  chain: baseSepolia,
  transport: http(rpcUrl),
  rpcSchema: rpcSchema<Alchemy4337RpcSchema>(),
});
 
// Implement these hooks with your smart account integration. Calls to
// buildPartialUserOperation must reuse the same sender and nonce.
declare function buildPartialUserOperation(
  calls: Call[],
): Promise<PartialUserOperationV07>;
declare function getDummySignature(): Promise<Hex>;
declare function signUserOperation(
  userOperation: UnsignedUserOperationV07,
): Promise<Hex>;

An ERC-20 approval names a spender, so you need the paymaster address before encoding the approval. The address differs by chain and EntryPoint version. This example discovers it from pm_getPaymasterStubData; you can instead configure the matching deployment address directly.

pay-gas-with-usdc.ts
// Replace this with the action the smart account should perform.
const applicationCall: Call = {
  to: "0xYOUR_TARGET_CONTRACT",
  data: "0xYOUR_ENCODED_CALLDATA",
  value: 0n,
};
 
const dummySignature = await getDummySignature();
const erc20Context = { tokenAddress: paymentToken } as const;
const discoveryOperation = await buildPartialUserOperation([
  applicationCall,
]);
 
const stub = await client.request({
  method: "pm_getPaymasterStubData",
  params: [
    discoveryOperation,
    entryPoint07Address,
    toHex(baseSepolia.id),
    { policyId, erc20Context },
  ],
});
const paymaster = getAddress(stub.paymaster);

pm_getPaymasterStubData returns estimation data, not final sponsorship. Never sign or submit the discovery operation.

Raw Gas Manager calls do not add an ERC-20 approval. Quote a batch that contains a temporary approval so the estimate includes its gas cost. The temporary maxUint256 value exists only in the unsigned quote operation.

pay-gas-with-usdc.ts
const currentAllowance = await client.readContract({
  address: paymentToken,
  abi: erc20Abi,
  functionName: "allowance",
  args: [discoveryOperation.sender, paymaster],
});
const tokenDecimals = await client.readContract({
  address: paymentToken,
  abi: erc20Abi,
  functionName: "decimals",
});
 
const quoteOperation = await buildPartialUserOperation([
  ...approvalCalls({
    token: paymentToken,
    spender: paymaster,
    currentAllowance,
    amount: maxUint256,
    resetFirst: requiresAllowanceReset,
  }),
  applicationCall,
]);
const quote = await client.request({
  method: "alchemy_requestPaymasterTokenQuote",
  params: [
    {
      policyId,
      entryPoint: entryPoint07Address,
      dummySignature,
      userOperation: quoteOperation,
      erc20Context,
    },
  ],
});
 
// Convert the human-readable quote to raw units without rounding down.
const approvalAmount = decimalTokenAmountToRawUnitsCeil(
  quote.estimateTokenAmount,
  tokenDecimals,
);

estimateTokenAmount is a human-readable decimal value. ERC-20 approve expects raw base units, so use the token's onchain decimals() value and round up.

Keep a sufficient existing allowance. Otherwise, replace the temporary approval with an approval for the quote-derived amount and rebuild callData.

pay-gas-with-usdc.ts
const finalCalls: Call[] = [
  ...approvalCalls({
    token: paymentToken,
    spender: paymaster,
    currentAllowance,
    amount: approvalAmount,
    resetFirst: requiresAllowanceReset,
  }),
  applicationCall,
];
const partialUserOperation = await buildPartialUserOperation(finalCalls);
 
const sponsorship = await client.request({
  method: "alchemy_requestGasAndPaymasterAndData",
  params: [
    {
      policyId,
      entryPoint: entryPoint07Address,
      dummySignature,
      userOperation: partialUserOperation,
      erc20Context,
    },
  ],
});
 
if (sponsorship.paymaster.toLowerCase() !== paymaster.toLowerCase()) {
  throw new Error("The paymaster address changed while preparing the operation");
}

For tokens that require an allowance to be set to zero before changing from one non-zero amount to another, approvalCalls includes approve(0) in both the temporary quote batch and final batch.

If the gas estimate or exchange rate changes enough that the exact approval no longer covers the final estimate, restart from the quote step and rebuild the operation.

Merge the returned gas and paymaster fields into the operation before signing. Any later change to callData, gas fields, or paymaster fields invalidates the signature and can invalidate the sponsorship.

pay-gas-with-usdc.ts
const unsignedSponsoredOperation: UnsignedUserOperationV07 = {
  ...partialUserOperation,
  ...sponsorship,
};
 
const signature = await signUserOperation(unsignedSponsoredOperation);
const userOperationHash = await client.request({
  method: "eth_sendUserOperation",
  params: [
    { ...unsignedSponsoredOperation, signature },
    entryPoint07Address,
  ],
});
 
console.log({
  userOperationHash,
  maximumTokenPayment: quote.estimateTokenAmount,
});

A successful response returns the UserOperation hash. Use eth_getUserOperationReceipt to wait for its receipt.

An operation can acquire its payment token before post-operation collection. For example, a swap can send USDC to UserOperation.sender in the same batch.

If the sender does not hold enough of the token before estimation, set skipBalanceCheck: true in erc20Context for pm_getPaymasterStubData, alchemy_requestPaymasterTokenQuote, and alchemy_requestGasAndPaymasterAndData:

const erc20Context = {
  tokenAddress: paymentToken,
  skipBalanceCheck: true,
} as const;

This setting skips only the estimation-time balance check. It does not bypass policy rules, simulation, or onchain collection. Order the batch so it approves the paymaster and acquires enough tokens for UserOperation.sender before post-operation collection.

Set erc20Context.maxTokenAmount to cap the estimated payment. Low-level Gas Manager methods expect a raw token amount as a decimal integer string, not a hex quantity:

const erc20Context = {
  tokenAddress: paymentToken,
  maxTokenAmount: "10000", // 0.01 USDC in raw six-decimal units
} as const;

Allow explicit tolerance for gas and exchange-rate movement between the preliminary quote and final sponsorship request. If the refreshed estimate exceeds the cap, request a new quote and ask for approval again instead of silently increasing the cap.

Was this page helpful?