BchainPay logoBchainPay
EngineeringEthereumERC-20CheckoutSecurity

Permit2: Universal ERC-20 Approvals for Gasless Merchant Checkout

How Uniswap's Permit2 makes signature-based checkout work for ERC-20 tokens without native permit. One upfront approval enables gasless repeat payments for merchants.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for Permit2: Universal ERC-20 Approvals for Gasless Merchant Checkout

EIP-2612 lets a token contract verify a typed signature before executing a transfer, so a user can approve and transfer in a single transaction. EIP-3009 extends the same idea to USDC and USDT via transferWithAuthorization. Both require the token itself to implement the permit interface.

The problem: most high-liquidity ERC-20s don't. USDT on Ethereum mainnet, WBTC, LINK, and hundreds of other tokens have no permit() function. Accepting them in a single-step checkout still requires two on-chain calls: approve(), then your settlement contract's transferFrom(). That two-step flow kills conversion on mobile and is an adoption barrier for any checkout product targeting non-DeFi-native users.

Uniswap's Permit2 fills that gap with a universal approval layer that any standard ERC-20 can use after a one-time on-chain bootstrap.

How Permit2 works#

Permit2 is a standalone smart contract deployed at the same address on every major supported EVM chain via CREATE2:

0x000000000022D473030F116dDEE9F6B43aC78BA3

Verify it is deployed on each chain you target; the canonical list is maintained in the Uniswap/permit2 repository.

Before using Permit2 for a token, the user makes one standard ERC-20 approve() call authorizing the Permit2 contract to move that token on their behalf. This is a gas-paying on-chain transaction — the only one required for the lifetime of the integration, per token.

After that, every subsequent payment is authorized with an EIP-712 off-chain signature. The user signs a structured message specifying which token, how much, which spender, and an expiry. BchainPay's relayer submits that signature and the corresponding transferFrom in a single transaction, at no gas cost to the user.

Permit2 has two modules with different mechanics.

AllowanceTransfer: recurring authorizations#

AllowanceTransfer is the right choice for subscriptions, top-up accounts, or high-frequency payments to the same merchant.

The user signs a PermitSingle message that grants a specific spender an allowance of up to amount tokens, expiring at a given timestamp. The nonce is sequential per (owner, token, spender) triple. Invalidating an unconfirmed permit signature is as simple as submitting a new permit() call with the current nonce, which advances it out from under any unconfirmed signature.

// EIP-712 typed data for AllowanceTransfer — hand-craft or generate via SDK
const domain = {
  name:              'Permit2',
  chainId,
  verifyingContract: '0x000000000022D473030F116dDEE9F6B43aC78BA3',
};
 
const types = {
  PermitSingle: [
    { name: 'details',     type: 'PermitDetails' },
    { name: 'spender',     type: 'address' },
    { name: 'sigDeadline', type: 'uint256' },
  ],
  PermitDetails: [
    { name: 'token',      type: 'address' },
    { name: 'amount',     type: 'uint160' },
    { name: 'expiration', type: 'uint48' },
    { name: 'nonce',      type: 'uint48' },
  ],
};
 
const value = {
  details: {
    token:      USDT_ADDRESS,
    amount:     BigInt('1000000000'),              // 1 000 USDT (6 decimals)
    expiration: Math.floor(Date.now() / 1000) + 86400 * 30,
    nonce,                                          // from Permit2.allowance()
  },
  spender:     BCHAINPAY_SETTLEMENT_CONTRACT,
  sigDeadline: Math.floor(Date.now() / 1000) + 600,
};
 
const signature = await signer._signTypedData(domain, types, value);

Once submitted on-chain via Permit2.permit(owner, permitSingle, sig), the spender can call Permit2.transferFrom() repeatedly until the allowance is exhausted or the expiry passes.

SignatureTransfer: single-use checkout#

SignatureTransfer is the right choice for one-time payments. Each signature consumes a bitmap-tracked unordered nonce: any 128-bit value the user chooses, consumed once and then permanently invalid. Multiple in-flight payment signatures don't block each other because nonces don't need to be sequential.

Binding the signature to a payment intent#

This is the critical safety step. Without it, the transfer recipient and exact amount are chosen by whoever submits the transaction, which creates a silent re-routing risk. Use permitWitnessTransferFrom to attach a custom EIP-712 witness struct to the signature. The settlement contract verifies the witness before calling Permit2.

// Witness struct (must match the Solidity struct in the settlement contract)
const witnessTypes = {
  PaymentWitness: [
    { name: 'intentId',  type: 'bytes32' },
    { name: 'recipient', type: 'address' },
    { name: 'amount',    type: 'uint256' },
  ],
};
 
const witness = {
  intentId:  ethers.id('pi_01HW2QKFP4X5Y3Z8A1B2C3D4E5'), // keccak256 of intent ID
  recipient: MERCHANT_TREASURY_ADDRESS,
  amount:    BigInt('49990000'),                             // $49.99 USDT
};
 
const types = {
  PermitWitnessTransferFrom: [
    { name: 'permitted',  type: 'TokenPermissions' },
    { name: 'spender',    type: 'address' },
    { name: 'nonce',      type: 'uint256' },
    { name: 'deadline',   type: 'uint256' },
    { name: 'witness',    type: 'PaymentWitness' },
  ],
  TokenPermissions: [
    { name: 'token',  type: 'address' },
    { name: 'amount', type: 'uint256' },
  ],
  ...witnessTypes,
};
 
const value = {
  permitted: { token: USDT_ADDRESS, amount: BigInt('49990000') },
  spender:   BCHAINPAY_SETTLEMENT_CONTRACT,
  nonce:     crypto.getRandomValues(new Uint8Array(16))
               .reduce((acc, b, i) => acc | (BigInt(b) << BigInt(i * 8)), 0n),
  deadline:  BigInt(Math.floor(Date.now() / 1000) + 900),
  witness,
};
 
const signature = await signer._signTypedData(domain, types, value);

The settlement contract decodes the witness, asserts it matches the stored payment intent, then calls Permit2. Any tampering with recipient or amount produces a signature mismatch and the transaction reverts:

// Solidity — settlement contract (simplified)
function settleWithPermit2(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    PaymentWitness calldata witness,
    address owner,
    bytes calldata sig
) external {
    PaymentIntent storage intent = intents[witness.intentId];
    require(intent.status    == Status.AwaitingPayment, "bad status");
    require(intent.recipient == witness.recipient,       "recipient mismatch");
    require(intent.amount    == witness.amount,          "amount mismatch");
 
    ISignatureTransfer.SignatureTransferDetails memory details =
        ISignatureTransfer.SignatureTransferDetails({
            to:              witness.recipient,
            requestedAmount: witness.amount
        });
 
    PERMIT2.permitWitnessTransferFrom(
        permit, details, owner,
        keccak256(abi.encode(WITNESS_TYPEHASH, witness)),
        WITNESS_TYPE_STRING,
        sig
    );
 
    intent.status = Status.Confirmed;
    emit PaymentConfirmed(witness.intentId);
}

Replay protection is handled by Permit2's bitmap nonce, not the settlement contract.

Integrating with the BchainPay API#

Create a payment intent with funding_method set to permit2_signature_transfer. The API returns a permit2 object pre-filled with the complete EIP-712 typed data, including the witness fields:

POST /v1/payment-intents
Authorization: Bearer sk_live_…
Content-Type: application/json
 
{
  "amount":         { "value": 49.99, "currency": "USD" },
  "accept":         ["USDT.ethereum"],
  "funding_method": "permit2_signature_transfer",
  "expires_in":     900
}

Response:

{
  "id":     "pi_01HW2QKFP4X5Y3Z8A1B2C3D4E5",
  "status": "awaiting_signature",
  "permit2": {
    "contract":   "0x000000000022D473030F116dDEE9F6B43aC78BA3",
    "spender":    "0xBcP5ettle…",
    "token":      "0xdAC17F958D2ee523a2206206994597C13D831ec7",
    "amount":     "49990000",
    "deadline":   1745756500,
    "nonce":      "149823649731258912",
    "typed_data": { }
  }
}

Your frontend checks whether the user has already approved Permit2 for the token, prompts the bootstrap approve() if needed, calls wallet.signTypedData(permit2.typed_data), and submits the signature:

curl -s -X POST https://api.bchainpay.com/v1/payment-intents/pi_01HW2.../authorize \
  -H "Authorization: Bearer $SK" \
  -H "Content-Type: application/json" \
  -d '{"signature": "0xabcdef…"}'

BchainPay's relayer submits the Permit2 transaction on-chain. After the bootstrap, every repeat payment to any BchainPay merchant requires no gas from the user.

Bootstrap detection#

const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
 
const current: bigint = await token.allowance(userAddress, PERMIT2);
if (current < requiredAmount) {
  // One-time per token. Frame this to users as setup, not a per-payment cost.
  const tx = await token.approve(PERMIT2, MaxUint256);
  await tx.wait();
}
// All subsequent payments: sign and submit, no further on-chain calls.

Whether to approve MaxUint256 or a finite amount is a product decision. A large standing approval reduces friction but widens the blast radius if the user's wallet is phished. Display the amount and link to revocation instructions in your checkout UI.

Security considerations#

Two independent trust layers#

Permit2 creates two distinct authorization relationships:

  1. Layer 1: token.approve(Permit2, amount) — grants the Permit2 contract the right to move tokens from the user's wallet.
  2. Layer 2: The Permit2 allowance to your spender contract — grants your settlement contract the right to instruct Permit2 to execute transfers.

Revoking layer 2 (invalidating a permit signature, or calling Permit2.invalidateNonces(...)) does not revoke layer 1. If a user wants to fully opt out, they must call token.approve(Permit2, 0) at layer 1. Build a visible revocation path into your account settings and link to Revoke.cash for users who manage multiple integrations.

Signature phishing via standing approvals#

A signed Permit2 message from a user with an existing MaxUint256 approval can drain the entire allowance in one transaction. Phishing sites create lookalike checkout pages and trick users into signing permit messages for malicious spenders. Countermeasures:

  • Display the spender contract address prominently before the user signs.
  • Use short deadlines on SignatureTransfer (15 minutes for a payment window).
  • Require the witness binding described above so a stolen signature is useless without the matching payment intent.

Tokens with non-standard behavior#

Permit2 works with standard approve/transferFrom ERC-20s. Fee-on-transfer tokens, rebasing tokens, and tokens with built-in freeze or burn mechanics can produce silent underpayment when routed through Permit2. BchainPay's accept list is restricted to tokens we have vetted for standard behavior; do not expose Permit2 flows for arbitrary token addresses supplied by merchants.

Permit2 vs EIP-2612 vs EIP-3009#

EIP-2612 EIP-3009 Permit2
Token requirement Implements permit() Implements transferWithAuthorization() Standard approve/transferFrom
Examples USDC, DAI, UNI USDC (Eth), USDT (Eth) USDT, WBTC, LINK, any std. ERC-20
First-payment gas None None One-time approve() per token
Repeat payment gas None None None
Nonce model Sequential, per token Unordered, per address AllowanceTransfer: sequential per (owner, token, spender); SignatureTransfer: unordered bitmap
Recipient binding Baked into signature Baked into signature Requires permitWitnessTransferFrom
Multi-token batch No No Yes (AllowanceTransfer batch)
ERC-1271 wallets Varies by token Varies by token Native support

Key takeaways#

  • Permit2 extends signature-based checkout to tokens that don't implement EIP-2612 or EIP-3009, including USDT on Ethereum mainnet and WBTC.
  • The first payment for a new token requires one gas-paying approve(). Frame it as a one-time bootstrap; subsequent payments are signature-only.
  • Use SignatureTransfer for one-time checkout; use AllowanceTransfer for subscriptions and recurring payments. The nonce semantics differ: bitmap unordered vs sequential per (owner, token, spender).
  • Always use permitWitnessTransferFrom to bind the signature to a specific payment intent, recipient, and exact amount. A bare SignatureTransfer leaves the recipient and amount to the spender contract's discretion.
  • Two trust layers exist independently. Revoking a Permit2 allowance does not revoke the underlying token approval. Build visible revocation paths into your product UI.
  • Verify the canonical Permit2 address on each target chain. The CREATE2 address is consistent across major EVM chains but should never be assumed for new or less-common networks.

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