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:
- Layer 1:
token.approve(Permit2, amount)— grants the Permit2 contract the right to move tokens from the user's wallet. - 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
SignatureTransferfor one-time checkout; useAllowanceTransferfor subscriptions and recurring payments. The nonce semantics differ: bitmap unordered vs sequential per (owner, token, spender). - Always use
permitWitnessTransferFromto bind the signature to a specific payment intent, recipient, and exact amount. A bareSignatureTransferleaves 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.