BchainPay logoBchainPay
EngineeringEVMEIP-2612CheckoutPayments

EIP-2612 permit: cut ERC-20 checkout to one transaction

How EIP-2612 permit() collapses approve + transferFrom into a single off-chain signature, cutting ERC-20 checkout to one on-chain transaction.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for EIP-2612 permit: cut ERC-20 checkout to one transaction

Every developer who has built an ERC-20 checkout flow has hit the same wall: the user must send two transactions before a single payment lands. First approve(spender, amount), then whatever downstream call invokes transferFrom. Two gas fees, two wallet confirmations, two on-chain waits. Real-funnel data puts drop-off at the approve step between 15–30%. The second step doesn't help.

EIP-2612 removes the approve transaction entirely. The user signs an off-chain EIP-712 message. Your backend submits one on-chain transaction that calls permit() — which sets the allowance — then transferFrom() atomically in the same call. From the user's side: one signature, zero approve gas.

This post covers the full technical picture: the permit signature structure, token compatibility, the BchainPay API integration, and the non-obvious failure modes that catch teams in production.

Why approve + transferFrom exists#

ERC-20's ownership model cleanly separates two operations: transfer(to, amount) is the owner spending their own tokens; transferFrom(from, to, amount) lets an approved spender pull on the owner's behalf. Merchants almost always want the pull model — the user authorizes once and the merchant controls when settlement happens.

The cost is that two-step setup. approve() must be mined before transferFrom() is submitted, because the approval is state that transferFrom() reads. An EOA cannot batch them in a single transaction, because the first must change state before the second can read it.

What EIP-2612 adds#

EIP-2612 (finalized 2020, adopted by USDC, UNI, COMP, AAVE, and virtually every token deployed after 2021) adds one function to the ERC-20 interface:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

permit() calls ecrecover on an EIP-712 typed message. If the recovered signer matches owner, it writes allowance[owner][spender] = value and emits a standard Approval event. The function reverts if block.timestamp > deadline or the nonce does not match nonces[owner]. The nonce increments on every successful call, preventing replay.

The signed message is structured EIP-712 data:

{
  "domain": {
    "name":              "USD Coin",
    "version":           "2",
    "chainId":           8453,
    "verifyingContract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
  },
  "message": {
    "owner":    "0xUser…",
    "spender":  "0xBchainPayContract…",
    "value":    "100000000",
    "nonce":    "0",
    "deadline": "1714165200"
  }
}

chainId in the domain prevents cross-chain replays. name and version must match the token contract's own DOMAIN_SEPARATOR() — a common source of signature failures when testing against a fork or a chain where the token uses a different version string.

Building the permit signature in TypeScript#

With viem, the full flow — fetch nonce, sign, split v/r/s — looks like this:

import {
  createPublicClient,
  createWalletClient,
  custom,
  http,
  parseUnits,
} from 'viem';
import { base } from 'viem/chains';
 
const publicClient = createPublicClient({ chain: base, transport: http() });
const walletClient  = createWalletClient({ chain: base, transport: custom(window.ethereum) });
 
const [owner] = await walletClient.getAddresses();
 
// Always fetch nonce fresh immediately before signing.
const nonce = await publicClient.readContract({
  address: USDC_ADDRESS,
  abi: [{
    name: 'nonces', type: 'function',
    inputs:  [{ type: 'address' }],
    outputs: [{ type: 'uint256' }],
    stateMutability: 'view',
  }],
  functionName: 'nonces',
  args: [owner],
});
 
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 min
const value    = parseUnits('100', 6);                              // 100 USDC
 
const sig = await walletClient.signTypedData({
  account:     owner,
  domain: {
    name:              'USD Coin',
    version:           '2',
    chainId:           8453,
    verifyingContract: USDC_ADDRESS,
  },
  types: {
    Permit: [
      { name: 'owner',    type: 'address' },
      { name: 'spender',  type: 'address' },
      { name: 'value',    type: 'uint256' },
      { name: 'nonce',    type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ],
  },
  primaryType: 'Permit',
  message: { owner, spender: BCHAINPAY_CONTRACT, value, nonce, deadline },
});
 
// Split 65-byte hex signature into { v, r, s }.
const v = Number(`0x${sig.slice(130, 132)}`);
const r = sig.slice(0, 66)             as `0x${string}`;
const s = `0x${sig.slice(66, 130)}`   as `0x${string}`;

This produces v, r, s without an on-chain transaction. The user sees one "Sign" prompt in their wallet — no gas, no confirmation wait.

Submitting the permit to BchainPay#

Pass the permit components when creating the payment intent. BchainPay's settlement contract calls permit() before pulling funds, so no prior approve is needed:

POST /v1/payment-intents
Authorization: Bearer sk_live_…
Content-Type: application/json
 
{
  "amount":   "100.00",
  "currency": "USDC",
  "chain":    "base",
  "payer":    "0xUser…",
  "permit": {
    "deadline": 1714165200,
    "v": 27,
    "r": "0xabc123…",
    "s": "0xdef456…"
  }
}

The payment intent transitions through these states:

created → permit_submitted → pending → succeeded

If permit() reverts on-chain — expired deadline, wrong nonce, bad signature — BchainPay surfaces payment_intent.failed with error.code: permit_rejected. Gas for the failed attempt is not charged to the payer. The standard recovery path is to prompt the user to re-sign with a fresh nonce and deadline.

Detecting permit support at runtime#

Not every ERC-20 implements EIP-2612. The most reliable detection method is to attempt to read nonces():

async function supportsPermit(token: `0x${string}`): Promise<boolean> {
  try {
    await publicClient.readContract({
      address: token,
      abi: [{
        name: 'nonces', type: 'function',
        inputs:  [{ type: 'address' }],
        outputs: [{ type: 'uint256' }],
        stateMutability: 'view',
      }],
      functionName: 'nonces',
      args: ['0x0000000000000000000000000000000000000000'],
    });
    return true;
  } catch {
    return false;
  }
}

If nonces() exists, permit() almost certainly does too. The ERC-165 interface selector for EIP-2612 (0x9d8ff7da) is not universally implemented — even compliant tokens often omit it — so nonces() is more reliable in practice.

Tokens with confirmed EIP-2612 support on major EVM networks: USDC (Ethereum mainnet, Base, Arbitrum, Optimism, Polygon), UNI, LINK, COMP, AAVE, and essentially every OpenZeppelin ERC20Permit-based token deployed since 2021.

The DAI variant — a boolean trap#

DAI's permit predates EIP-2612 and uses an incompatible interface:

function permit(
    address holder,
    address spender,
    uint256 nonce,
    uint256 expiry,
    bool    allowed,   // boolean, not uint256 value
    uint8 v, bytes32 r, bytes32 s
) external;

allowed = true grants infinite approval; false revokes it. There is no partial-amount variant. Ethereum mainnet DAI still uses this interface. Polygon's bridged DAI and most L2 deployments of DAI use standard EIP-2612.

BchainPay routes DAI permits automatically based on chain and token address. When chain: "ethereum" and currency: "DAI" are combined, the API accepts the standard { v, r, s, deadline } fields and constructs the DAI-variant domain and struct internally. You do not need to detect or handle this difference yourself.

Nonce races and the concurrent-tab problem#

The permit nonce is a per-owner counter in the token contract. Two browser tabs building permits simultaneously will both read nonce = 4. One succeeds; the other reverts because the nonce advanced to 5.

Mitigations:

  1. Re-fetch the nonce immediately before signing, not on page load. Nonces cached minutes earlier are the most common permit failure mode in production.
  2. Prevent concurrent permit creation: lock the checkout form while a payment intent is in-flight.
  3. Use a short deadline (10–20 minutes). Long deadlines combined with stale nonces generate the most confusing support tickets.
  4. Retry on permit_rejected: re-fetch the nonce, re-sign, and re-submit. Never reuse the same v/r/s after a rejection.

Front-running: permit before transferFrom#

If permit() and transferFrom() are submitted in two separate transactions — or two separate contract calls in different blocks — an MEV bot can front-run the second step. The permit already set allowance = 100 USDC; the bot calls transferFrom(user, bot, 100) before your settlement transaction.

The correct pattern is a single atomic contract call:

function checkoutWithPermit(
    address token,
    uint256 value,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    IERC20Permit(token).permit(
        msg.sender, address(this), value, deadline, v, r, s
    );
    IERC20(token).transferFrom(msg.sender, address(this), value);
    // record payment, emit event
}

BchainPay's settlement contract uses this pattern. If a front-runner consumes the permit before the settlement transaction lands, transferFrom reverts, the entire call rolls back, and the nonce is consumed — the user must sign again. That outcome is rare but possible; surface a clear "please re-authorize" message rather than a generic failure.

Smart-contract wallets and ERC-1271#

EIP-2612 uses ecrecover, which only works for externally-owned accounts (EOAs). If the payer is a smart-contract wallet — Safe, Argent, Coinbase Smart Wallet, any ERC-4337 account — ecrecover returns an incorrect signer address and the permit reverts.

The workaround is ERC-1271: contracts expose isValidSignature(bytes32, bytes) so verifiers can call back into the wallet to validate a signature rather than using ecrecover directly. USDC v2.2 (deployed on Base and Optimism) supports ERC-1271 permit natively. For token deployments and chains where ERC-1271 permit is unavailable, BchainPay falls back to the EIP-3009 receiveWithAuthorization path, which is explicitly designed for contract-wallet payers.

When creating a payment intent via the API, you do not need to specify which path to use. BchainPay inspects the payer address at intent creation time — if it resolves to a contract, the API switches to the EIP-3009 flow transparently.

Key takeaways#

  • EIP-2612 permit() replaces the two-step approve + transferFrom flow with one off-chain EIP-712 signature and one on-chain transaction.
  • The signed message must match the token's DOMAIN_SEPARATOR exactly — name, version, chainId, and verifyingContract all matter.
  • Always fetch nonces[owner] fresh immediately before signing; stale nonces are the top permit failure mode in production.
  • Keep the deadline at 10–20 minutes; longer windows compound nonce-race risk without providing user value.
  • Call permit() and transferFrom() atomically in a single contract call to close the MEV front-running window.
  • DAI on Ethereum mainnet uses a boolean allowed variant; BchainPay routes this automatically.
  • For contract-wallet payers (ERC-4337, Safe), BchainPay transparently falls back to EIP-3009 receiveWithAuthorization.

Ready to test it? Sandbox keys are free at dashboard.bchainpay.com/register.


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