TRON Signing

TRON USDT settles through Permit2 — canonical TRC-20 USDT implements neither EIP-3009 nor EIP-2612, so a payer must grant Permit2 an allowance once before their first payment. Unlike the EVM chains (where the client builds the approve transaction itself — see USDT Signing), the backend provides two helper endpoints that build, validate, and broadcast the TRON approve for you. The client never constructs a TRON transaction: call prepare, raw-sign the returned hash with the payer's wallet, and post the envelope back to submit.

The approve is a one-time step per wallet. On-chain gas (bandwidth / energy) is paid by the payer's own TRON wallet — the backend does not sponsor TRX for this step. Once the approval lands, every payment is an off-chain Permit2 PermitWitnessTransferFrom signature — no gas. See TRON USDT in USDT Signing for the payment-side signal.

Both endpoints are public (no authentication, /api prefix). They are registered only when the deployment has TRON wired — a 404 on these routes means TRON is not enabled on that deployment.


Flow overview

1. POST /api/tron/permit2/approve/prepare   { address }
   → needed=false : already approved — go straight to the payment flow
   → needed=true  : receive tx_id + raw_data (+ raw_data_hex)

2. Raw-sign tx_id with the payer's wallet (raw secp256k1 over the 32-byte hash)
   → 64-byte (r||s) or 65-byte (r||s||v) hex signature

3. POST /api/tron/permit2/approve/submit    { address, raw_data_hex, raw_data, signature }
   → returns tx_id (the broadcast on-chain transaction id)

4. (optional) Poll the tx_id on TronScan until confirmed, then start the payment flow

The prepared transaction expires roughly 60 seconds after prepare (the exact deadline is the expiration field, epoch milliseconds). Complete the sign + submit steps before it; an expired envelope is rejected with 400 — call prepare again for a fresh transaction.


Prepare

Builds the unsigned approve(Permit2, MaxUint256) transaction, or reports that no approval is needed.

POST /api/tron/permit2/approve/prepare

Request Body

Field Required Type Description
address Yes string The payer's TRON wallet address (base58check, T… prefix)
{ "address": "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC" }

Response (200) — already approved

{
  "needed": false,
  "usdt_contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
  "permit2_contract": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9"
}

The wallet already holds a max allowance — skip submit and go straight to the payment flow.

Response (200) — approval needed

{
  "needed": true,
  "step": "approve",
  "tx_id": "65ce40b668fa91b71c1b71897f17fdf513e128de8569eb5a2abe75f824bd8853",
  "raw_data_hex": "0a02....",
  "raw_data": { "contract": [ ... ], "expiration": 1751991000000, "fee_limit": 50000000 },
  "expiration": 1751991000000,
  "usdt_contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
  "permit2_contract": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9"
}
Field Type Description
needed bool false = already approved (tx fields omitted); true = sign and submit the transaction below
step string "approve" (normal) or "reset" (see Two-step approval). Omitted when needed is false
tx_id string The value to sign — the transaction id, equal to sha256(raw_data) as hex
raw_data_hex string The transaction's protobuf raw_data (hex). Return it to submit unmodified
raw_data object The transaction's raw_data as JSON. Return it to submit unmodified
expiration int64 Transaction deadline (epoch milliseconds). Submit must complete before this
usdt_contract string The USDT contract being approved (display only)
permit2_contract string The Permit2 contract being granted the allowance (display only)

Signing the transaction

Sign tx_id with a raw secp256k1 hash signature — not a personal message, not typed data. TRON uses the same curve and keccak-based addressing as EVM, so any wallet exposing a raw-sign primitive works (e.g. Privy embedded wallets):

// tx_id is the hex string from prepare (sha256(raw_data)); pass it 0x-prefixed
const hash = tx_id.startsWith("0x") ? tx_id : `0x${tx_id}`;

const { signature } = await wallet.rawSign({ hash });
// 64-byte (r||s) or 65-byte (r||s||v) hex — both are accepted

Key points:

  • The value to sign is exactly tx_id (sha256(raw_data)), already computed by the backend — do not hash anything yourself.
  • Both 64-byte (r‖s) and 65-byte (r‖s‖v) signatures are accepted; the backend re-derives the recovery byte itself, so the EVM 27/28 v offset is irrelevant.
  • Sign with the same wallet as the address you sent to prepare — the backend requires the signature to recover to that address and rejects mismatches with 400.

Submit

Validates the signed envelope and broadcasts it via TronGrid.

POST /api/tron/permit2/approve/submit

Request Body

All fields are required.

Field Type Description
address string The payer's TRON address — must match the prepare call
raw_data_hex string The raw_data_hex from prepare, unmodified
raw_data object The raw_data from prepare, unmodified
signature string The raw signature over tx_id (64- or 65-byte hex, 0x prefix optional)
{
  "address": "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC",
  "raw_data_hex": "0a02....",
  "raw_data": { "contract": [ ... ], "expiration": 1751991000000, "fee_limit": 50000000 },
  "signature": "27de5f....01"
}

Response (200)

{ "tx_id": "65ce40b668fa91b71c1b71897f17fdf513e128de8569eb5a2abe75f824bd8853" }
Field Type Description
tx_id string The broadcast on-chain transaction id — look it up on TronScan

Submit is not a transaction relay. The backend pins the submitted raw_data to the one shape prepare builds: a single TriggerSmartContract calling approve(Permit2, MaxUint256) (or the reset approve(Permit2, 0)) on the configured USDT contract, owned by the declared payer, fee_limit within the 50 TRX cap, unexpired — and the signature must recover to that payer. Anything else is rejected with 400.


Two-step approval (step = "reset")

TRC-20 USDT carries Tether's classic anti-front-running restriction: a non-zero allowance cannot be changed directly to another non-zero value — it must be reset to zero first.

When a wallet holds a partial allowance (non-zero, non-max), prepare returns step: "reset" with an approve(Permit2, 0) transaction:

1. prepare → step="reset"
2. sign → submit (zeroes the allowance)
3. prepare again → step="approve"
4. sign → submit (sets max — done)

A fresh wallet (zero allowance) gets step: "approve" on the first call and needs only one round. The client rule is simple: after a successful submit, if the step you just executed was "reset", run the full prepare/sign/submit cycle once more.


Gas and fees

  • The approve transaction carries a fee_limit of 50 TRX (50,000,000 sun). Typical real cost is ≈15–20 TRX in energy/bandwidth; the cap leaves headroom for chain-condition spikes.
  • The payer's wallet must hold enough TRX (or staked energy/bandwidth) to cover it. The backend does not sponsor this transaction.
  • This cost is incurred once per wallet. Subsequent payments sign the Permit2 witness off-chain — zero gas.

Error Reference

All errors use the standard JSON error envelope:

{
  "error": "Bad Request",
  "message": "tron payer approve: transaction expired, request a new prepare",
  "statusCode": 400
}
Status Cause Client handling
400 Malformed address, missing fields, raw_data is not the expected approve, expired envelope, or the signature does not recover to the payer Fix the input; on expiry or signature mismatch, prepare again and re-sign
403 The address was declined by compliance screening, or screening is temporarily unavailable (fail-closed). Body is the generic payment declined Show a generic "payment cannot be processed" message. Do not retry
429 Rate limited Back off and retry later
502 TronGrid rejected or dropped the broadcast (insufficient bandwidth/energy, duplicate transaction, gateway issues) Retry from prepare; if it persists, the wallet likely lacks TRX resources
500 Internal backend error (message is sanitized) Retry later
404 Route not registered — this deployment has TRON disabled Not retryable; TRON is unavailable here

References