Counting USDT freezes on Tron with 70 lines of Node.js
This week Tron, Tether and Chainalysis announced the Digital Crime Task Force, a group that will use Chainalysis data to spot illicit USDT on Tron and help police freeze it before it moves (Decrypt, Cointelegraph). It re
This week Tron, Tether and Chainalysis announced the Digital Crime Task Force, a group that will use Chainalysis data to spot illicit USDT on Tron and help police freeze it before it moves (Decrypt, Cointelegraph). It replaces the T3 Financial Crime Unit that Tron and Tether formed with TRM Labs in 2024, which Bloomberg reported had disbanded in late August.
The news coverage talks about freezes as if they were a legal process. Under the hood the process ends in a contract call, and contract calls leave events. So instead of taking anyone's totals on trust, I wanted to count them.
Where the freeze lives
USDT on Tron is the TRC20 contract TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. Like the Ethereum version, it carries a blacklist with three events that matter here:
-
AddedBlackList(address _user)when the issuer freezes an address -
RemovedBlackList(address _user)when it lifts one -
DestroyedBlackFunds(address _blackListedUser, uint _balance)when it burns the balance of a frozen address
It also exposes a public read, isBlackListed(address), that returns true or false. Reading it costs nothing because it runs as a constant call.
The script
No dependencies, Node 18 or newer. It pages through TronGrid's public event API for the last 30 days, backs off when the free tier answers 429, and optionally checks one address.
// freeze-watch.mjs: how often does Tether freeze USDT on Tron, and is a given address frozen?
// Node 18+, no dependencies, public TronGrid API (no key needed for light use).
// node freeze-watch.mjs -> blacklist activity on the USDT TRC20 contract, last 30 days
// node freeze-watch.mjs <T... address> -> also checks that address with isBlackListed(address)
const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const API = 'https://api.trongrid.io';
const DAYS = 30;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function events(name, since) {
const out = [];
let url = `${API}/v1/contracts/${USDT}/events?event_name=${name}&min_block_timestamp=${since}&limit=200&order_by=block_timestamp,desc`;
while (url) {
let r;
for (let tries = 0; ; tries++) {
r = await fetch(url);
if (r.status !== 429 || tries === 5) break;
await sleep(2000 * (tries + 1)); // free tier: back off and retry
}
if (!r.ok) throw new Error(`${name}: HTTP ${r.status}`);
const j = await r.json();
out.push(...j.data);
url = j.meta?.links?.next || null;
await sleep(1200); // stay polite with the free tier
}
return out;
}
async function isBlackListed(addr) {
const r = await fetch(`${API}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ owner_address: USDT, contract_address: USDT, function_selector: 'isBlackListed(address)', parameter: toWord(addr), visible: true }),
});
const j = await r.json();
const hex = j.constant_result?.[0];
if (!hex) throw new Error('no result: ' + JSON.stringify(j.result || j));
return BigInt('0x' + hex) === 1n;
}
// base58check T-address -> 32-byte ABI word (drop the 0x41 prefix, left-pad the 20 bytes)
function toWord(addr) {
const A = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let n = 0n;
for (const c of addr) {
const i = A.indexOf(c);
if (i < 0) throw new Error('not a base58 address');
n = n * 58n + BigInt(i);
}
const hex = n.toString(16).padStart(50, '0'); // 25 bytes: 0x41 + 20 bytes + 4-byte checksum
if (!hex.startsWith('41')) throw new Error('not a Tron mainnet address');
return hex.slice(2, 42).padStart(64, '0');
}
const since = Date.now() - DAYS * 864e5;
const added = await events('AddedBlackList', since);
const removed = await events('RemovedBlackList', since);
const destroyed = await events('DestroyedBlackFunds', since);
const day = (e) => new Date(e.block_timestamp).toISOString().slice(0, 10);
const perDay = {};
for (const e of added) perDay[day(e)] = (perDay[day(e)] || 0) + 1;
const burned = destroyed.reduce((s, e) => s + Number(e.result._balance || e.result[1] || 0), 0) / 1e6;
console.log(`USDT on Tron (${USDT}), last ${DAYS} days, as of ${new Date().toISOString().slice(0, 16)} UTC`);
console.log(` addresses added to the blacklist: ${added.length}`);
console.log(` addresses removed from it: ${removed.length}`);
console.log(` DestroyedBlackFunds calls: ${destroyed.length} (${burned.toLocaleString('en-US')} USDT burned)`);
const busiest = Object.entries(perDay).sort((a, b) => b[1] - a[1]).slice(0, 3);
console.log(` busiest days: ${busiest.map(([d, n]) => `${d} (${n})`).join(', ')}`);
const target = process.argv[2];
if (target) console.log(`\n${target} is ${(await isBlackListed(target)) ? 'FROZEN' : 'not frozen'} by the USDT contract`);
A few details that took a second look:
- TronGrid paginates with a
meta.links.nextURL, so the loop just follows it until it's gone. -
triggerconstantcontractwants the ABI-encoded argument, not the base58 address. A Tron address is base58check over0x41+ 20 bytes + a 4-byte checksum, so the script decodes it, drops the0x41and pads the 20 bytes to a 32-byte word. -
_balanceis in USDT's 6 decimals.
What it printed
I ran it on November 13, 2026, with a freshly generated address that has never held anything:
USDT on Tron (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t), last 30 days, as of 2026-11-13T00:10 UTC
addresses added to the blacklist: 385
addresses removed from it: 26
DestroyedBlackFunds calls: 41 (37,992,921.092 USDT burned)
busiest days: 2026-10-14 (65), 2026-10-30 (63), 2026-10-28 (36)
TVZ8ST7Ue57QnTWCNPcBW5VL51jhDu714H is not frozen by the USDT contract
October 30 was the day after the $6.3 billion Binance hack. Cointelegraph, citing Crystal Intelligence, reports that the attackers swapped about $234 million of USDT into TRX before Tether froze what was left in two linked wallets. Tether's CEO now puts total USDT freezes at more than $7 billion.
Where this is useful
If you run anything that pays out USDT on Tron, the isBlackListed check is cheap enough to run before every send. A frozen destination means the funds get stuck at an address that can't move them.
The event counts also make a decent sanity check for news. When a report says the issuer froze funds after an incident, the contract events show the day and the size.
Two caveats. TronGrid's public tier is rate-limited, so for anything regular use an API key or your own node. And each network's USDT is a separate contract with its own list, so a check on Tron says nothing about the same holder on Ethereum.
Sources: Decrypt (Nov 2026); Cointelegraph (Nov 2026); The Block (Nov 12, 2026); TronGrid public API.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.