If you've built USDT payouts on TRON, you've probably hit one of two surprises. A transfer fails with OUT_OF_ENERGY even though the wallet "had TRX". Or a transfer goes through but burns twice as much TRX as the one before it.

Both come from the same place: how TRON budgets resources for a contract call, and a parameter many integrations set once and forget — fee_limit.

This post walks through what a TRC-20 transfer actually pays for, why a shortage of Energy alone doesn't make a transfer fail, and a small pre-flight routine that checks all of this before you sign.

How a USDT transfer pays for itself

A USDT transfer on TRON is a call to the USDT TRC-20 contract (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t). It consumes two resources.

Bandwidth pays for the transaction's size in bytes. A typical single-signature USDT transfer is around 345–350 bytes. Bandwidth is all-or-nothing: the sender's staked or delegated Bandwidth must cover the full size; if it can't, the account's free daily quota (currently 600) is tried; if neither covers the whole transaction, TRX is burned for the entire size at the current rate of 1,000 sun per byte.

Energy pays for contract execution. There is no free quota. Energy from staking or delegation is used first, and TRX is burned for the remainder at the current rate of 100 sun (0.0001 TRX) per unit.

How much Energy a transfer needs depends mostly on the recipient, not the amount:

  • Recipient already holds USDT: about 64–65K Energy.
  • Recipient's USDT balance is zero: about 130K Energy.

The difference comes from contract storage. Updating an existing non-zero slot is cheap; setting a zero slot to a non-zero value is expensive. On top of that, the Dynamic Energy Model can raise the cost of heavily used contracts, and USDT is one of the most heavily used contracts on the network. So hardcoding "65,000" is a bug waiting to happen. Simulate instead (Step 3).

Not enough Energy ≠ OUT_OF_ENERGY

A wallet with zero Energy can still send USDT. The network simply burns TRX to cover the Energy shortfall. The transfer fails only when the caller can't cover the required Energy at all.

TRON computes the caller's usable Energy as:

callerEnergyLimit = min( stakedEnergy + (balance - callValue) / energyPrice, fee_limit / energyPrice ) 

If execution needs more than this, it fails with OUT_OF_ENERGY. In practice that happens in three situations:

  1. Resources plus burnable TRX are too small. Example: 0 Energy and a 6 TRX balance at 100 sun per unit gives at most 60,000 Energy. A transfer that needs ~64K fails.
  2. fee_limit is too low. The fee_limit cap applies even to staked or delegated Energy. A wallet with plenty of Energy and a fee_limit of 5 TRX can only spend 50,000 Energy on the call.
  3. fee_limit was set in TRX instead of sun. fee_limit = 100 means 100 sun, not 100 TRX, and fails immediately.

A failed call isn't free. Energy spent on a failed execution is not refunded, burned TRX isn't returned, and OUT_OF_ENERGY exhausts the whole Energy allowance of the transaction. In case 1 above, the wallet loses its 6 TRX and the USDT stays where it was.

That's why the checks belong before signing.

Before you copy the code

The snippets below follow the TronWeb 6 and TRON HTTP API documentation. They pass a syntax check and unit tests against TronWeb 6.5.2 with stubbed node responses, but they haven't been run against a live network. Treat them as a starting point, not production code: run them on a testnet and review the error handling before you rely on them.

One practical detail: the mainnet USDT contract doesn't exist on testnets. To try this on Nile or Shasta, deploy or use a TRC-20 token you control there and pass its address in. Expect different Energy numbers on a testnet, because the Dynamic Energy Model depends on how heavily each contract is used.

Setup

The examples use TronWeb 6. Every TronWeb call below maps to a FullNode HTTP endpoint, noted in the comments, if you'd rather call the API directly.

import { TronWeb } from 'tronweb'; const NETWORKS = { mainnet: { fullHost: 'https://api.trongrid.io', token: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT (TRC-20) on mainnet }, nile: { fullHost: 'https://nile.trongrid.io', token: process.env.TEST_TOKEN, // a TRC-20 you control on Nile }, }; const net = NETWORKS[process.env.TRON_NETWORK ?? 'nile']; if (!net?.token) throw new Error('Token contract address is not configured'); const tronWeb = new TronWeb({ fullHost: net.fullHost, headers: process.env.TRONGRID_API_KEY ? { 'TRON-PRO-API-KEY': process.env.TRONGRID_API_KEY } : {}, privateKey: process.env.PRIVATE_KEY, // only needed for the final send step }); // Fail loudly instead of silently computing with a missing value. function requirePositive(value, name) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw new Error(`${name} is missing or invalid: ${value}`); } return value; } 

The requirePositive helper matters. A missing price or Energy estimate that quietly turns into 0 produces a fee_limit of 0 and a "nothing to burn" verdict — exactly the failure this routine is meant to prevent.

Step 1: Read prices from the chain

Both resource prices are chain parameters that can change by governance vote. Read them instead of hardcoding, and stop if any of them is missing.

// GET /wallet/getchainparameters async function getPrices() { const params = await tronWeb.trx.getChainParameters(); const get = (key) => requirePositive( params.find((p) => p.key === key)?.value, `chain parameter ${key}`, ); return { energyPrice: get('getEnergyFee'), // sun per Energy bandwidthPrice: get('getTransactionFee'), // sun per byte maxFeeLimit: get('getMaxFeeLimit'), // sun }; } 

Step 2: Read the sender's resources

getaccountresource returns limits and usage, not available amounts. Here, a missing field is different from a missing chain parameter: the API documentation says fields with a value of 0 may be omitted from the JSON response and should be treated as 0. So defaulting is correct for these fields only.

// POST /wallet/getaccountresource and /wallet/getaccount async function getSenderState(address) { const r = await tronWeb.trx.getAccountResources(address); // Protobuf omits zero values; per the API docs, absent = 0. const n = (k) => r[k] ?? 0; // Same full-node state as getaccountresource; getBalance() would read // the solidified (slightly older) state instead. const balance = await tronWeb.trx.getUnconfirmedBalance(address); // sun if (typeof balance !== 'number') throw new Error('Balance unavailable'); return { energy: Math.max(0, n('EnergyLimit') - n('EnergyUsed')), stakedBandwidth: Math.max(0, n('NetLimit') - n('NetUsed')), freeBandwidth: Math.max(0, n('freeNetLimit') - n('freeNetUsed')), balance, }; } 

EnergyLimit includes Energy delegated to the address by other accounts, so delegated or rented Energy shows up here once it lands. Both resources recover gradually over a rolling 24-hour window, so read them right before sending, not once per batch.

Step 3: Simulate the transfer

triggerconstantcontract runs the call locally on the node without creating an on-chain transaction. It returns the Energy the call would consume, including the current dynamic penalty, and it automatically reflects the 64K vs 130K difference for this specific recipient.

The tricky part is reading the result. result.result only says the node executed the call, not that the call succeeded. When the contract reverts, the node can still return result.result: true, put REVERT opcode executed in result.message and mark transaction.ret[0].ret as FAILED. A reverted simulation also reports a low energy_used, which is useless for sizing fee_limit. So check all three.

// POST /wallet/triggerconstantcontract function assertSimulationSucceeded(sim) { const raw = sim?.result?.message ?? ''; // message is hex unless the request used visible: true const message = /^([0-9a-f]{2})+$/i.test(raw) ? tronWeb.toUtf8(raw) : raw; const ret = sim?.transaction?.ret?.[0]?.ret; if (sim?.result?.result !== true || message || ret === 'FAILED') { throw new Error(`Simulation did not succeed: ${message || ret || 'unknown'}`); } } async function estimateTransfer(from, to, amount) { const sim = await tronWeb.transactionBuilder.triggerConstantContract( net.token, 'transfer(address,uint256)', {}, [ { type: 'address', value: to }, { type: 'uint256', value: amount }, // USDT has 6 decimals: 10 USDT = 10_000_000 ], from, ); assertSimulationSucceeded(sim); return { energy: requirePositive(sim.energy_used, 'energy_used'), penalty: sim.energy_penalty ?? 0, // informational; already included in energy_used }; } 

TronWeb 6 throws on its own when the node returns result.message, for example a revert with a message. The explicit check covers what it doesn't: a FAILED result without a message and a missing or zero energy_used. Two more notes. The simulation runs against current state, so from must actually hold the tokens, or the call reverts. And wallet/estimateenergy exists too, with the same interface and a slightly more accurate estimate for some edge-case contracts, but it's optional on nodes and may be disabled. Default to triggerconstantcontract.

Step 4: Decide before you sign

Now you have everything needed to answer three questions: will it succeed, what will it burn, and what fee_limit to set.

const TX_BYTES = 350; // assumption: typical single-sig USDT transfer; verify via net_usage const BUFFER = 1.1; // estimates drift: state changes, new maintenance period function plan({ need, state, prices }) { const needed = Math.ceil(need.energy * BUFFER); const energyShortfall = Math.max(0, needed - state.energy); const energyBurn = energyShortfall * prices.energyPrice; const bandwidthCovered = state.stakedBandwidth >= TX_BYTES || state.freeBandwidth >= TX_BYTES; const bandwidthBurn = bandwidthCovered ? 0 : TX_BYTES * prices.bandwidthPrice; const totalBurn = energyBurn + bandwidthBurn; const feeLimit = needed * prices.energyPrice; // covers the whole call, not just the burn if (feeLimit > prices.maxFeeLimit) { throw new Error(`fee_limit ${feeLimit} exceeds the network maximum`); } return { feeLimit, energyShortfall, totalBurnTrx: totalBurn / 1e6, ok: state.balance >= totalBurn, }; } 

The key line is feeLimit. Because the cap applies to all Energy the caller spends, not only the burned part, it has to cover the full estimated Energy even when the wallet has staked or delegated Energy. fee_limit is a ceiling, not a payment: if the call uses less, you're charged only for what was used.

Then wire it together:

async function preflight(from, to, amount) { const [prices, state, need] = await Promise.all([ getPrices(), getSenderState(from), estimateTransfer(from, to, amount), ]); const p = plan({ need, state, prices }); if (!p.ok) { return { action: 'abort', reason: 'balance cannot cover the shortfall', ...p }; } if (p.energyShortfall > 0) { return { action: 'review', reason: `will burn ~${p.totalBurnTrx} TRX`, ...p }; } return { action: 'send', ...p }; } 

What "review" means is a product decision. Some systems accept the burn. Others pause the payout until Energy arrives.

Step 5: Send with an explicit fee_limit

When you build the actual transaction, pass the computed limit explicitly instead of relying on a default. With a contract instance, that's the feeLimit option of send():

async function send(to, amount, feeLimit) { if (!Number.isSafeInteger(feeLimit) || feeLimit <= 0) { throw new Error(`fee_limit must be a positive integer in sun: ${feeLimit}`); } const token = await tronWeb.contract().at(net.token); return token.transfer(to, amount).send({ feeLimit }); // returns the txid } 

If you build transactions with transactionBuilder.triggerSmartContract (or wallet/triggersmartcontract) instead, set feeLimit / fee_limit there and check raw_data.fee_limit in the built transaction before signing. According to TRON's FeeLimit guide, TronWeb's default feeLimit is 150 TRX — enough for a USDT transfer, but it hides sizing mistakes in other calls.

Step 6: Read the receipt

After broadcast, the execution receipt tells you what really happened. In TronWeb 6, getTransactionInfo reads the solidified receipt, so it returns an empty object until the block is confirmed, usually within about a minute. getUnconfirmedTransactionInfo answers sooner, but that result can still change.

// POST /walletsolidity/gettransactioninfobyid async function readReceipt(txid) { const info = await tronWeb.trx.getTransactionInfo(txid); if (!info?.id) return { status: 'pending' }; // not executed yet; poll again later const r = info.receipt; if (!r?.result) { throw new Error(`Receipt for ${txid} has no execution result`); } // Resource fields are protobuf numbers: an omitted field means 0. const n = (k) => r[k] ?? 0; return { status: r.result === 'SUCCESS' ? 'success' : 'failed', result: r.result, // e.g. SUCCESS, REVERT, OUT_OF_ENERGY message: info.resMessage ? tronWeb.toUtf8(info.resMessage) : undefined, energyFromResources: n('energy_usage'), energyBurnedSun: n('energy_fee'), energyTotal: n('energy_usage_total'), bandwidthUsed: n('net_usage'), bandwidthBurnedSun: n('net_fee'), }; } 

A pending transaction and a receipt without a result are treated differently on purpose: the first is normal and worth polling, the second is unexpected and should stop your pipeline. OUT_OF_ENERGY in result means your pre-flight numbers were wrong or the state changed between simulation and execution. Logging energyTotal against your estimate also gives you a real dataset to tune the buffer, instead of guessing.

Where the Energy can come from

If the pre-flight keeps returning "review", the wallet needs a steady Energy source. The options, briefly:

  • Stake TRX for Energy (Stake 2.0). The account gets a share of the network Energy pool proportional to its stake. The TRX stays locked, and unstaking has a waiting period (getUnfreezeDelayDays).
  • Delegate from your own staking account. Common for exchanges and payment services: one account stakes, and hot wallets receive delegated Energy.
  • Rent delegated Energy from a third-party provider. No TRX locked; the Energy arrives as a delegation and shows up in EnergyLimit. Confirm it has landed before sending.
  • Burn TRX. No setup, highest cost per transfer.

Whichever you choose, the pre-flight logic stays the same: read resources, simulate, size fee_limit, check the receipt.

Checklist

  • Read getEnergyFee and getTransactionFee from the chain; don't hardcode 100 and 1,000.
  • Compute available Energy as EnergyLimit - EnergyUsed, defaulting missing fields to 0.
  • Simulate each transfer; don't assume 65K.
  • Stop with an error if a chain parameter or the Energy estimate is missing; never default them to 0.
  • Treat a simulation as successful only if there's no revert message and transaction.ret isn't FAILED.
  • Set fee_limit to cover the full estimated Energy, in sun.
  • Make sure the TRX balance covers any Energy and Bandwidth shortfall.
  • Poll until the receipt exists, alert on receipt.result, and compare actual Energy with your estimate.

References

Disclosure: I work at FeeSaver, a TRON Energy and Bandwidth rental service. This article was written with AI assistance. The facts and code were checked against the TRON and TronWeb documentation, and the code passed unit tests with stubbed node responses. It has not been run against a live network, so test it before use.