When we added crypto top-ups to VerifDoc, many customers in South Asia asked for one thing: USDT on Tron (TRC-20). It's what they hold on exchanges, and network fees are a few cents. Here's how we accept it without a payment processor, and the checks that stop us from crediting money that never arrived.
The shape of it
- We keep a small pool of Tron wallets. A customer starts a deposit, gets a wallet and an amount, and sends USDT.
- A scanner asks TronGrid for TRC-20 transfers into that wallet, then confirms each candidate on a solidity (confirmed-block) node before crediting.
// Candidates: TRC-20 transfers into the wallet since the deposit started.
const url = `/v1/accounts/${wallet}/transactions/trc20?only_to=true&min_timestamp=${since}&contract_address=${USDT}&limit=50`;
const { data } = await tronGet<{ data: Trc20Transfer[] }>(url);
const candidates = data.filter((t) => t.to === wallet && t.type === "Transfer");
Check 1: the token is really USDT
Anyone can deploy a token named "Tether USD" with the symbol USDT and send it to you. Never match on the symbol; match the contract address (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t for USDT on Tron mainnet), both in the list query and again in the receipt.
Check 2: confirm on the solidity node, from the receipt
The transfers list is an index and convenient, but it isn't proof. For every candidate we fetch the transaction info from the solidity node, which only knows confirmed blocks:
const info = await tronPost<TxInfo>("/walletsolidity/gettransactioninfobyid", { value: txid });
const ok =
info.id === txid &&
info.receipt?.result === "SUCCESS" &&
info.log?.some(
(l) =>
l.address === usdtHex && // emitted by the real USDT contract
l.topics[0] === TRANSFER_TOPIC && // Transfer(address,address,uint256)
l.topics[2].endsWith(walletHex) && // to our wallet
BigInt("0x" + l.data) === expectedUnits, // the amount, in 6-decimal units
);
A failed transaction can still show up in some indexes. The receipt.result === "SUCCESS" check, plus reading the actual Transfer event, is what makes it real.
Check 3: validate addresses properly
Tron addresses are base58check: a 0x41 prefix, 20 bytes, and a 4-byte double-SHA256 checksum. We decode and verify the checksum before saving a wallet. A single wrong character gives an invalid checksum instead of a silently wrong address.
Check 4: never credit twice
- Each transfer can only ever be recorded once: a unique key on network + transaction id + log index in the database, so a re-scan or a crash mid-credit can't double it.
- The scanner keeps a cursor per wallet: it re-reads a small overlap window (a few minutes) each run, so transfers that land between runs aren't missed.
- One open deposit per wallet at a time, so an incoming transfer has exactly one deposit it can belong to.
Testing it
- We ran the whole flow (scan, match, confirm, credit) locally against a real mainnet transfer, with a throwaway database.
- We ran it twice to prove the second pass credits nothing.
- The first real customer deposit arrived the day after we shipped it.
If you accept stablecoins yourself: match contracts, not symbols; trust receipts, not indexes; and make the credit idempotent.
We run VerifDoc (https://verifdoc.net/?utm_source=dev&utm_medium=article&utm_campaign=tron-usdt): second phone numbers in the browser, 5¢ email addresses and more, paid from one prepaid balance in USDT or USDC.
Top comments (1)
Dev-verifytr.ee/dev-to