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:
- Permit only grants an allowance. You still need a second transaction to actually move the tokens. EIP-3009 moves the tokens in one shot.
- EIP-3009 uses a random
bytes32nonce, 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
approveround-trip, nopermitpre-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.
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:
nameisUSD Coinon Circle's contract, notUSDC. The string is hashed into the domain separator; one wrong character and every signature you produce is invalid on-chain.versionis'2'on mainnet but'1'on Polygon PoS because that's how it was deployed. Always read the live value fromEIP712Domain()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:
- Cross-chain replay. USDC on Ethereum and USDC on Base have
different
chainIdvalues in the domain separator, so the signature itself is chain-bound. Good. But if you accidentally construct the typed data with the wrongchainIdserver-side, you will produce signatures that work on a chain the user didn't intend. Always derivechainIdfromwallet_switchEthereumChain, never from a config file. - 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: shortvalidBeforewindow (we use 90 seconds) and an idempotency key on the API so the second confirm is a no-op. - Long-lived signatures. Some wallets cache signed messages.
validAfterdefaults to0in most examples; set it tonow - 30sto allow for clock skew but no further.validBeforeshould benow + 120sat 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:
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.
transferWithAuthorizationreverts 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
sendTransactionandpersistPending, 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
domainexactly right per chain.nameis'USD Coin',versionis'2'on mainnet and'1'on Polygon. - Random
bytes32nonces enable parallel checkouts but require you to enforce shortvalidBeforewindows 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.