On 25 October 2026 the Indian exchange CoinDCX published an update on 15.99 million USDT that it lost on TRON. The wallet involved belonged to old operational infrastructure that had been exposed in a 2025 incident and deprecated. Users kept sending deposits to it anyway, and the exchange kept crediting them. In its own words, "our automated sweeping service had failed for a particular period, causing deposits to accumulate in the deprecated address" (CoinDCX). The company absorbed the loss.
Most guides to accepting stablecoins end when the webhook arrives. That incident shows where the expensive part sits: in the work after the deposit is seen. This post walks through the full path for USDT on TRON and Ethereum: deposit addresses, detection, confirmations, the internal ledger, sweeping and daily reconciliation. The code is TypeScript and deliberately minimal; treat it as a sketch of the moving parts, not a library.
One token, two networks: what actually differs
"USDT" is not one thing your code can look up by name. Tether lists separate contracts per network: 0xdac17f958d2ee523a2206206994597c13d831ec7 on Ethereum and TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t on TRON (Tether). Anyone can deploy a token called "Tether USD" with the symbol USDT, so the contract address is the only identifier you should trust.
Three practical differences follow:
- Address formats differ. TRON addresses are Base58 and start with T; Ethereum addresses are 0x hex. The same 0x address is also valid on every other EVM network, so a customer can send USDT to your address on a network you do not watch. You still control the key, so the funds can be recovered, but only manually.
- Both networks expose transfers as a Transfer event. The ERC-20 standard says it "MUST trigger when tokens are transferred, including zero value transfers" (EIP-20). Zero-value and dust transfers to your addresses are normal noise; do not let them create payments.
- Fees are paid differently. Ethereum charges gas in ETH. TRON charges in Energy and Bandwidth, which you can stake for, receive by delegation, or pay for by burning TRX. This matters most when you move funds out of deposit addresses.
Start with a registry keyed by network and contract, never by symbol:
type Network = "tron" | "ethereum";
interface TokenConfig {
network: Network;
contract: string;
decimals: number;
}
const ACCEPTED: TokenConfig[] = [
{ network: "tron", contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6 },
{ network: "ethereum", contract: "0xdac17f958d2ee523a2206206994597c13d831ec7", decimals: 6 },
];
// Ethereum addresses are case-insensitive hex; TRON Base58 is case-sensitive
const same = (n: Network, a: string, b: string) => (n === "ethereum" ? a.toLowerCase() === b.toLowerCase() : a === b);
function findToken(network: Network, contract: string): TokenConfig | undefined {
return ACCEPTED.find((t) => t.network === network && same(network, t.contract, contract));
}
Deposit addresses: per invoice or per customer
There are two common models. A permanent address per customer is simple for users and works well for top-ups. A temporary address per invoice ties every transfer to one order and makes underpayments and late payments easier to resolve. Many systems use a pool of temporary addresses and reuse them only after a cool-down period.
Either way, derive addresses from an HD wallet so that one backed-up seed covers all of them. The BIP-44 coin type is 60 for Ethereum and 195 for TRON (SLIP-44), so paths look like m/44'/195'/0'/0/n and m/44'/60'/0'/0/n. Keep the seed and the signing service inside your own infrastructure, separate from the application that hands out addresses.
The table that matters most is the one that records which address belongs to whom and in what state:
CREATE TABLE deposit_address (
network text NOT NULL, -- 'tron' | 'ethereum'
address text NOT NULL,
hd_index integer NOT NULL,
customer_id uuid,
invoice_id uuid,
status text NOT NULL, -- 'active' | 'cooling' | 'retired'
retired_at timestamptz,
PRIMARY KEY (network, address),
UNIQUE (network, hd_index)
);
The retired status is the CoinDCX lesson. An address you stop handing out is not an address that stops receiving money. Old invoices, saved contacts and copy-pasted addresses keep sending funds for months. Retired addresses stay in the watch list, stay in sweeping and stay in reconciliation until their balance has been zero for as long as your policy says.
Detecting deposits on TRON and Ethereum
On TRON, TronGrid has an endpoint that returns TRC-20 transfers for one account: GET /v1/accounts/{address}/transactions/trc20. It supports only_to for incoming transfers, contract_address to filter by token, min_timestamp for incremental polling, only_confirmed and pagination through fingerprint, up to 200 records per page (TRON API reference).
On Ethereum, you read Transfer logs from the USDT contract and filter by recipient. The recipient is an indexed topic, so one query can cover many of your addresses at once.
Both watchers should produce the same normalized event, so everything after detection is network-agnostic:
import { JsonRpcProvider, id, zeroPadValue, getAddress } from "ethers";
interface DepositEvent {
network: Network;
txHash: string;
logIndex: number; // position of the transfer inside the transaction
to: string;
from: string;
amount: bigint; // in token units, 6 decimals for USDT
blockNumber?: number;
timestamp?: number; // TronGrid returns it; on Ethereum read it from the block if you need it
}
const TRANSFER = id("Transfer(address,address,uint256)");
async function ethereumDeposits(rpc: JsonRpcProvider, addresses: string[], fromBlock: number, toBlock: number) {
const logs = await rpc.getLogs({
address: "0xdac17f958d2ee523a2206206994597c13d831ec7",
topics: [TRANSFER, null, addresses.map((a) => zeroPadValue(a, 32))],
fromBlock,
toBlock,
});
return logs.map((l): DepositEvent => ({
network: "ethereum",
txHash: l.transactionHash,
logIndex: l.index,
from: getAddress("0x" + l.topics[1].slice(26)),
to: getAddress("0x" + l.topics[2].slice(26)),
amount: BigInt(l.data),
blockNumber: l.blockNumber,
}));
}
async function tronDeposits(address: string, since: number, apiKey: string) {
const url = new URL(`https://api.trongrid.io/v1/accounts/${address}/transactions/trc20`);
url.search = new URLSearchParams({
only_to: "true",
contract_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
min_timestamp: String(since),
limit: "200",
}).toString();
const res = await fetch(url, { headers: { "TRON-PRO-API-KEY": apiKey } });
const body = await res.json();
return body.data.map((t: any): DepositEvent => ({
network: "tron",
txHash: t.transaction_id,
logIndex: 0, // see the note on idempotency below
from: t.from,
to: t.to,
amount: BigInt(t.value),
timestamp: t.block_timestamp,
}));
}
Two details make this production-grade rather than demo-grade. First, polling must be resumable: store the last processed block or timestamp per network and overlap the next window a little, because duplicates are cheap and gaps are not. Second, one transaction can carry several transfers to you; batch payout contracts do exactly that. Your deduplication key must include the transfer's position in the transaction, not only the hash.
Confirmations are a policy, not a number
Seeing a transfer and being sure it will stay on chain are different moments, and the two networks reach certainty at different speeds.
On TRON, 27 Super Representatives produce a block every 3 seconds. A block becomes irreversible once at least 19 distinct active SRs have produced a block at that height or above, "typically in about 1 minute" (TRON consensus docs). The same page notes that the code does not count a fixed number of confirmations.
On Ethereum, a slot is 12 seconds and an epoch is 32 slots; "it takes about 15 minutes for an Ethereum block to finalize" (ethereum.org). JSON-RPC exposes safe and finalized block tags, so you can ask the node directly instead of counting blocks yourself (JSON-RPC docs).
Model each deposit as a small state machine and keep the threshold in configuration, because it is a business decision: a small top-up and a large B2B invoice deserve different waiting times.
type DepositState = "seen" | "confirmed" | "credited" | "rejected";
async function isFinal(ev: DepositEvent, rpc: JsonRpcProvider, tronFinal: (tx: string) => Promise<boolean>) {
if (ev.network === "tron") return tronFinal(ev.txHash);
const finalized = await rpc.getBlock("finalized");
return ev.blockNumber !== undefined && finalized !== null && ev.blockNumber <= finalized.number;
}
function next(state: DepositState, final: boolean, accepted: boolean): DepositState {
if (!accepted) return "rejected"; // unknown token, dust, address not ours
if (state === "seen" && final) return "confirmed";
return state; // "confirmed" -> "credited" happens only in the ledger transaction
}
Showing "payment detected" in the interface at the seen stage is good for users. Releasing goods or balance should wait for confirmed.
Credit the ledger, not the address
The on-chain balance of a deposit address is not the customer's balance. It changes when you sweep, it can receive funds that belong to nobody you know, and on a reused address it mixes payments from different invoices. The customer's balance lives in your own ledger, and every movement is a double entry: one account goes up, another goes down, and the sum is always zero.
CREATE TABLE deposit (
network text NOT NULL,
tx_hash text NOT NULL,
log_index integer NOT NULL,
address text NOT NULL,
amount numeric NOT NULL,
state text NOT NULL,
PRIMARY KEY (network, tx_hash, log_index) -- the idempotency key
);
CREATE TABLE posting (
entry_id uuid NOT NULL,
account text NOT NULL, -- e.g. 'asset:tron:deposit-addresses', 'liability:customer:<id>:usdt'
asset text NOT NULL, -- 'USDT', 'TRX', 'ETH'
amount numeric NOT NULL -- positive = debit, negative = credit
);
-- Crediting one confirmed deposit, in one database transaction:
-- asset:tron:deposit-addresses +100 USDT
-- liability:customer:42:usdt -100 USDT
-- Sweeping it later moves funds between your own accounts:
-- asset:tron:hot-wallet +100 USDT
-- asset:tron:deposit-addresses -100 USDT
-- expense:network-fees +fee TRX
-- asset:tron:hot-wallet -fee TRX
Crediting the customer and sweeping the address are separate steps on purpose. That is also how CoinDCX describes its setup: users are credited when funds arrive, and "a secondary service then sweeps those funds". Decoupling is sound. What it needs in return is monitoring that notices when the second step stops working.
The same idempotency rule applies to the notifications you send downstream. Webhook receivers get duplicates and out-of-order deliveries; Stripe's documentation tells integrators to log processed event IDs and not to depend on event order (Stripe Docs). Give your own events stable IDs and expect your merchants to need the same advice.
Sweeping: paying for gas you don't have
A fresh deposit address holds USDT and nothing else. To move the USDT out, it needs to pay for the transaction, and that is where the two networks differ most.
On TRON, a USDT transfer is a smart contract call, so it consumes Energy, and the transaction consumes Bandwidth. If the account lacks Energy, TRX is burned for the rest (TRON resource model). Instead of sending TRX to every deposit address, you can stake TRX in one service account and delegate Energy to the address before the sweep. The staked TRX stays under your account, the recipient only uses the resource, the recipient must be a regular account rather than a contract, the minimum is 1 TRX, and a delegation without a lock can be withdrawn immediately (TRON delegation docs). One more thing to test on a test network before launch: TRON's docs describe account activation through a TRX or TRC-10 transfer, with a 1 TRX creation fee paid by the sender, and do not list TRC-20 transfers among the ways to activate an address (TRON accounts). Check what your fresh deposit addresses need before the first sweep, not during it.
On Ethereum, the simple path is two transactions: send the deposit address enough ETH for gas, then transfer the USDT. It works, but it leaves ETH dust on every address. The alternative is a forwarder contract per customer: with CREATE2 the address is known before the contract exists (EIP-1014), so you can hand it out first and deploy only when there is something to sweep. If you write such a contract, note that USDT on Ethereum does not return a boolean from transfer; wrappers like OpenZeppelin's SafeERC20 support "tokens that return no value (and instead revert or throw on failure)" (OpenZeppelin).
Whatever the mechanics, the sweeper needs three rules:
- a threshold below which sweeping costs more than it moves, so dust waits;
- a hot-wallet limit above which funds go on to cold storage;
- an alert when any address, active or retired, holds funds longer than your sweep interval.
interface AddressBalance { network: Network; address: string; usdt: bigint; oldestUnsweptAt: number; status: string }
function sweepAlerts(balances: AddressBalance[], maxAgeMs: number, now = Date.now()) {
return balances
.filter((b) => b.usdt > 0n && now - b.oldestUnsweptAt > maxAgeMs)
.map((b) => `${b.network} ${b.address} (${b.status}): ${b.usdt} units waiting ${Math.round((now - b.oldestUnsweptAt) / 60000)} min`);
}
That last check is the one that turns "the sweeper failed for a period" into an alert within minutes.
The edge cases that reach support
- Underpayment: the invoice says 100 USDT, 99.5 arrives. Decide in advance whether you accept a tolerance, ask for the difference or refund.
- Overpayment: credit the excess to the customer's balance or refund it; never silently keep it.
- Late payment: funds arrive after the invoice expired, when the price or the stock may have changed. Keep the address watched and route the case to a person.
- Several transfers for one invoice: sum them inside the invoice window.
- Wrong network: USDT sent to your 0x address on another EVM network. The key is yours, so recovery is possible, but it is manual work.
- Fake token: a transfer from a contract that is not in your registry. Record it, never credit it.
- Zero-value and dust transfers: allowed by the standard, ignored by your ledger.
- Duplicate or out-of-order webhooks: idempotency keys and state checks on the receiving side.
Reconcile every day, including retired addresses
Once a day, compare two numbers per network and asset. On one side, the on-chain balances of every address you control: active deposit addresses, cooling and retired ones, the hot wallet and cold storage. On the other side, the asset accounts in your ledger. They should match to the unit, with fees accounted for in TRX and ETH.
When they do not match, the direction of the difference tells you where to look. More on chain than in the ledger, on known deposit addresses, means a stuck sweep or a deposit nobody has credited yet. Less on chain than in the ledger means money left an address without a posting, which is a security question before it is an accounting one.
Wrapping up
A checklist for the review before launch:
- Tokens are identified by network and contract address, never by symbol.
- Addresses come from an HD wallet; keys and signing live in your own infrastructure.
- Retired addresses stay watched, swept and reconciled.
- Detection is resumable and deduplicated by transaction hash plus transfer position.
- Confirmation thresholds are configuration: TRON irreversibility, Ethereum
finalized. - The customer's balance is a ledger account, and every movement is a double entry.
- Sweeping has a dust threshold, a hot-wallet limit and an age alert.
- Edge cases have written rules before the first support ticket.
- On-chain balances and the ledger are reconciled daily.
Disclosure: we are ilink, and this is the flow we built into our White-Label Crypto Processing. The case study describes temporary wallet generation for customers, deposit tracking, off-chain balance reflection, automatic fund sweeping, service wallet aggregation and withdrawal to cold storage for USDT ERC-20 and TRC-20, deployed inside the client's own infrastructure. If you are building this yourself, the list above is the part we would review first.
Top comments (0)