DEV Community

gasrent
gasrent

Posted on

Stop Burning TRX on USDT Transfers: Estimate Energy with TronWeb and Rent It via API

If you have ever shipped a payout bot or a withdrawal service on TRON, you have probably seen one of these:

  • a USDT transfer that fails with OUT_OF_ENERGY and still eats TRX for the attempt, or
  • a transfer that succeeds but quietly burns TRX from the sender because the account had no energy.

Neither one is a bug in your code. That's how TRON's resource model works. The good news is that both are predictable. You can measure how much energy a transfer needs before you send it, and you can make sure the energy is already on the sending address. This post covers both, with TronWeb v6 and plain fetch.

How TRON charges for a USDT transfer

Every transaction on TRON consumes two resources:

  • Bandwidth pays for the transaction's size in bytes. Every account gets a small free daily bandwidth allowance. Once that runs out, bandwidth is paid by burning TRX.
  • Energy pays for smart-contract execution. USDT (TRC20) is a smart contract, so every transfer costs energy. Without staked or delegated energy, the network burns TRX from the caller to cover it. If you set the feeLimit too low, the call fails with OUT_OF_ENERGY.

How much energy a USDT transfer needs depends mostly on the recipient:

Recipient state Approx. energy
Already holds (or has held) USDT ~65,000
Has never held USDT ~131,000

The difference comes from contract storage. Writing a balance slot that doesn't exist yet costs much more than updating one that does. So "how much energy do I need?" has no single answer. You have to check each recipient.

Step 1: Estimate energy before sending (TronWeb v6)

TronWeb v6 uses a named export and an options object. triggerConstantContract dry-runs the call on a node and reports energy_used without broadcasting anything.

// npm i tronweb
import { TronWeb } from 'tronweb';

const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; // USDT TRC20 on mainnet

const tronWeb = new TronWeb({
  fullHost: 'https://api.trongrid.io',
  headers: { 'TRON-PRO-API-KEY': process.env.TRONGRID_KEY },
  privateKey: process.env.SENDER_PK,
});

const sender = tronWeb.defaultAddress.base58;

async function estimateUsdtTransferEnergy(to, amountUnits) {
  const { result, energy_used } =
    await tronWeb.transactionBuilder.triggerConstantContract(
      USDT,
      'transfer(address,uint256)',
      {},
      [
        { type: 'address', value: to },
        { type: 'uint256', value: amountUnits.toString() },
      ],
      sender
    );

  if (!result?.result) throw new Error('Simulation failed');
  return energy_used;
}

async function recipientUsdtBalance(to) {
  const usdt = await tronWeb.contract().at(USDT);
  const bal = await usdt.balanceOf(to).call();
  return BigInt(bal.toString());
}

const to = 'TRecipientAddressHere...';
const amount = 25_000_000n; // 25 USDT (6 decimals)

const used = await estimateUsdtTransferEnergy(to, amount);
const bal = await recipientUsdtBalance(to);
console.log({ energy_used: used, recipientHasUsdt: bal > 0n });
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The simulation runs from the sender you pass, so that address must actually hold enough USDT. Otherwise the call reverts.
  • A zero balance doesn't always mean "never held USDT", because an address that emptied its balance still has its storage slot. Treat balanceOf as a quick hint and energy_used as the number to trust. Add a small safety margin on top.

Step 2: Rent the energy instead of burning TRX

Staking TRX for energy only makes sense at steady, high volume. For everything else, renting is usually cheaper: a provider delegates energy to your address for a fixed period. Prices vary between providers and through the day, so comparing them by hand gets tedious.

gasrent.click is an aggregator that does that comparison: you request an amount and duration, and it fills the order from the lowest offer it finds. It works through a plain REST API (no SDK required). The relevant facts from the API docs:

  • Bodies are JSON. Auth uses the X-API-KEY header, and keys are issued in the account cabinet. You need an account and a prepaid balance (top-ups in TRX or USDT) to order.
  • Energy volume ranges from 65,000 to 4,000,000. Durations are 15m, 1h, 1d, 3d.
  • Errors come back as {"error_code":"..."} with a non-200 status.

2a. Check health and public prices (no key)

const BASE = 'https://gasrent.click/api/v1';

const health = await fetch(`${BASE}/health`).then(r => r.json());
// { ok: true }

const prices = await fetch(`${BASE}/prices`).then(r => r.json());
// prices.energy.min_volume, prices.energy.max_volume,
// prices.energy.tiers[] -> { duration, duration_sec, volume, price_trx, price_sun }
// prices.bandwidth.tiers[] -> same shape
Enter fullscreen mode Exit fullscreen mode

/prices is public, so it's handy for dashboards or for choosing a duration before you authenticate.

2b. Get a quote

const headers = {
  'Content-Type': 'application/json',
  'X-API-KEY': process.env.GASRENT_KEY,
};

async function api(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error_code}`);
  return body;
}

// Round the estimate up and respect the 65,000 minimum
const volume = Math.max(65_000, Math.ceil((used * 1.05) / 1000) * 1000);

const quote = await api('/quote', {
  method: 'POST',
  body: JSON.stringify({
    volume,
    duration: '1h',
    address: sender,
    no_activation: true,
  }),
});
// { volume, duration, price_trx, price_sun, activation }
Enter fullscreen mode Exit fullscreen mode

Integers must be JSON numbers. The docs say stringified numbers are rejected. With the default exact_volume: false / exact_duration: false, the quote may return a larger volume or longer duration when that fill is cheaper. It never returns a smaller or shorter one. no_activation: true makes the API return 400 inactive_address for an unactivated address instead of quoting something else.

2c. Create the order (idempotently)

The quote doesn't charge anything. POST /api/v1/order does. Always send an idempotency key, so that if the connection drops you can retry without paying twice:

import { randomUUID } from 'node:crypto';

const idempotencyKey = `payout-${randomUUID()}`; // 8–128 chars; persist it with your job

const order = await api('/order', {
  method: 'POST',
  body: JSON.stringify({
    address: sender,
    volume,
    duration: '1h',
    no_activation: true,
    idempotency_key: idempotencyKey,
  }),
});
// { order_id, idempotency_key, status: 'Pending', address, volume,
//   duration, price_trx, price_sun, activation, txid, created_at }
Enter fullscreen mode Exit fullscreen mode

The key can go in the idempotency_key field or the X-Idempotency-Key header. If you set both, they must match. A repeat with the same key and the same body returns the original result. A repeat with the same key and a different body returns conflict.

2d. Wait for Filled: poll or webhook

An order's status is Pending, Filled, or Cancelled. The simplest approach is polling:

async function waitForOrder(orderId, timeoutMs = 120_000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const o = await api(`/order/${orderId}`);
    if (o.status === 'Filled') return o;
    if (o.status === 'Cancelled') throw new Error('Order cancelled');
    await new Promise(r => setTimeout(r, 3000));
  }
  throw new Error('Timed out waiting for energy');
}

const filled = await waitForOrder(order.order_id);
console.log('Delegation tx:', filled.txid);
Enter fullscreen mode Exit fullscreen mode

For a service, webhooks work better. Register an https URL and a secret (at least 16 characters) with POST /api/v1/webhook or in the cabinet. gasrent then POSTs events such as order.filled and order.cancelled with the fields event, order_id, idempotency_key, status, price_trx, txid. Each delivery is signed: the X-Signature header holds the hex HMAC-SHA256 of the raw body bytes, keyed with your secret. Verify against the raw bytes, not re-serialized JSON:

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

app.post('/gasrent', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = createHmac('sha256', process.env.GASRENT_WEBHOOK_SECRET)
    .update(req.body) // Buffer of the raw body
    .digest('hex');
  const got = String(req.get('X-Signature') || '');

  const ok =
    got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const evt = JSON.parse(req.body.toString('utf8'));
  if (evt.event === 'order.filled') {
    // look up your job by evt.idempotency_key or evt.order_id, then send USDT
  }
  res.status(200).end();
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Any 2xx response completes a delivery. Anything else is retried, up to five attempts in total, so keep the handler idempotent too. The idempotency_key in the payload is what lets you map an event back to the job that created the order.

2e. Send the USDT

Once the order is Filled, the energy sits on the sender address and the transfer uses it instead of burning TRX:

const usdt = await tronWeb.contract().at(USDT);
const txid = await usdt.transfer(to, amount.toString()).send({
  feeLimit: 30_000_000, // ceiling in sun; tune for your app
});
console.log('USDT sent:', txid);
Enter fullscreen mode Exit fullscreen mode

Keep a sensible feeLimit anyway. If the estimate was off, it caps what can be burned instead of letting the call fail.

Tips from running this in production

Pick the duration by how many transfers you'll do. 15m or 1h fits a one-off transfer or a short burst. If you batch payouts several times a day, one larger order for 1d or 3d (up to 4,000,000 energy) is often simpler than many small ones. Check /prices to see how the tiers compare right now, because prices change during the day.

Size from the simulation, not from a constant. Run triggerConstantContract per recipient and sum the results for a batch. Mixed batches of 65k and 131k recipients are where fixed constants waste the most money.

Don't forget bandwidth. A USDT transfer also uses a few hundred bytes of bandwidth. A hot wallet that sends many transfers a day burns through the free allowance quickly. gasrent rents bandwidth through the same key and balance (POST /api/v1/bandwidth/quote, POST /api/v1/bandwidth, durations 5m, 1h, 1d, 3d, 7d). Bandwidth orders are separate from energy orders, and their webhooks are bandwidth.filled / bandwidth.cancelled.

Lock down your key. The cabinet supports an IP whitelist and read-only keys. A dashboard that only reads balances and orders doesn't need a key that can spend.

Use GET /api/v1/balance as your key check. /health ignores the key entirely, so it can't tell you whether your key is valid.

Wrap-up

Energy on TRON is predictable once you measure it: simulate the transfer, check the recipient, rent exactly what you need for as long as you need it, then send. You get no more OUT_OF_ENERGY surprises and no unexpected TRX burn.

Disclosure: I'm part of the gasrent team. The TronWeb techniques above work regardless of where (or whether) you rent energy.

Top comments (0)