If you send USDT TRC-20 from a backend, your fee estimate is probably a constant. Something like "65,000 energy per transfer". That constant is right most of the time and wrong in a way that costs exactly double.
A USDT transfer to an address that already holds USDT uses 64,285 energy. A transfer to an address that holds zero USDT uses 130,285. The amount does not matter: 1 USDT and 100,000 USDT cost the same. Only the recipient's balance does.
This post shows where the extra 66,000 energy comes from, how to detect the case from code before you sign, and how to turn energy into TRX at the current network rate.
Where the extra 66,000 comes from
The USDT contract keeps balances in a mapping from address to amount. When the recipient already has a non-zero balance, transfer updates an existing storage slot. When the balance is zero, the contract writes a fresh slot, and the TVM charges much more for a new storage write than for an update.
130,285 − 64,285 = 66,000. That difference is the price of the new slot.
Two practical consequences:
- An address whose balance dropped back to zero is "empty" again. A wallet you paid last month can cost 130K today if it was swept since.
- Splitting a payment into smaller parts never saves energy. Each part costs the full price, and only the first one to an empty address pays the 130K.
Check 1: does the recipient hold USDT?
The cheapest signal is a balanceOf call. It is a read, so it costs nothing.
const { TronWeb } = require('tronweb'); const tronWeb = new TronWeb({ fullHost: 'https://api.trongrid.io', headers: { 'TRON-PRO-API-KEY': process.env.TRONGRID_KEY }, }); const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; async function recipientIsEmpty(recipient) { tronWeb.setAddress(recipient); // any valid address works as caller for a read const usdt = await tronWeb.contract().at(USDT); const balance = await usdt.balanceOf(recipient).call(); return BigInt(balance.toString()) === 0n; } Same thing in Python with tronpy:
from tronpy import Tron from tronpy.providers import HTTPProvider USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" client = Tron(HTTPProvider(api_key="YOUR_TRONGRID_KEY")) def recipient_is_empty(recipient: str) -> bool: usdt = client.get_contract(USDT) return usdt.functions.balanceOf(recipient) == 0 If this returns true, budget 130,285 energy for the transfer, not 64,285.
Check 2: simulate the exact transfer
A balance check tells you which bucket you are in. A dry run tells you the number. triggerConstantContract executes the call on a node without broadcasting it and returns energy_used.
async function estimateTransferEnergy(from, to, amountSun) { const res = await tronWeb.transactionBuilder.triggerConstantContract( USDT, 'transfer(address,uint256)', {}, [ { type: 'address', value: to }, { type: 'uint256', value: amountSun }, ], from ); if (!res.result || !res.result.result) { throw new Error('simulation reverted: check that `from` holds the amount'); } return res.energy_used; } Two things trip people up here. The simulation only succeeds if from really holds at least amountSun USDT (6 decimals, so 10 USDT is 10_000_000). And it is an estimate on the node's current state: if someone funds the recipient between your simulation and your broadcast, the real transfer gets cheaper, never more expensive.
Check 3: convert energy into TRX
Energy has no fixed TRX price. The network sets the burn rate with the chain parameter getEnergyFee, in sun per energy unit, and it has changed by vote before. Read it instead of hard-coding it.
async function energyFeeSun() { const params = await tronWeb.trx.getChainParameters(); const p = params.find((x) => x.key === 'getEnergyFee'); return p.value; // 100 at the time of writing } async function burnCostTrx(energyNeeded, sender) { const r = await tronWeb.trx.getAccountResources(sender); const available = (r.EnergyLimit || 0) - (r.EnergyUsed || 0); const missing = Math.max(0, energyNeeded - available); return (missing * (await energyFeeSun())) / 1e6; } At 100 sun per unit the two cases come out as:
| Recipient | Energy | Burned if you have none |
|---|---|---|
| Holds USDT | 64,285 | ~6.43 TRX |
| Holds zero USDT | 130,285 | ~13.03 TRX |
Bandwidth adds roughly 0.35 TRX on top when the free daily allowance is used up. That is the small part of the bill.
Putting it together
async function planTransfer(from, to, amountSun) { const empty = await recipientIsEmpty(to); const energy = await estimateTransferEnergy(from, to, amountSun); const burn = await burnCostTrx(energy, from); return { empty, energy, burnTrx: burn }; } Use the result for three decisions:
- fee_limit. Set it from
energy * energyFeeplus a margin, not from a constant tuned for the 64K case. A fee_limit sized for 64K fails with OUT_OF_ENERGY on an empty recipient. I wrote about reading that failure in an earlier post. - Pre-funding energy. If the sender stakes or receives delegated energy, check that
availablecovers 130,285 for payouts to new users, not just 64,285. - Showing the fee to users. If you pass the network fee to the customer, an empty-recipient flag lets you quote the right number up front instead of explaining the difference afterwards.
Verify the numbers yourself
You do not have to trust the two constants. Take a few recent USDT transactions, call wallet/gettransactioninfobyid on each and read receipt.energy_usage_total. In a sample of ten consecutive transfers checked in July, seven showed exactly 64,285 and three showed exactly 130,285. The full method, with a sample transaction ID, is in the original article.
Where renting fits
If the sender has no energy, every transfer burns TRX at the network rate. Renting energy delegates it to the sender's address before the send: a 65K package for one regular transfer costs 2.26 TRX instead of about 6.5 TRX burned, and a 131K package that covers one transfer to an empty address (or two regular ones) costs 4.56 TRX instead of about 13 TRX. Delegation goes to a public address, no private keys involved, and lands in 10 to 60 seconds. That is what we built at Overtron; there is also a Telegram bot.
Whichever way you pay, the check stays the same: look at the recipient's USDT balance before you size the fee.
Originally published at overtron.io.
