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
PermitWitnessTransferFromsignature — 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
{ "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"
}
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
voffset is irrelevant. - Sign with the same wallet as the
addressyou sent to prepare — the backend requires the signature to recover to that address and rejects mismatches with400.
Submit
Validates the signed envelope and broadcasts it via TronGrid.
POST /api/tron/permit2/approve/submit
Request Body
All fields are required.
{
"address": "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC",
"raw_data_hex": "0a02....",
"raw_data": { "contract": [ ... ], "expiration": 1751991000000, "fee_limit": 50000000 },
"signature": "27de5f....01"
}
Response (200)
{ "tx_id": "65ce40b668fa91b71c1b71897f17fdf513e128de8569eb5a2abe75f824bd8853" }
Submit is not a transaction relay. The backend pins the submitted
raw_datato the one shape prepare builds: a singleTriggerSmartContractcallingapprove(Permit2, MaxUint256)(or the resetapprove(Permit2, 0)) on the configured USDT contract, owned by the declared payer,fee_limitwithin the 50 TRX cap, unexpired — and the signature must recover to that payer. Anything else is rejected with400.
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_limitof 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
}
References
- USDT Signing — the payment-side Permit2 signal and
extrafields for TRON USDT - Supported Chains — TRON per-chain notes and gating
- Permit2 contract — protocol reference (TRON uses the SUN.io deployment)