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:
- Re-fetch the nonce immediately before signing, not on page load. Nonces cached minutes earlier are the most common permit failure mode in production.
- Prevent concurrent permit creation: lock the checkout form while a payment intent is in-flight.
- Use a short deadline (10–20 minutes). Long deadlines combined with stale nonces generate the most confusing support tickets.
- Retry on
permit_rejected: re-fetch the nonce, re-sign, and re-submit. Never reuse the samev/r/safter 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-stepapprove + transferFromflow with one off-chain EIP-712 signature and one on-chain transaction. - The signed message must match the token's
DOMAIN_SEPARATORexactly —name,version,chainId, andverifyingContractall 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()andtransferFrom()atomically in a single contract call to close the MEV front-running window. - DAI on Ethereum mainnet uses a boolean
allowedvariant; 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.