USDT Signing
Cross402 supports USDT (and USDT0) as a payer asset on selected chains. The signing flow for USDT differs by chain: Ethereum / BSC / Base USDT implement neither EIP-3009 nor EIP-2612, so a one-time approve(Permit2, amount) transaction — built and signed by the payer — must land on-chain before the first payment (gas required). Polygon USDT / USDT0 is the exception — its contract exposes EIP-2612 against a salted EIP-712 domain, so the signing path is gasless. TRON USDT also settles through Permit2, with backend helper endpoints that prepare the one-time approve transaction for you — see TRON Signing.
Pass
payerAsset: "usdt"(or"usdt0") inCreateIntentto use USDT. Thepayment_requirementsobject in the response tells the SDK exactly which signing path to use.
Which chains require gas for USDT?
USDT on Ethereum, BSC, and Base requires an on-chain ERC20.approve(Permit2, amount) transaction before the payment signature. This transaction costs gas in the chain's native token. Polygon USDT is the exception — see the
Why USDT needs gas (ETH / BSC)
USDC and some other tokens implement one or both of these standards that enable gasless Permit2 authorization:
- EIP-3009
TransferWithAuthorization— the contract transfers tokens directly on a signed message, no prior approval needed. - EIP-2612
permit()— an off-chain signature that grants Permit2 an allowance in a single call. Cross402 can use this to sponsor the approval gas on behalf of the payer.
Legacy Tether (Ethereum / BSC / Base) implements neither. The only way to authorize Permit2 to move USDT on these chains is a standard on-chain ERC20.approve(Permit2, amount) call, which requires native gas from the payer's wallet. The approve can either be broadcast by the payer directly or handed to the facilitator as a pre-signed raw transaction via the erc20ApprovalGasSponsoring extension — in both cases the gas is deducted from the payer's address.
Polygon is the exception. Tether's Polygon-PoS contract (0xc2132D...) implements EIP-2612 against a non-standard salted EIP-712 domain. Cross402 uses this to sign the Permit2 allowance gaslessly — no on-chain approve tx needed from the payer.
Polygon USDT: EIP-2612 salted
Polygon USDT (and USDT0 — same contract) uses Permit2 + EIP-2612 with a salted EIP-712 domain. The payment flow is:
- Sign EIP-2612 permit off-chain — typed-data signature against the salted domain (
domainType = "salted"inpayment_requirements.extra). No gas. - Sign Permit2
PermitWitnessTransferFromoff-chain — no gas. - Submit
settle_proof— Cross402 broadcasts both signatures in one call.
The salted domain replaces the standard chainId field with a bytes32 salt = bytes32(chainID):
// Standard EIP-712 (most chains)
EIP712Domain(string name, string version, uint256 chainId, address verifyingContract)
// Polygon-PoS salted domain
EIP712Domain(string name, string version, address verifyingContract, bytes32 salt)
The SDK reads payment_requirements.extra.domainType == "salted" to switch layouts automatically. Signing against the standard layout fails on-chain verification.
The permit2 signal and gas-sponsoring extensions
For legacy USDT on Ethereum / BSC / Base (and Binance-Peg USDC on BSC), the backend sets payment_requirements.extra.assetTransferMethod = "permit2" and advertises the acceptable payload extensions:
{
"assetTransferMethod": "permit2",
"extensions": ["eip2612GasSponsoring", "erc20ApprovalGasSponsoring"],
"decimals": 6
}
The extensions array lists which extension payloads the facilitator accepts for this (chain, asset) pair. The SDK picks whichever fits the token:
- Token implements EIP-2612 (e.g. Polygon USDT/USDT0, Monad USDC) — the payer signs the
permit()typed data off-chain and attaches it asextensions.eip2612GasSponsoring.info.signaturein the settle proof. The facilitator submits the permit on-chain and pays the gas — the payer stays gasless. - Token has no EIP-2612 (legacy Tether USDT on Ethereum / BSC / Base, BSC Binance-Peg USDC) — the payer builds and signs a raw
approve(Permit2, max)transaction and attaches it underextensions.erc20ApprovalGasSponsoring. The facilitator broadcasts it on the payer's behalf. If the client cannot produce a raw signed transaction, fall back to sending theapproveon-chain from the payer's wallet directly. Either way the gas comes out of the payer's address — the payer must hold native gas (ETH / BNB).
The full payment flow is then:
- Authorize Permit2 once — via the
erc20ApprovalGasSponsoringextension or a direct on-chainERC20.approve(Permit2, amount). Requires gas. - Sign Permit2 off-chain —
PermitWitnessTransferFromtyped-data signature. No gas. - Submit
settle_proof— POST to/api/intents/{intent_id}.
The backend exposes no approve-helper endpoints for EVM chains. Constructing and signing the
approvetransaction (raw or broadcast) is entirely the client's job. TRON is the exception:POST /api/tron/permit2/approve/prepareandPOST /api/tron/permit2/approve/submitprepare and broadcast the one-time TRON approve for you — see TRON Signing.
Ethereum USDT: double-approve quirk
Ethereum mainnet USDT (0xdAC17F958D2ee523a2206206994597C13D831ec7) has a non-standard approve() that reverts if you try to change a non-zero allowance directly to another non-zero value. You must reset it to 0 first.
The SDK handles this automatically:
// Pseudocode — SDK does this for you
if currentAllowance > 0 {
token.approve(Permit2, 0) // reset
}
token.approve(Permit2, amount) // set
If you implement signing yourself, add this reset step for eip155:1 when the existing Permit2 allowance is non-zero.
TRON USDT: Permit2 with helper API
TRON USDT (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t, 6 decimals) also settles through Permit2 — canonical Tether on TRON implements neither EIP-3009 nor EIP-2612. The intent response's payment_requirements.extra carries two TRON-specific fields on top of the usual permit2 signal:
{
"assetTransferMethod": "permit2",
"permit2Address": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9",
"spender": "<x402ExactPermit2Proxy address>",
"name": "Tether USD",
"version": "1",
"decimals": 6
}
permit2Address— the SUN.io Permit2 deployment (the EIP-712verifyingContractfor thePermitWitnessTransferFromdomain).spender— the operator-deployed x402ExactPermit2Proxy that pulls tokens viaPermit2.permitWitnessTransferFrom(the witnessspender).
The flow mirrors the EVM legacy-USDT flow, with one big difference: the backend provides helper endpoints for the one-time approve. Instead of building a TRON transaction yourself, call POST /api/tron/permit2/approve/prepare and POST /api/tron/permit2/approve/submit — the backend constructs the approve(Permit2, max) transaction, you raw-sign its tx_id, and the backend validates and broadcasts it. The approve costs TRX (paid by the payer's wallet) and happens once per wallet; every payment after that is an off-chain Permit2 witness signature — no gas. See TRON Signing for the full endpoint reference.
Implementation example (JS/TS)
import { PublicPayClient, Asset } from '@cross402/usdc';
import { createWalletClient, createPublicClient, http, erc20Abi } from 'viem';
import { mainnet } from 'viem/chains';
const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const intent = await client.createIntent({
recipient: '0xRecipientAddress',
amount: '10.00',
payerChain: 'ethereum',
payerAsset: Asset.USDT,
});
const { extra } = intent.paymentRequirements;
// Legacy USDT signals Permit2 with the gas-sponsoring extensions advertised.
// Tokens with EIP-2612 (e.g. Polygon USDT) take the gasless permit path
// instead — pick per token, not just per method.
const extensions = extra.extensions ?? [];
const needsApproval =
extra.assetTransferMethod === 'permit2' &&
extensions.includes('erc20ApprovalGasSponsoring') &&
!tokenSupportsEIP2612(extra.asset); // legacy Tether: no permit()
// Step 1: authorize Permit2 once (on-chain approve; gas from the payer wallet).
// Alternatively, sign the approve as a raw tx and attach it to the proof as
// extensions.erc20ApprovalGasSponsoring for the facilitator to broadcast.
if (needsApproval) {
const current = await publicClient.readContract({
address: extra.asset,
abi: erc20Abi,
functionName: 'allowance',
args: [walletAddress, PERMIT2],
});
// ETH USDT: reset to 0 first if non-zero
if (current > 0n) {
const resetHash = await walletClient.writeContract({
address: extra.asset, abi: erc20Abi,
functionName: 'approve', args: [PERMIT2, 0n],
});
await publicClient.waitForTransactionReceipt({ hash: resetHash });
}
const approveHash = await walletClient.writeContract({
address: extra.asset, abi: erc20Abi,
functionName: 'approve', args: [PERMIT2, BigInt(extra.amount) * 10n],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
}
// Step 2: sign Permit2 PermitWitnessTransferFrom (off-chain, no gas)
// Step 3: submit settle_proof
await client.submitProof(intent.intentId, settleProof);
Implementation example (Go)
// The sdk_test reference implementation handles the Permit2 approval flow
// automatically. Set payerAsset in CreateIntentRequest:
resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
Recipient: "0xRecipientAddress",
Amount: "10.00",
PayerChain: "ethereum",
PayerAsset: pay.AssetUSDT,
})
// The settle_proof builder reads extra.assetTransferMethod ("permit2") and
// extra.extensions from payment_requirements. For legacy Tether USDT it:
// 1. Ensures the Permit2 allowance — a payer-signed raw approve attached as
// extensions.erc20ApprovalGasSponsoring, or an on-chain approve fallback
// (gas deducted from the payer either way)
// 2. Signs PermitWitnessTransferFrom (off-chain)
// 3. Returns the Permit2 proof (no EIP-2612 extension — the token has none)
Key differences vs USDC
References
- BSC Signing — Permit2 flow for USDC on BSC
- TRON Signing — TRON Permit2 approve helper endpoints
- Supported Chains — full payer chain matrix
- Permit2 contract —
0x000000000022D473030F116dDEE9F6B43aC78BA3