Permit2 signing guide (permit2-exact)
This page is a self-contained reference for producing valid permit2-exact payloads that B402 V2
accepts. Use this when your asset does not support EIP-3009 (e.g. USDT on BSC) — Permit2 is the
only path to /papi/v2/b402/settle in that case.
The stock @x402/evm client library is EIP-3009 focused and does not ship a Permit2 signer. You
construct the EIP-712 typed data yourself with viem or
ethers.js, then drop the signature into
paymentPayload.payload.permit2Authorization.
permit2-uptouses a different witness struct and is out of scope for this page — contact the B402 team if you need it.
How it works
- Payer wallet approves Permit2 once on the token contract (standard ERC-20
approve(permit2, max)— one-time, per token, per wallet). - For each payment, the payer signs an EIP-712
PermitWitnessTransferFrommessage (typed data below). Thewitnessbinds the signature to the merchant'spayTo, so a settled signature can only pay that merchant. - You POST the signed envelope to
/papi/v2/b402/verify(off-chain check) and then/papi/v2/b402/settle. B402's facilitator callsPermit2.permitWitnessTransferFrom(...)on-chain with gas sponsored.
Prerequisites
| Item | Value | Notes |
|---|---|---|
| Permit2 contract | 0x000000000022D473030F116dDEE9F6B43aC78BA3 | Canonical deployment — same address on every EVM chain. |
| Payer's Permit2 approval | token.approve(0x000000000022D473030F116dDEE9F6B43aC78BA3, 2^256-1) | One-time per (wallet, token) pair. If the payer has used any Uniswap / Permit2-integrated dapp before, this is usually already set. |
extra.signerAddress | facilitator EOA | Returned by /supported under the matching kinds[] entry. Surface it to buyers. |
extra.spenderAddress | Permit2 proxy contract | Returned by /supported. This is the spender in the typed data message, not the facilitator EOA. |
Note:
spenderAddresschanges if b402 redeploys the proxy contract. Always read it fresh from/supportedrather than hard-coding.
Step 1 — Build the EIP-712 typed data
Domain
Permit2 uses a three-field EIP-712 domain. There is no version field — adding one will
change the domain separator and your signatures will be rejected.
Code
Use chainId 56 for BSC mainnet or 97 for BSC testnet. The verifyingContract is constant
across all chains.
Types
Code
Field order is load-bearing. EIP-712 hashes struct fields in declaration order. Swapping
toandvalidAfter(or any other pair) produces a digest that looks valid but fails the on-chain check. TheWitnessstruct name must be exactlyWitness— notB402Witnessor anything else.
primaryType
Code
Message
| Field | Source | Notes |
|---|---|---|
permitted.token | paymentRequirements.asset | ERC-20 token contract address |
permitted.amount | paymentRequirements.amount | Exact amount in atomic units — authorized and settled 1:1 |
spender | paymentRequirements.extra.spenderAddress | Permit2 proxy contract address — not the facilitator EOA and not the signer |
nonce | 256-bit random | Permit2 uses a bitmap; generate fresh random bytes per signature |
deadline | unix seconds, now + 3600 | Signature expiry |
witness.to | paymentRequirements.payTo | Merchant wallet — the on-chain transfer recipient |
witness.validAfter | unix seconds, now − 60 | Earliest time settlement can execute. Back-dating by 60 s absorbs client/server clock skew. "0" is also valid (signature usable immediately); replay protection comes from nonce, not validAfter. |
Step 2 — Sign it
TypeScript — viem (recommended)
Signing is a pure local operation — no RPC needed. privateKeyToAccount(...).signTypedData(...) is
enough.
Code
TypeScript — ethers.js v6
Code
Note: ethers.js v6's
signTypedDataauto-derivesEIP712Domain. Viem does the same. Do not addEIP712Domainto thetypesmap manually — both libraries will reject it.
Step 3 — Build the wire envelope
Drop the signature and permit2Authorization into the V2 PaymentPayload shape. See
Verify Payment — Example — Permit2 Exact
for the full JSON.
Code
All numeric fields (nonce, deadline, validAfter, amount) are decimal strings on the
wire, but uint256 inside the EIP-712 typed data. Both the EIP-712 signing libraries above accept
the bigint/string types natively — just ensure the string you put on the wire matches the value that
was signed.
Common pitfalls
- Adding
versionto the domain. Permit2's domain has exactly{name, chainId, verifyingContract}. If you spread a{ version: '1' }in, the domain separator changes and/verifyreturnsinvalid_exact_evm_payload_signature. spender= proxy, not signer.extra.spenderAddress(the Permit2 proxy contract) goes inspender. The facilitator EOA (extra.signerAddress) does not appear in the Permit2 exact typed data — it's only used bypermit2-upto.- Witness field order. Must be
(to, validAfter)— in that order — for both the struct nameWitnessand its field list. Swapping fields or renaming the struct breaks the typehash. - Wire format vs typed data.
nonce,deadline,validAfter,amountsign asuint256but travel as decimal strings on the JSON wire. Library serialization usually handles this; if you seeinvalid_payload, check that the string representations match. - Forgetting the Permit2 approval — or granting a finite one. If the payer's wallet has never
approved Permit2 on this token, the on-chain
permitWitnessTransferFromreverts withTRANSFER_FROM_FAILEDat/settle(verify does not check this). Granting a finite approval (e.g. exactly the first payment amount) works once, but the allowance is consumed on each transfer and later payments will revert the same way. Recommendtoken.approve(permit2, 2^256-1)once per(wallet, token). - Signature length / prefix. The signature is a
0x-prefixed 65-byte hex string (r || s || v). Both viem and ethers.js return this shape natively; don't trim or reformat. - Clock skew. If
validAfteris later than the facilitator'sblock.timestampat settle time, the proxy reverts. Back-dating by 60 s is conservative; shorter backdates are fine if your clocks are NTP-synced.
End-to-end walkthrough
- Fetch configurations:
POST /papi/v2/b402/supported(with{}body, RSA-signed). Find thekinds[]entry withasset == <your USDT address>andassetTransferMethod == "permit2-exact". Readextra.spenderAddressandextra.signerAddress. - Merchant constructs
paymentRequirementsusing the chosen kind; surfaceextra.spenderAddressandextra.signerAddressto the buyer (HTTP 402 response body). - Buyer signs the EIP-712 payload as shown above, producing
signatureandpermit2Authorization. - Buyer POSTs envelope to
POST /papi/v2/b402/verify. ExpectisValid: true. - Merchant POSTs the same envelope to
POST /papi/v2/b402/settle. Expectsuccess: truewith an on-chaintransactionhash.
Troubleshooting
| Symptom | Likely cause |
|---|---|
invalid_exact_evm_payload_signature | Domain / types / field-order mismatch, or signing with a key that doesn't own the from address |
invalid_exact_evm_payload_authorization_valid_after | validAfter is in the future; reduce the backdate or wait |
invalid_exact_evm_payload_authorization_valid_before | deadline is in the past; sign a fresh payload |
invalid_exact_evm_payload_recipient_mismatch | witness.to doesn't match paymentRequirements.payTo |
invalid_exact_evm_payload_authorization_value_mismatch | permitted.amount < paymentRequirements.amount |
invalid_network | chainId in domain doesn't match the CAIP-2 network in paymentRequirements.network |
/settle returns invalid_transaction_state with TRANSFER_FROM_FAILED on-chain | Payer hasn't approved Permit2 on the token, or has insufficient balance |
invalid_payload | Wire field types off — check decimal-string encoding of numeric fields |
Still stuck? Send us the failing paymentPayload JSON and the response — we can match the digest
against what B402 recovered and pinpoint the mismatch.