USDT on TRON is, by transfer count, the largest stablecoin venue in the world. Cheap, fast, no allowance dance, no priority-fee auctions. For merchants accepting payments in emerging markets it is often the only rail customers actually use.
It is also the rail where we see the most novel attempts at double-spend and confirmation-edge attacks. TRON's finality model isn't bad, but it isn't Ethereum's either, and the gap between "the node returned a receipt" and "the value is yours" is wider than most payment teams assume.
This post is the playbook we use at BchainPay to detect double-spend
attempts, short reorgs, and TRC-20 receipt forgery on TRON USDT
deposits — before we mark a payment_intent as succeeded and fire
the webhook.
TRON's finality, in one paragraph#
TRON uses Delegated Proof-of-Stake with 27 Super Representatives
producing blocks in a round. A block is solidified once two-thirds
of SRs (+2/3, i.e. 19) have built on top of it. Solidification
typically happens in ~19 blocks ≈ 57 seconds. Anything before
solidification is, in principle, replaceable. Reorgs deeper than a
few blocks are rare in 2026, but single-block forks happen daily
— our indexer sees a non-solidified parent get swapped roughly 90 to
140 times per day on Mainnet.
Two practical consequences:
- The
confirmedfield returned bywallet/gettransactionbyidonly means the transaction is in some node's view of the chain, not that it's irreversible. - The block number a tx appears in can change. You must store the tx hash, not the block number, as your primary key, and you must re-fetch and re-validate on every poll cycle until the tx's block is solidified.
What "double-spend" actually looks like on TRON USDT#
There are four distinct attack shapes we've seen against merchants. They're worth naming, because the detection rule for each is different.
1. Receipt forgery by a hostile RPC#
An attacker controls the RPC endpoint the merchant's checkout polls.
They serve a fake gettransactionbyid response showing a successful
USDT transfer to the merchant's address. The transaction was never
broadcast at all.
This is the most common attack we see and the easiest to defeat.
Always cross-check against at least one independent source. We hit
two of trongrid.io, the merchant's own full node, and a public
TRON Stack endpoint, and refuse to credit if any disagree on the
existence of the tx hash.
2. Same-block fork (single-slot reorg)#
Two SRs in the same round each produce a block at the same height and broadcast. One wins solidification, the other becomes an orphan. If the attacker's USDT transfer landed in the orphan and a different transfer with the same hash didn't land in the winner, the receipt disappears.
Detection: never credit before the tx's block has been solidified
and has at least one solidified child. We require the tx block
height to be ≤ latest_solidified_block_number - 1.
3. Replacement via energy auction#
This is TRON-specific. Energy is the unit you spend to call a
contract. If a merchant's deposit address is also actively being
used as a fee payer, an attacker can race to consume its energy
budget so a legitimate inbound USDT transfer's triggersmartcontract
call fails with OUT_OF_ENERGY, while a different transfer they
control succeeds. The Transfer event fires for the wrong tx.
You won't notice this at the transaction level because both txs are
"successful". You notice at the event log level: a confirmed tx
hash with no matching Transfer(from, to, value) event in its
receipt is a red flag.
4. ABI-collision spoof#
The TRC-20 Transfer event signature
(0xddf252ad…) is shared with hundreds of fake-USDT contracts that
mimic it. A naive indexer that filters by event signature alone,
without pinning the contract address, will happily ingest a transfer
from a fake USDT contract at a vanity address (often
TR7NHqj...-prefixed to look right in a UI) and credit the merchant
for free coins.
Detection: pin the contract. Mainnet USDT is exactly
TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. Any other emitter is, by
definition, not USDT, no matter how it renders in a wallet.
The verification pipeline#
Here is the exact sequence we run before any TRON USDT deposit becomes a credit. Each step is a hard gate.
import { TronWeb } from 'tronweb';
const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const TRANSFER_TOPIC =
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
const MIN_SOLID_GAP = 1; // tx block must be solidified - 1 or older
export async function verifyTronUsdtDeposit(
primary: TronWeb, secondary: TronWeb, txId: string, expected: {
to: string; amountUnits: bigint; // 6 decimals
},
) {
// 1. Pull the tx + receipt from two independent RPCs.
const [txA, txB, infoA] = await Promise.all([
primary.trx.getTransaction(txId),
secondary.trx.getTransaction(txId),
primary.trx.getTransactionInfo(txId),
]);
if (!txA?.txID || !txB?.txID) throw new Error('tx-not-found');
if (txA.txID !== txB.txID) throw new Error('tx-id-mismatch');
// 2. Receipt must be SUCCESS and contain the Transfer log
// emitted by the *real* USDT contract.
if (infoA.receipt?.result !== 'SUCCESS') throw new Error('exec-failed');
const log = (infoA.log ?? []).find((l) =>
`41${l.address}`.toLowerCase() ===
TronWeb.address.toHex(USDT).toLowerCase()
&& l.topics?.[0] === TRANSFER_TOPIC,
);
if (!log) throw new Error('no-transfer-event');
// 3. Decode amount + recipient straight from the log topics.
const toAddr = TronWeb.address.fromHex('41' + log.topics[2].slice(-40));
const amount = BigInt('0x' + log.data);
if (toAddr !== expected.to) throw new Error('wrong-recipient');
if (amount !== expected.amountUnits) throw new Error('wrong-amount');
// 4. Block must be solidified deeply enough.
const solid = await primary.trx.getCurrentBlock();
// solidifiedBlockNumber lives in block_header.raw_data.number on
// /walletsolidity endpoints; we wrap it in our RPC client.
const solidifiedHeight =
await (primary as any).rpc.solidifiedHeight();
if (infoA.blockNumber > solidifiedHeight - MIN_SOLID_GAP) {
throw new Error('not-solidified');
}
// 5. Re-fetch by hash *from the solidified node* and confirm the
// block hash for the tx's height matches what we just verified.
const solidTx = await primary.trx.getTransactionFromBlock(
infoA.blockNumber, undefined,
);
if (!solidTx.find((t: any) => t.txID === txId)) {
throw new Error('reorg-vanish');
}
return { ok: true, blockNumber: infoA.blockNumber };
}The order matters. Steps 1 and 2 catch the cheap forgery and ABI spoof attempts in tens of milliseconds, before you've spent any budget on the more expensive solidified-chain queries. Steps 4 and 5 are the ones that defeat reorgs.
Confirmation policy: what we actually use in production#
Different ticket sizes warrant different waits. Solidification gives you cryptoeconomic finality, but waiting that long for a $2 in-game purchase is a UX disaster. Our defaults:
| Ticket size (USDT) | Confirmation gate | Median time |
|---|---|---|
| < 10 | tx in block + 3 successor blocks built on top | ~9 s |
| 10 – 1,000 | block solidified (+2/3 of SRs) |
~57 s |
| 1,000 – 50,000 | solidified + 1 child solidified | ~2 min |
| > 50,000 | solidified + manual treasury sign-off | minutes+ |
We expose this to the merchant as confirmation_policy on the
payment_intent so they can opt up if they want stricter guarantees,
and we surface the gate that's currently blocking the credit on the
intent's next_check_at field.
Important nuance: the 3-successor policy for sub-$10 tickets is not safe against an adversary with SR-level coordination. It is, empirically, safe enough against the long tail of opportunistic attackers we see in practice, and the conditional expected loss is below the gas cost of being stricter. Pick your point on this curve with eyes open.
Polling vs event subscriptions#
TRON's WebSocket event API exists, but we don't trust it as the sole source of truth. The reasons:
- The event firehose is delivered before solidification. You'd be re-validating every event anyway.
- A node that drops a connection mid-event silently leaves you with gaps you can only detect by scanning blocks.
- Public WS endpoints are aggressively rate-limited.
Our deposit indexer is a block-walker, not an event subscriber.
It pulls every block on a 1.5s tick, decodes USDT transfer logs to
known merchant deposit addresses, writes a pending_deposit row
keyed on (tx_hash, log_index), and runs verifyTronUsdtDeposit
exactly once per row when the gate condition flips.
If we lag behind the chain, we lag behind safely: customers see "Pending" longer, but no double-credit can occur because the credit is gated on solidification, not on indexer wall-clock.
What we send the merchant#
Once verifyTronUsdtDeposit returns ok and the confirmation policy
is satisfied, we mark the intent succeeded and fire the webhook
described in Bulletproof
webhooks.
The payload includes the fields a fraud or accounting reviewer
actually needs:
{
"type": "payment_intent.succeeded",
"data": {
"id": "pi_01HZ...",
"amount": "200000000",
"currency": "USDT",
"chain": "tron",
"tx_hash": "0x9e4a...",
"block_number": 71234567,
"solidified_at": "2026-04-27T05:43:11Z",
"confirmation_gate": "solid_plus_1",
"indexer_sources": ["trongrid", "self_node"]
}
}Two of those fields are intentionally rare in the industry:
confirmation_gate lets the merchant audit which policy actually
fired, and indexer_sources lets them prove to a downstream
reviewer that the credit was cross-validated against more than one
RPC. Both have saved us in chargeback disputes.
What we still don't try to defend against#
- Stake-based long reorgs. A coordinated attack by a 2/3 super-majority of TRON SRs could in principle rewrite hours of history. No payment processor can defend against that cryptoeconomically; the only response is the same one card networks have when a major issuer collapses — pause settlement, freeze in-flight credits, wait it out. Our runbook calls it.
- Front-running of energy-priced contract calls by parties who control SR scheduling. We mitigate by never sharing a deposit address with a hot wallet that's also paying energy for outbound txs (see auto-sweep patterns).
- Wallet-app spoofing of the recipient address. Merchants pasting the wrong address from a malicious clipboard helper isn't a chain-level problem; we surface a checksummed preview-then-confirm step in the dashboard but cannot enforce it client-side.
Key takeaways#
- TRON's
confirmed: trueis not finality. Always gate USDT credits on solidification (+2/3SR confirmation), and tier the confirmation depth by ticket size. - Pin the USDT contract address. ABI-collision spoofs are real and cheap to deploy at vanity-prefixed addresses.
- Cross-validate every receipt against at least two independent RPCs. Hostile-RPC receipt forgery is the most common attack vector against TRON merchants, not chain-level reorgs.
- Use a block-walker indexer, not a WebSocket event subscriber, as your source of truth. Lag is recoverable; missed-event gaps aren't.
- Decode
Transfer(from, to, value)from the receipt log itself, not from the transaction'sdatafield — the receipt is what the chain actually executed. - Surface
confirmation_gateandindexer_sourcesto merchants in the webhook payload. Auditability is a feature.