BchainPay logoBchainPay
EngineeringStablecoinsEIP-3009EthereumGasless

Gasless USDC checkout with EIP-3009 transferWithAuthorization

How EIP-3009 lets shoppers pay USDC without holding ETH: signature flow, relayer design, replay protection and the gotchas we hit shipping it.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Gasless USDC checkout with EIP-3009 transferWithAuthorization

The single most painful moment in any crypto checkout is the one where a shopper has $50 of USDC in their wallet, clicks Pay, and watches their wallet say "insufficient ETH for gas". They came to spend a stablecoin. They have the stablecoin. They cannot spend the stablecoin. We've watched this kill conversion in funnels we've instrumented — somewhere between 18% and 31% of would-be USDC payers abandon at exactly this step.

EIP-3009 fixes it. Properly. Not with a meta-transaction relayer bolted onto a generic transfer, not with Permit + an extra approve hop, but with a single signed message that the merchant (or its relayer) submits on the user's behalf. The user pays in USDC and only USDC. They never need ETH.

This post is how we shipped EIP-3009 checkout at BchainPay, the attack surface we had to close, and the few specific footguns that will absolutely bite you if you implement it from the spec alone.

What EIP-3009 actually is#

EIP-3009 is an extension to ERC-20 that adds two functions to the token contract:

function transferWithAuthorization(
  address from, address to, uint256 value,
  uint256 validAfter, uint256 validBefore, bytes32 nonce,
  uint8 v, bytes32 r, bytes32 s
) external;
 
function receiveWithAuthorization(
  address from, address to, uint256 value,
  uint256 validAfter, uint256 validBefore, bytes32 nonce,
  uint8 v, bytes32 r, bytes32 s
) external;

The user signs an EIP-712 typed message off-chain. Anyone with that signature can submit the transfer on-chain and pay the gas. The token contract verifies the signature against from, checks the time window and the per-user nonce, then moves the funds.

USDC implements EIP-3009 on every chain Circle deploys to — Ethereum mainnet, Arbitrum, Base, Polygon PoS, Optimism, Avalanche C-Chain. USDT does not (it ships its own permit-style scheme on some chains and nothing on others). PYUSD does. So when we say "gasless checkout" in 2026, in practice we mean "gasless USDC checkout" plus a fallback for everything else.

The two important differences from EIP-2612 (permit) are:

  1. Permit only grants an allowance. You still need a second transaction to actually move the tokens. EIP-3009 moves the tokens in one shot.
  2. EIP-3009 uses a random bytes32 nonce, not a sequential one. That single design choice unlocks parallel checkouts from the same wallet without nonce contention. It also changes how you protect against replay (more on that below).

The end-to-end flow#

Here is the flow we use for a one-off payment intent. It assumes the merchant already has a payment_intent created against the BchainPay API and has shown the shopper an amount and a recipient address.

shopper wallet ─► sign EIP-712 (transferWithAuthorization)
       │
       ▼
shopper browser ─POST /v1/payment_intents/:id/authorize
       │       { v, r, s, nonce, validAfter, validBefore }
       ▼
BchainPay API   ─► validate signature client-side, queue
       │
       ▼
relayer worker  ─► eth_sendRawTransaction(transferWithAuthorization)
       │
       ▼
USDC contract   ─► Transfer(from → merchantSweep, value)
       │
       ▼
indexer         ─► payment_intent.succeeded webhook

Three things to notice:

  • The user signs once. No approve round-trip, no permit pre-step. One MetaMask popup.
  • The signature lives off-chain until the relayer submits it. That's important for replay defense — see the next section.
  • The relayer is just a hot wallet with ETH. It isn't custodial; it never touches USDC. Its only job is to pay gas in exchange for a small fee that we deduct from the payment.

Building the typed-data payload#

Get this wrong and signatures verify locally but fail on-chain. The domain separator must exactly match what the deployed USDC contract expects, including the name, version, chainId and verifying Contract.

lib/eip3009.ts
import { TypedDataDomain, TypedDataField } from 'ethers';
 
export function buildAuthorization({
  chainId,
  usdc,            // verifyingContract address
  from, to, value,
  validAfter, validBefore,
  nonce,           // 32-byte random hex string
}: AuthArgs) {
  const domain: TypedDataDomain = {
    name: 'USD Coin',          // exactly, not 'USDC'
    version: '2',              // '2' on mainnet, '1' on Polygon PoS
    chainId,
    verifyingContract: usdc,
  };
 
  const types: Record<string, TypedDataField[]> = {
    TransferWithAuthorization: [
      { name: 'from',         type: 'address' },
      { name: 'to',           type: 'address' },
      { name: 'value',        type: 'uint256' },
      { name: 'validAfter',   type: 'uint256' },
      { name: 'validBefore',  type: 'uint256' },
      { name: 'nonce',        type: 'bytes32' },
    ],
  };
 
  return { domain, types, message: {
    from, to, value, validAfter, validBefore, nonce,
  }};
}

The two values that catch teams out:

  • name is USD Coin on Circle's contract, not USDC. The string is hashed into the domain separator; one wrong character and every signature you produce is invalid on-chain.
  • version is '2' on mainnet but '1' on Polygon PoS because that's how it was deployed. Always read the live value from EIP712Domain() on the contract instead of hard-coding it.

A 5-line sanity test we run in CI: deploy nothing, just call the view function DOMAIN_SEPARATOR() on the live contract and assert it equals keccak256(abi.encode(...)) over your client-side values. Catches every chain-id and version drift before it reaches users.

Replay protection without sequential nonces#

Because the nonce is a 32-byte random value, a malicious relayer who intercepts a signature at the API boundary cannot trivially replay it — once it's spent on-chain, the contract's nonces[from] [nonce] flag flips to true and the second submission reverts.

But there are three real attacks that the contract does not protect you from:

  1. Cross-chain replay. USDC on Ethereum and USDC on Base have different chainId values in the domain separator, so the signature itself is chain-bound. Good. But if you accidentally construct the typed data with the wrong chainId server-side, you will produce signatures that work on a chain the user didn't intend. Always derive chainId from wallet_switchEthereumChain, never from a config file.
  2. Front-running by a watching relayer. If your relayer pool is public or compromised, an attacker can submit the same valid signature from their own EOA, paying the gas themselves and stealing nothing — the funds still move to to — but they can grief you by burning the nonce before your relayer does, making your "I just submitted this" UI lie. Mitigation: short validBefore window (we use 90 seconds) and an idempotency key on the API so the second confirm is a no-op.
  3. Long-lived signatures. Some wallets cache signed messages. validAfter defaults to 0 in most examples; set it to now - 30s to allow for clock skew but no further. validBefore should be now + 120s at the outside. We've seen abuse where a stolen laptop replays a 24-hour-old signed checkout.

The relayer: a 200-line worker, not a product#

The relayer is the boring part, which is exactly why people get it wrong. Ours is a single Node worker with three responsibilities:

services/relayer/submit.ts
export async function submitAuthorization(intent: PaymentIntent) {
  // 1. Re-verify off-chain before paying gas.
  const recovered = ethers.verifyTypedData(
    domainFor(intent.chainId),
    AUTH_TYPES,
    intent.message,
    intent.signature,
  );
  if (recovered.toLowerCase() !== intent.from.toLowerCase()) {
    throw new BadSignature();
  }
 
  // 2. Simulate. If the call would revert, fail fast and refund nonce.
  const data = usdc.interface.encodeFunctionData(
    'transferWithAuthorization',
    [...orderedArgs(intent)],
  );
  await provider.call({ to: intent.usdc, data, from: relayerAddr });
 
  // 3. Submit with EIP-1559 fees and a tight gas cap.
  const tx = await relayer.sendTransaction({
    to: intent.usdc,
    data,
    maxFeePerGas: await suggestMaxFee(),
    maxPriorityFeePerGas: 1_500_000_000n,
    gasLimit: 120_000n,
  });
 
  await persistPending(intent.id, tx.hash);
  return tx.hash;
}

The non-obvious bits:

  • Always simulate before submitting. transferWithAuthorization reverts on a stale nonce, an expired window, or insufficient balance. Simulating costs nothing and saves real ETH.
  • Cap gas tightly. A correct call uses ~75k gas. A 120k cap protects you from a malicious token at a malicious address (which can't happen with real USDC, but your relayer should be agnostic).
  • Log the tx hash before broadcast. If your worker crashes between sendTransaction and persistPending, you can lose the hash and the user sees "pending" forever. We persist a placeholder row first and update it on receipt.

Pricing the gas#

The economics only work if the gas cost is bounded relative to the payment. We refuse to relay if txCost > 0.6% of the intent value or above a hard floor of $0.40 — below that the merchant's net is worse than just asking the user to bring ETH. The fee is shown in the checkout UI as a single line item, computed from a fresh eth_gasPrice quote at intent-creation time. If the chain price moves more than 25% by the time the user signs, we re-quote and ask them to confirm.

For high-value intents (> $500) we batch nothing — gas is a rounding error and latency matters more. For low-value, we have an optional batchWindowMs: 4000 mode that submits up to eight authorizations in one block via a thin Multicall3 wrapper. That cuts per-transfer gas by ~38%.

Where it doesn't fit#

EIP-3009 is not a free lunch. It doesn't help on:

  • Tokens that don't implement it. USDT, DAI on most chains, and every long-tail ERC-20.
  • Solana, Tron, Bitcoin. Different chains, different stories. On Solana, the equivalent is durable nonces + a fee payer, which we'll cover in a separate post.
  • Subscriptions. A single-use authorization can't auto-renew. For recurring billing you still want permit + an allowance, or a smart-contract account (ERC-4337 / EIP-7702) with a session key.

What it is great for: one-off checkouts, invoices, donations, and any flow where the user is in front of a wallet, signing once, and expects the experience to feel like Apple Pay.

Key takeaways#

  • EIP-3009 is the cleanest way to accept USDC from a user who has no ETH — one signature, one transaction, no allowance dance.
  • Get the EIP-712 domain exactly right per chain. name is 'USD Coin', version is '2' on mainnet and '1' on Polygon.
  • Random bytes32 nonces enable parallel checkouts but require you to enforce short validBefore windows and API-side idempotency.
  • Always re-verify the signature server-side and simulate the call before paying gas. Cap gas at ~120k.
  • Pair EIP-3009 with a price ceiling (we use 0.6% / $0.40) so a gas spike doesn't quietly destroy the merchant's margin.
  • It's not a universal solution. Plan a fallback for non-3009 tokens and a separate path for subscriptions.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading