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") in CreateIntent to use USDT. The payment_requirements object in the response tells the SDK exactly which signing path to use.


Which chains require gas for USDT?

Chain Token Signing path Payer needs gas? Gas token
BSC (bsc) USDT Permit2 + approval extension Yes BNB
Ethereum (ethereum) USDT Permit2 + approval extension Yes ETH
Base (base) USDT Permit2 + approval extension Yes ETH
Polygon (polygon) USDT / USDT0 Permit2 + EIP-2612 (salted domain, sponsored) No
TRON (tron) USDT Permit2 (one-time on-chain approve via helper API) One-time TRX
Base (base) USDC EIP-3009 No
Arbitrum (arbitrum) USDC EIP-3009 No
BSC (bsc) USDC Permit2 + approval extension Yes BNB
Monad (monad) USDC Permit2 + EIP-2612 (sponsored) No
Solana (solana) USDC Solana VT v0 (fee payer sponsored) No

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 Polygon USDT section below. TRON USDT needs the approve only once per wallet; every subsequent payment is an off-chain signature — see TRON USDT below.


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:

  1. Sign EIP-2612 permit off-chain — typed-data signature against the salted domain (domainType = "salted" in payment_requirements.extra). No gas.
  2. Sign Permit2 PermitWitnessTransferFrom off-chain — no gas.
  3. 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 as extensions.eip2612GasSponsoring.info.signature in 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 under extensions.erc20ApprovalGasSponsoring. The facilitator broadcasts it on the payer's behalf. If the client cannot produce a raw signed transaction, fall back to sending the approve on-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:

  1. Authorize Permit2 once — via the erc20ApprovalGasSponsoring extension or a direct on-chain ERC20.approve(Permit2, amount). Requires gas.
  2. Sign Permit2 off-chainPermitWitnessTransferFrom typed-data signature. No gas.
  3. Submit settle_proof — POST to /api/intents/{intent_id}.

The backend exposes no approve-helper endpoints for EVM chains. Constructing and signing the approve transaction (raw or broadcast) is entirely the client's job. TRON is the exception: POST /api/tron/permit2/approve/prepare and POST /api/tron/permit2/approve/submit prepare 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-712 verifyingContract for the PermitWitnessTransferFrom domain).
  • spender — the operator-deployed x402ExactPermit2Proxy that pulls tokens via Permit2.permitWitnessTransferFrom (the witness spender).

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

  USDC (Base / Arb / ETH) USDC (Monad) USDT (ETH / BSC / Base), USDC (BSC) USDT (Polygon) USDT (TRON)
Standard EIP-3009 Permit2 + EIP-2612 Permit2 + approval extension Permit2 + EIP-2612 (salted) Permit2 (SUN.io deployment)
Payer gas required No No (Cross402 sponsors) Yes (one-time approve) No (Cross402 sponsors) One-time (TRX for approve)
assetTransferMethod absent / "eip3009" "permit2" "permit2" "permit2" "permit2" + permit2Address / spender
EIP-2612 extension in proof No Yes No (erc20ApprovalGasSponsoring instead) Yes (salted domain) No

References