Introduction
Tron processes more USDT transfers than any other blockchain. With over $50 billion in daily TRC20-USDT volume, low transaction fees, and a three-second block time, the network has become the backbone of stablecoin payments across emerging markets. If you are building a payment platform, exchange, or any system that needs to programmatically receive and route funds, Tron is a network you cannot ignore.
This tutorial walks you through building a deterministic wallet factory on Tron: a system that pre-computes deposit addresses off-chain using CREATE2, deploys lightweight receiver contracts on demand, and sweeps incoming TRC20 tokens or TRX to one or more treasury wallets. The architecture is the same pattern used in the EVM version of this system, but adapted for the quirks of the Tron Virtual Machine (TVM).
What you will build:
- A
WalletFactorycontract that deploys deterministicWalletReceiverinstances using CREATE2 - A
WalletReceivercontract that holds funds and allows authorized sweeps with BPS-based splits - An off-chain script that pre-computes Tron addresses before deployment
- A sweep script that triggers fund collection via TronWeb
Why deterministic wallets?
In a payment platform, each customer gets a unique deposit address. The naive approach is to deploy a contract for every customer upfront, which wastes energy and TRX. With CREATE2, you compute the address off-chain using a deterministic formula, give it to the customer, and only deploy the contract when funds actually arrive. The address is guaranteed to match because the formula is deterministic: same factory, same salt, same bytecode, same address. Every time.
Prerequisites:
- Solid understanding of Solidity (0.8.x)
- Familiarity with EVM concepts (CREATE2, ABI encoding)
- Node.js 18+ installed
- Basic understanding of the Tron network
Tron vs EVM: Key Differences
Before diving into the code, you need to understand the fundamental differences between Tron and standard EVM chains. Tron runs a modified EVM called the TVM (Tron Virtual Machine), which is mostly compatible with Ethereum but diverges in several critical areas.
| Feature | Tron (TVM) | EVM (Ethereum, Polygon, etc.) |
|---|---|---|
| Address format | Base58check, starts with T (e.g., TJRab...) | Hex, starts with 0x (e.g., 0x1234...) |
| Address prefix | 0x41 (internal hex representation) | 0x (no prefix beyond the standard) |
| CREATE2 prefix | 0x41 | 0xff |
| Token standard | TRC20 | ERC20 |
| TRC20 transfer() | Returns void (no bool) | Returns bool |
| Gas model | Energy + Bandwidth | Gas |
| Native currency | TRX (1 TRX = 1,000,000 SUN) | ETH (1 ETH = 10^18 Wei) |
| Development tools | TronBox | Hardhat / Foundry |
| Chain ID | 728126428 (mainnet) | 1 (Ethereum mainnet) |
| Block time | ~3 seconds | ~12 seconds (Ethereum) |
| CREATE2 library | Native opcode (built-in) | Native or via CreateX |
The two differences that will affect your code the most are the 0x41 prefix in CREATE2 address computation and the TRC20 transfer() return value. We will cover both in detail.
The 0x41 Prefix
Every Tron address has an internal hex representation that starts with 0x41. When the TVM computes a CREATE2 address, it uses 0x41 as the prefix byte instead of 0xff as Ethereum does. This single byte difference means that the same factory contract, salt, and bytecode will produce a completely different address on Tron than on an EVM chain. Your off-chain address computation scripts must account for this.
Tron CREATE2 formula:
EVM CREATE2 formula:
The resulting 20-byte address is then prefixed with 0x41 and encoded in base58check to produce the familiar T... Tron address.
The TRC20 Problem
This is the single most important Tron-specific issue you will encounter. On Ethereum and all standard EVM chains, the ERC20 transfer() function returns a bool:
// ERC20 standard
function transfer(address to, uint256 amount) external returns (bool);
On Tron, the native TRC20 tokens (including USDT, the most important one) do not return a bool:
// TRC20 on Tron — no return value
function transfer(address to, uint256 amount) external;
This means that OpenZeppelin’s SafeERC20 library, which wraps transfer calls and checks the return value, will revert when used with native Tron TRC20 tokens. The safeTransfer function expects either a true return value or empty return data that it can interpret. Tron’s TRC20 tokens return data that does not conform to this expectation, causing the safety check to fail.
The solution is straightforward: define a custom ITRC20 interface that matches Tron’s actual behavior.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
/// @dev Tron-compatible TRC20 interface.
/// transfer does not return a bool — compatibility with native Tron TRC20 tokens.
interface ITRC20 {
function transfer(address to, uint256 amount) external;
function balanceOf(address account) external view returns (uint256);
}
This interface declares transfer() as returning void. When you call ITRC20(token).transfer(to, amount), Solidity will not attempt to decode a return value, and the call will succeed as long as the token’s internal logic does not revert.
The trade-off is that you lose the ability to check if a transfer succeeded via return value. In practice, this is acceptable because Tron TRC20 tokens revert on failure rather than returning false. The behavior is effectively the same as a checked transfer: if it does not revert, it succeeded.
Architecture Overview
The system follows a three-contract architecture with off-chain address pre-computation:
Flow:
- Backend calls
compute-wallet.jswith awalletId(e.g.,"user_123") to get a deterministic Tron address - Customer is given that
T...address to deposit TRC20 tokens or TRX - When funds arrive, the backend calls
deployAndSweep()on the factory, which deploys the receiver and immediately sweeps funds to the treasury in a single transaction - For subsequent deposits to the same address,
sweepExisting()is called since the receiver is already deployed
Project Setup
Initialize the TronBox project
mkdir wallet-factory-tron && cd wallet-factory-tron
npx tronbox init
npm install @openzeppelin/contracts@^5.0.0 dotenv minimist tronweb@^6.2.0
npm install --save-dev tronbox@^4.5.0
Configure TronBox
Replace the contents of tronbox-config.js with the full configuration:
require('dotenv').config();
module.exports = {
networks: {
mainnet: {
privateKey: process.env.PRIVATE_KEY_MAINNET,
userFeePercentage: 100,
feeLimit: 1000 * 1e6,
fullHost: 'https://api.trongrid.io',
network_id: '1'
},
shasta: {
privateKey: process.env.PRIVATE_KEY_SHASTA,
userFeePercentage: 50,
feeLimit: 1000 * 1e6,
fullHost: 'https://api.shasta.trongrid.io',
network_id: '2'
},
nile: {
privateKey: process.env.PRIVATE_KEY_NILE,
userFeePercentage: 100,
feeLimit: 1000 * 1e6,
fullHost: 'https://nile.trongrid.io',
network_id: '3'
},
development: {
privateKey: '0000000000000000000000000000000000000000000000000000000000000001',
userFeePercentage: 0,
feeLimit: 1000 * 1e6,
fullHost: 'http://127.0.0.1:9090',
network_id: '9'
}
},
compilers: {
solc: {
version: '0.8.25',
settings: {
optimizer: {
enabled: true,
runs: 200
},
evmVersion: 'cancun'
}
}
}
};
Network breakdown:
- mainnet — Tron production network.
userFeePercentage: 100means the caller pays 100% of the energy cost. - shasta — Public testnet with free test TRX at shasta.tronex.io.
- nile — Another testnet, often more stable. Get test TRX at nileex.io.
- development — Local TronBox docker environment (tronbox/tre image).
Environment variables
Create a .env file at the project root:
# Private keys (without 0x prefix)
PRIVATE_KEY_NILE=your_private_key_here
PRIVATE_KEY_MAINNET=your_mainnet_key_here
# Relayer: the EOA authorized to trigger deploys and sweeps
RELAYER_ADDRESS=TYourRelayerBase58Address
# After deployment, set this:
WALLET_FACTORY_ADDRESS=TYourFactoryBase58Address
# Treasury: where swept funds go
TREASURY_ADDRESS=TYourTreasuryBase58Address
# Default TRC20 token for sweeps (e.g., USDT on Nile)
TRC20_TOKEN_ADDRESS=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj
# RPC endpoint
FULL_NODE_URL_TRON=https://nile.trongrid.io
package.json
{
"dependencies": {
"@openzeppelin/contracts": "^5.0.0",
"dotenv": "^17.3.1",
"minimist": "^1.2.8",
"tronweb": "^6.2.0"
},
"devDependencies": {
"tronbox": "^4.5.0"
}
}
Note that ethers is not listed as a direct dependency but is available transitively through TronBox. The compute-wallet.js script uses it for keccak256 hashing.
Contract 1: ITRC20 Interface
The first contract is the simplest but arguably the most important Tron-specific adaptation:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
/// @dev Tron-compatible TRC20 interface.
/// transfer does not return a bool — compatibility with native Tron TRC20 tokens.
interface ITRC20 {
function transfer(address to, uint256 amount) external;
function balanceOf(address account) external view returns (uint256);
}
Why this exists:
On EVM chains, the ERC20 standard defines transfer() as returning bool. OpenZeppelin’s SafeERC20 wraps calls to handle tokens that do not follow the standard (the USDT on Ethereum, for example, does not return a value on older versions). On Tron, the situation is different: the standard TRC20 implementation does not return bool on transfer(). This is not a bug in specific tokens; it is how TRC20 works on Tron.
Using OpenZeppelin’s IERC20 interface would compile fine, but at runtime the ABI decoder would try to read a bool return value from the call. Since the TRC20 token does not return one, the transaction would revert with a decoding error. By declaring transfer() as external without a return type, Solidity generates a call that ignores the return data entirely.
balanceOf() returns uint256 on both ERC20 and TRC20, so it works identically.
Contract 2: WalletReceiver
The WalletReceiver is a minimal contract deployed via CREATE2 for each user wallet. It holds funds until the relayer or factory triggers a sweep. All configuration is stored in immutable variables, which are embedded in the contract bytecode at deploy time and cost zero storage reads at runtime.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./ITRC20.sol";
/**
* @title WalletReceiver
* @author Beltsys Labs
* @notice Minimal contract that receives TRC20 tokens or TRX and allows
* the relayer to sweep funds to one or multiple destinations.
* @dev Deployed via native CREATE2 on Tron. Parameters are
* immutable to minimize energy consumption.
* Uses ITRC20 instead of SafeERC20 for compatibility with
* native Tron TRC20 tokens that do not return a bool on transfer.
* @custom:version 1.0.2-tron
* @custom:security-contact [email protected]
*/
contract WalletReceiver is ReentrancyGuard {
// ─── Constants ────────────────────────────────────────────────────────────
uint256 public constant MAX_RECIPIENTS = 5;
// ─── Structs ──────────────────────────────────────────────────────────────
struct Recipient {
address wallet;
uint256 bps;
}
// ─── Immutables ───────────────────────────────────────────────────────────
address public immutable relayer;
address public immutable factory;
bytes32 public immutable walletId;
// ─── Events ───────────────────────────────────────────────────────────────
event Swept(
bytes32 indexed walletId,
address indexed token,
uint256 totalAmount,
uint256 recipientCount
);
event NativeSwept(
bytes32 indexed walletId,
uint256 totalAmount,
uint256 recipientCount
);
// ─── Errors ───────────────────────────────────────────────────────────────
error NotAuthorized();
error NothingToSweep();
error TransferFailed();
error ZeroAddress();
error InvalidRecipients();
error TooManyRecipients();
error BpsDoNotSum();
error BpsOverflow();
// ─── Constructor ──────────────────────────────────────────────────────────
constructor(
address _relayer,
address _factory,
bytes32 _walletId
) {
if (_relayer == address(0)) revert ZeroAddress();
if (_factory == address(0)) revert ZeroAddress();
relayer = _relayer;
factory = _factory;
walletId = _walletId;
}
// ─── Modifiers ────────────────────────────────────────────────────────────
modifier onlyAuthorized() {
if (msg.sender != relayer && msg.sender != factory) revert NotAuthorized();
_;
}
// ─── Main Functions ───────────────────────────────────────────────────────
function sweep(
address token,
Recipient[] calldata recipients
) external onlyAuthorized nonReentrant {
if (recipients.length == 0) revert InvalidRecipients();
if (recipients.length > MAX_RECIPIENTS) revert TooManyRecipients();
uint256 totalBps;
for (uint256 i = 0; i < recipients.length; i++) {
if (recipients[i].wallet == address(0)) revert ZeroAddress();
if (recipients[i].bps > 10_000) revert BpsOverflow();
totalBps += recipients[i].bps;
}
if (totalBps != 10_000) revert BpsDoNotSum();
ITRC20 _token = ITRC20(token);
uint256 balance = _token.balanceOf(address(this));
if (balance == 0) revert NothingToSweep();
uint256 distributed;
for (uint256 i = 0; i < recipients.length; i++) {
uint256 amount = i == recipients.length - 1
? balance - distributed
: (balance * recipients[i].bps) / 10_000;
if (amount > 0) {
// Direct call without checking return value —
// native Tron TRC20 tokens do not return a bool
_token.transfer(recipients[i].wallet, amount);
distributed += amount;
}
}
emit Swept(walletId, token, balance, recipients.length);
}
function sweepNative(
Recipient[] calldata recipients
) external onlyAuthorized nonReentrant {
if (recipients.length == 0) revert InvalidRecipients();
if (recipients.length > MAX_RECIPIENTS) revert TooManyRecipients();
uint256 totalBps;
for (uint256 i = 0; i < recipients.length; i++) {
if (recipients[i].wallet == address(0)) revert ZeroAddress();
if (recipients[i].bps > 10_000) revert BpsOverflow();
totalBps += recipients[i].bps;
}
if (totalBps != 10_000) revert BpsDoNotSum();
uint256 bal = address(this).balance;
if (bal == 0) revert NothingToSweep();
uint256 distributed;
for (uint256 i = 0; i < recipients.length; i++) {
uint256 amount = i == recipients.length - 1
? bal - distributed
: (bal * recipients[i].bps) / 10_000;
if (amount > 0) {
distributed += amount;
(bool ok, ) = recipients[i].wallet.call{value: amount}("");
if (!ok) revert TransferFailed();
}
}
emit NativeSwept(walletId, bal, recipients.length);
}
receive() external payable {}
}
Key design decisions
Immutables over storage. The relayer, factory, and walletId variables are all immutable. They are set once in the constructor and embedded directly into the deployed bytecode. Reading an immutable costs 3 gas (a PUSH instruction) versus 2,100 gas for an SLOAD. Since each WalletReceiver is deployed per user, minimizing storage reads directly reduces energy costs across thousands of wallets.
ITRC20 instead of SafeERC20. As explained in the ITRC20 section, we call _token.transfer() directly without checking a return value. The comment in the code makes this explicit: native Tron TRC20 tokens do not return a bool.
BPS-based distribution. Recipients are specified as (address wallet, uint256 bps) pairs where bps stands for basis points. 10,000 BPS = 100%. The contract enforces that all BPS values sum to exactly 10,000, preventing partial distributions. The last recipient receives balance - distributed instead of a BPS calculation to avoid dust from rounding.
Dual authorization. The onlyAuthorized modifier allows both the relayer (direct EOA calls) and the factory (calls via deployAndSweep) to trigger sweeps. This dual-path design enables both atomic deploy-and-sweep flows and standalone sweeps of already-deployed receivers.
ReentrancyGuard. Both sweep() and sweepNative() are protected by OpenZeppelin’s nonReentrant modifier. This is especially important for sweepNative(), which uses low-level .call{value}() to send TRX and could be vulnerable to reentrancy if a recipient is a malicious contract.
MAX_RECIPIENTS = 5. This bounds the loop iterations to prevent unbounded energy consumption. In practice, most sweeps go to a single treasury address (1 recipient, 10,000 BPS).
Contract 3: WalletFactory
The WalletFactory is the central orchestrator. It deploys WalletReceiver instances via CREATE2, manages the relayer address, and provides atomic deploy-and-sweep operations.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "./ITRC20.sol";
import "./WalletReceiver.sol";
/**
* @title WalletFactory
* @author Beltsys Labs
* @notice Factory contract that deploys deterministic WalletReceiver instances on Tron.
* @dev Uses native CREATE2 for deterministic addresses within the Tron network.
* Salt: keccak256(abi.encodePacked(walletId)).
* Uses ITRC20 instead of IERC20 for compatibility with native Tron TRC20
* tokens that do not return a bool on transfer.
* @custom:version 1.0.2-tron
* @custom:security-contact [email protected]
*/
contract WalletFactory is Ownable, Pausable {
// ─── State ────────────────────────────────────────────────────────────────
/// @notice Maximum number of recipients allowed per sweep to bound energy usage
uint256 public constant MAX_RECIPIENTS = 5;
/// @notice Backend EOA authorized to trigger deployments and sweeps
address public relayer;
/// @notice Tracks all WalletReceiver addresses deployed by this factory
mapping(address => bool) public isDeployedReceiver;
// ─── Events ───────────────────────────────────────────────────────────────
event WalletDeployed(
bytes32 indexed walletId,
address indexed receiver,
uint256 indexed chainId
);
event DeployedAndSwept(
bytes32 indexed walletId,
address indexed receiver,
address indexed token,
uint256 totalAmount,
uint256 recipientCount
);
event EmergencySweep(
address indexed receiver,
address indexed token,
address indexed destination
);
event RelayerUpdated(
address indexed oldRelayer,
address indexed newRelayer
);
// ─── Errors ───────────────────────────────────────────────────────────────
error NotRelayer();
error ZeroAddress();
error InvalidToken();
error InvalidReceiver();
error TooManyRecipients();
// ─── Constructor ──────────────────────────────────────────────────────────
constructor(address _relayer) Ownable(msg.sender) {
if (_relayer == address(0)) revert ZeroAddress();
relayer = _relayer;
}
// ─── Modifiers ────────────────────────────────────────────────────────────
modifier onlyRelayer() {
if (msg.sender != relayer) revert NotRelayer();
_;
}
modifier onlyValidReceiver(address receiver) {
if (!isDeployedReceiver[receiver]) revert InvalidReceiver();
_;
}
modifier validRecipients(uint256 count) {
if (count > MAX_RECIPIENTS) revert TooManyRecipients();
_;
}
// ─── Admin Functions ──────────────────────────────────────────────────────
function setRelayer(address _relayer) external onlyOwner {
if (_relayer == address(0)) revert ZeroAddress();
emit RelayerUpdated(relayer, _relayer);
relayer = _relayer;
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// ─── Precompute ───────────────────────────────────────────────────────────
/**
* @notice Returns the deterministic address for a given walletId.
* @dev Uses the same formula as `new WalletReceiver{salt: salt}(...)`.
* Formula: keccak256(0x41 ++ address(this) ++ salt ++ keccak256(bytecode))
*/
function computeWalletAddress(bytes32 walletId)
external
view
returns (address walletAddress)
{
bytes32 salt = keccak256(abi.encodePacked(walletId));
bytes memory bytecode = abi.encodePacked(
type(WalletReceiver).creationCode,
abi.encode(relayer, address(this), walletId)
);
// Tron/TVM CREATE2 address calculation (simplified for view)
// Note: off-chain scripts should use the 0x41 prefix for exact matching
return address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0x41),
address(this),
salt,
keccak256(bytecode)
)))));
}
// ─── Deploy Standalone ────────────────────────────────────────────────────
function deployWallet(bytes32 walletId)
external
onlyRelayer
whenNotPaused
returns (address receiver)
{
bytes32 salt = keccak256(abi.encodePacked(walletId));
receiver = address(new WalletReceiver{salt: salt}(relayer, address(this), walletId));
isDeployedReceiver[receiver] = true;
emit WalletDeployed(walletId, receiver, 728126428);
}
// ─── Deploy + Sweep ───────────────────────────────────────────────────────
function deployAndSweep(
bytes32 walletId,
address token,
WalletReceiver.Recipient[] calldata recipients
)
external
onlyRelayer
whenNotPaused
validRecipients(recipients.length)
returns (address receiver)
{
bytes32 salt = keccak256(abi.encodePacked(walletId));
receiver = address(new WalletReceiver{salt: salt}(relayer, address(this), walletId));
isDeployedReceiver[receiver] = true;
emit WalletDeployed(walletId, receiver, 728126428);
if (token == address(0)) {
uint256 balance = receiver.balance;
if (balance > 0) {
WalletReceiver(payable(receiver)).sweepNative(recipients);
emit DeployedAndSwept(walletId, receiver, address(0), balance, recipients.length);
}
} else {
// Use ITRC20 instead of IERC20 — compatible with native Tron TRC20 tokens
uint256 balance = ITRC20(token).balanceOf(receiver);
if (balance > 0) {
WalletReceiver(payable(receiver)).sweep(token, recipients);
emit DeployedAndSwept(walletId, receiver, token, balance, recipients.length);
}
}
}
function sweepExisting(
address receiver,
address token,
WalletReceiver.Recipient[] calldata recipients
)
external
onlyRelayer
whenNotPaused
onlyValidReceiver(receiver)
validRecipients(recipients.length)
{
if (token == address(0)) {
WalletReceiver(payable(receiver)).sweepNative(recipients);
} else {
WalletReceiver(payable(receiver)).sweep(token, recipients);
}
}
// ─── Emergency Functions ──────────────────────────────────────────────────
function emergencySweep(
address receiver,
address token,
address destination
)
external
onlyOwner
onlyValidReceiver(receiver)
{
if (destination == address(0)) revert ZeroAddress();
if (token == address(0)) revert InvalidToken();
WalletReceiver.Recipient[] memory recipients = new WalletReceiver.Recipient[](1);
recipients[0] = WalletReceiver.Recipient({
wallet: destination,
bps: 10_000
});
WalletReceiver(payable(receiver)).sweep(token, recipients);
emit EmergencySweep(receiver, token, destination);
}
function emergencySweepNative(
address receiver,
address destination
)
external
onlyOwner
onlyValidReceiver(receiver)
{
if (destination == address(0)) revert ZeroAddress();
WalletReceiver.Recipient[] memory recipients = new WalletReceiver.Recipient[](1);
recipients[0] = WalletReceiver.Recipient({
wallet: destination,
bps: 10_000
});
WalletReceiver(payable(receiver)).sweepNative(recipients);
emit EmergencySweep(receiver, address(0), destination);
}
}
Function-by-function breakdown
Ownable + Pausable (not Ownable2Step). On EVM chains, we use Ownable2Step for safer ownership transfers. On Tron, Ownable2Step adds an extra transaction that consumes energy and bandwidth for the acceptance step. Since Tron transactions cost real TRX (energy is not free unless you stake), the simpler Ownable is preferred. The owner can still be transferred, but without the two-step confirmation. For production deployments where the owner is a multisig, this trade-off is acceptable.
computeWalletAddress(). This is the on-chain version of the address pre-computation. It takes a walletId (bytes32), computes the salt as keccak256(abi.encodePacked(walletId)), constructs the full init code (creation bytecode + constructor args), and applies the Tron CREATE2 formula with the 0x41 prefix. This function is view and costs no energy to call.
deployWallet(). Standalone deployment without sweeping. Uses Solidity’s native new WalletReceiver{salt: salt}(...) syntax, which maps to the CREATE2 opcode. The deployed address is recorded in isDeployedReceiver and the WalletDeployed event is emitted with the hardcoded chain ID 728126428 (Tron mainnet). This chain ID is hardcoded rather than using block.chainid because TronBox environments may report different chain IDs during testing.
deployAndSweep(). The atomic operation: deploy a receiver and immediately sweep its funds in a single transaction. This is the primary function used in production. The flow is:
- Deploy the
WalletReceiverwith CREATE2 - Register it in
isDeployedReceiver - Check if the receiver has a balance of the specified token
- If yes, call
sweep()orsweepNative()on the receiver
If token is address(0), it sweeps native TRX. Otherwise, it uses ITRC20 to check the TRC20 balance and sweep tokens. Note the use of ITRC20 instead of IERC20 in the balance check.
sweepExisting(). For receivers that are already deployed (second deposit and beyond). Validates the receiver address against isDeployedReceiver to prevent calling arbitrary contracts.
emergencySweep() and emergencySweepNative(). Owner-only functions for recovering funds in case the relayer key is compromised or lost. They bypass the relayer check and sweep 100% of funds to a single destination (10,000 BPS to one address). emergencySweep handles TRC20 tokens and explicitly rejects address(0) as the token, while emergencySweepNative handles TRX.
The 0x41 Prefix: Tron’s CREATE2 Formula
The CREATE2 address computation is the core of the deterministic wallet pattern. Understanding exactly how it works on Tron is critical for getting the off-chain and on-chain addresses to match.
The standard EVM formula
On Ethereum and all EVM-compatible chains, CREATE2 computes the address as:
The 0xff prefix byte prevents collisions with the standard CREATE opcode (which uses the deployer’s nonce). The result is the last 20 bytes of the keccak256 hash.
The Tron formula
Tron replaces 0xff with 0x41:
Why 0x41? Because 0x41 is the Tron address prefix byte. All Tron addresses in their hex representation start with 41. When you see a T... address, it is a base58check encoding of 41 + 20 bytes. The TVM uses this same prefix in the CREATE2 preimage to keep the address derivation consistent with Tron’s address space.
Step-by-step computation
Here is how the computeWalletAddress function works internally:
The important detail is that the initCode includes the constructor arguments appended to the creation bytecode. If any constructor argument changes (different relayer, different factory address, different walletId), the init code hash changes, and therefore the resulting address changes. This is what makes each walletId produce a unique, deterministic address.
Deployment
The migration script handles deploying the WalletFactory and converting the resulting hex address to Tron’s base58check format.
require('dotenv').config();
const WalletFactory = artifacts.require("WalletFactory");
const crypto = require('crypto');
// Converts a hex Tron address (41...) to base58check format (T...)
function hexToBase58(hexAddr) {
const BASE58_CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const payload = Buffer.from(hexAddr, 'hex');
const h1 = crypto.createHash('sha256').update(payload).digest();
const h2 = crypto.createHash('sha256').update(h1).digest();
const checksum = h2.slice(0, 4);
const full = Buffer.concat([payload, checksum]);
let num = BigInt('0x' + full.toString('hex'));
let result = '';
const base = BigInt(58);
while (num > 0n) {
result = BASE58_CHARS[Number(num % base)] + result;
num = num / base;
}
for (const byte of full) {
if (byte !== 0) break;
result = '1' + result;
}
return result;
}
module.exports = async function (deployer, network) {
const relayer = process.env.RELAYER_ADDRESS;
if (!relayer) {
throw new Error("RELAYER_ADDRESS is not defined in .env");
}
console.log(">> Deploying WalletFactory with Relayer:", relayer);
await deployer.deploy(WalletFactory, relayer);
const instance = await WalletFactory.deployed();
const hexAddress = instance.address; // 41...
const base58Address = hexToBase58(hexAddress); // T...
console.log("\n╔══════════════════════════════════════════════════════╗");
console.log("║ WalletFactory — Deploy successful ║");
console.log("╠══════════════════════════════════════════════════════╣");
console.log("║ Network :", network.padEnd(43), "║");
console.log("║ Hex :", hexAddress.padEnd(43), "║");
console.log("║ Tron :", base58Address.padEnd(43), "║");
console.log("║ Relayer :", relayer.padEnd(43), "║");
console.log("╚══════════════════════════════════════════════════════╝\n");
console.log(">> Update WALLET_FACTORY_ADDRESS in .env with:", base58Address);
};
hexToBase58 explained
TronBox returns contract addresses in hex format (41...). To get the human-readable T... address, you need to:
- Take the 21-byte hex payload (
41prefix + 20 address bytes) - Double SHA-256 hash it:
sha256(sha256(payload)) - Take the first 4 bytes as a checksum
- Append the checksum to the payload (25 bytes total)
- Encode the 25 bytes in base58
This is the same encoding Bitcoin uses for addresses (base58check), just with a different version byte (0x41 for Tron instead of 0x00 for Bitcoin mainnet).
Running the deployment
# Compile contracts
npx tronbox compile
# Deploy to Nile testnet
npx tronbox migrate --network nile
# Deploy to mainnet
npx tronbox migrate --network mainnet
After deployment, copy the Tron address from the output and set it as WALLET_FACTORY_ADDRESS in your .env file.
Off-Chain Address Computation
The compute-wallet.js script reproduces the CREATE2 formula entirely off-chain. This is the script your backend calls to generate deposit addresses for customers without making any on-chain transactions.
#!/usr/bin/env node
/**
* scripts/compute-wallet.js
* ─────────────────────────────────────────────────────────────────────────────
* Pre-computes the CREATE2 address of a WalletReceiver on Tron,
* reproducing exactly the formula from the WalletFactory contract:
*
* salt = keccak256(abi.encodePacked(walletId))
* address = keccak256(0x41 ++ factory ++ salt ++ keccak256(initCode))
*
* Usage:
* node scripts/compute-wallet.js <walletId>
* node scripts/compute-wallet.js user_123
* node scripts/compute-wallet.js 0xabc... (bytes32 hex)
*
* Required environment variables (.env):
* WALLET_FACTORY_ADDRESS T... address of the deployed WalletFactory
* RELAYER_ADDRESS T... address of the relayer
*/
require('dotenv').config();
const crypto = require('crypto');
const path = require('path');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** keccak256 of a Buffer → Buffer */
function keccak256(buf) {
const { createKeccakHash } = require('crypto');
// Node does not have native keccak — use ethers (available via TronBox)
try {
const ethers = require('ethers');
return Buffer.from(ethers.keccak256(buf).slice(2), 'hex');
} catch {
throw new Error(
'Install ethers: npm install ethers\n' +
'Or use: npx tronbox exec scripts/compute-wallet.js (has access to web3)'
);
}
}
/** Converts a hex Tron address (41...) to base58check (T...) */
function hexToBase58(hexAddr) {
const CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const payload = Buffer.from(hexAddr, 'hex');
const h1 = crypto.createHash('sha256').update(payload).digest();
const h2 = crypto.createHash('sha256').update(h1).digest();
const full = Buffer.concat([payload, h2.slice(0, 4)]);
let num = BigInt('0x' + full.toString('hex'));
let result = '';
while (num > 0n) { result = CHARS[Number(num % 58n)] + result; num /= 58n; }
for (const b of full) { if (b !== 0) break; result = '1' + result; }
return result;
}
/** Converts a Tron base58 address (T...) to hex without prefix (40 chars) */
function base58ToHex(addr) {
const CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const c of addr) {
const idx = CHARS.indexOf(c);
if (idx < 0) throw new Error(`Invalid character in address: ${c}`);
num = num * 58n + BigInt(idx);
}
// Convert BigInt to hex padded to 50 chars (25 bytes = 21 payload + 4 checksum)
let hex = num.toString(16).padStart(50, '0');
// Last 8 hex chars (4 bytes) are the checksum — take first 42 (21 bytes)
const payload = hex.slice(0, 42); // 21 bytes: prefix 41 + 20 address bytes
return payload; // e.g., "41adc7832321..."
}
/** Converts a Tron address (T... or 41...) to a 20-byte EVM Buffer */
function addressToBytes20(addr) {
let hex;
if (addr.startsWith('T')) {
hex = base58ToHex(addr); // 42 hex chars: "41" + 40 address chars
hex = hex.slice(2); // remove Tron prefix "41" → 40 chars
} else if (addr.startsWith('0x')) {
hex = addr.slice(2); // remove "0x"
if (hex.startsWith('41')) hex = hex.slice(2); // remove Tron prefix if present
} else if (addr.startsWith('41')) {
hex = addr.slice(2); // remove Tron prefix
} else {
hex = addr;
}
if (hex.length !== 40) throw new Error(`Invalid address (expected 40 hex chars): ${addr}`);
return Buffer.from(hex, 'hex');
}
/** walletId string → bytes32 Buffer (right-padded with zeros) */
function walletIdToBytes32(id) {
if (id.startsWith('0x') && id.length === 66) {
return Buffer.from(id.slice(2), 'hex');
}
// Plain text → UTF-8 → right-pad to 32 bytes
const buf = Buffer.alloc(32, 0);
const src = Buffer.from(id, 'utf8');
if (src.length > 32) throw new Error('walletId exceeds 32 bytes in UTF-8');
src.copy(buf, 0);
return buf;
}
// ─── Main CREATE2 logic ─────────────────────────────────────────────────────
async function computeWalletAddress(walletIdStr) {
// 1. Load bytecode from compiled artifact
const artifactPath = path.join(__dirname, '..', 'build', 'contracts', 'WalletReceiver.json');
const artifact = require(artifactPath);
const creationBytecode = Buffer.from(artifact.bytecode.replace('0x', ''), 'hex');
// 2. Read configuration from .env
const factoryAddrRaw = process.env.WALLET_FACTORY_ADDRESS;
const relayerAddrRaw = process.env.RELAYER_ADDRESS;
if (!factoryAddrRaw) throw new Error('WALLET_FACTORY_ADDRESS not defined in .env');
if (!relayerAddrRaw) throw new Error('RELAYER_ADDRESS not defined in .env');
const factoryBytes = addressToBytes20(factoryAddrRaw); // 20 bytes
const relayerBytes = addressToBytes20(relayerAddrRaw); // 20 bytes
// 3. Prepare walletId as bytes32
const walletIdBytes32 = walletIdToBytes32(walletIdStr); // 32 bytes
// 4. Constructor args encoded (ABI encode: address, address, bytes32)
// ABI encoding: each value is a 32-byte word, addresses are left-padded
const relayerPadded = Buffer.concat([Buffer.alloc(12, 0), relayerBytes]); // 32 bytes
const factoryPadded = Buffer.concat([Buffer.alloc(12, 0), factoryBytes]); // 32 bytes
const constructorArgs = Buffer.concat([relayerPadded, factoryPadded, walletIdBytes32]);
// 5. initCode = creationBytecode + constructorArgs
const initCode = Buffer.concat([creationBytecode, constructorArgs]);
// 6. salt = keccak256(abi.encodePacked(walletId)) = keccak256(walletId)
const salt = keccak256(walletIdBytes32);
// 7. Tron CREATE2 formula:
// keccak256(0x41 ++ factory(20bytes) ++ salt(32bytes) ++ keccak256(initCode))
const initCodeHash = keccak256(initCode);
const preimage = Buffer.concat([
Buffer.from([0x41]), // Tron prefix (vs 0xff on EVM)
factoryBytes, // 20 bytes
salt, // 32 bytes
initCodeHash // 32 bytes
]);
const addressHash = keccak256(preimage);
// 8. Take the last 20 bytes → EVM address
const addressBytes = addressHash.slice(-20);
const hexWithPrefix = '41' + addressBytes.toString('hex');
const base58Addr = hexToBase58(hexWithPrefix);
return { walletIdBytes32, salt, hexWithPrefix, base58Addr };
}
// ─── Entry point ──────────────────────────────────────────────────────────────
(async () => {
const walletIdStr = process.argv[2];
if (!walletIdStr) {
console.error('Usage: node scripts/compute-wallet.js <walletId>');
console.error('Example: node scripts/compute-wallet.js user_123');
process.exit(1);
}
try {
const { walletIdBytes32, salt, hexWithPrefix, base58Addr } =
await computeWalletAddress(walletIdStr);
console.log('\n════════════════════════════════════════════════════════');
console.log(' WalletReceiver — Pre-computed address');
console.log('════════════════════════════════════════════════════════');
console.log(' WalletId (input) :', walletIdStr);
console.log(' WalletId (bytes32):', '0x' + walletIdBytes32.toString('hex'));
console.log(' Salt (keccak256) :', '0x' + salt.toString('hex'));
console.log(' Address (hex) :', hexWithPrefix);
console.log(' Address (Tron) :', base58Addr);
console.log('════════════════════════════════════════════════════════\n');
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
})();
How the script works
Step 1: Load the compiled bytecode. The script reads build/contracts/WalletReceiver.json, which TronBox generates during compilation. The bytecode field contains the creation bytecode (the code that runs during deployment and returns the runtime bytecode).
Step 2: Convert addresses. Tron addresses come in base58 format (T...). The script converts them to raw 20-byte buffers using addressToBytes20(). This function handles multiple input formats: base58 (T...), hex with Tron prefix (41...), and hex with 0x prefix.
Step 3: Encode walletId. If the walletId is a plain string like "user_123", it is converted to UTF-8 bytes and right-padded with zeros to fill 32 bytes. If it is already a 0x-prefixed hex string of 66 characters (32 bytes), it is used directly.
Step 4: ABI-encode constructor arguments. The constructor takes (address _relayer, address _factory, bytes32 _walletId). ABI encoding packs each argument into a 32-byte word: addresses are left-padded with 12 zero bytes, and bytes32 is used as-is.
Step 5: Build init code. The init code is the creation bytecode concatenated with the ABI-encoded constructor arguments. This is what the CREATE2 opcode hashes.
Step 6: Compute salt. The salt is keccak256(walletId), matching what the Solidity contract does with keccak256(abi.encodePacked(walletId)).
Step 7: Apply the Tron CREATE2 formula. Concatenate [0x41, factory(20), salt(32), keccak256(initCode)(32)] and hash with keccak256. Take the last 20 bytes as the raw address.
Step 8: Convert to Tron format. Prepend 41 to the 20-byte hex address and encode in base58check to get the final T... address.
Running the script
# Pre-compute address for walletId "user_123"
node scripts/compute-wallet.js user_123
# Pre-compute address for a hex bytes32 walletId
node scripts/compute-wallet.js 0x757365725f31323300000000000000000000000000000000000000000000000000
The output will show the Tron address that will be generated when deployWallet() or deployAndSweep() is called with the same walletId on the deployed factory.
Verifying against the on-chain function
You can also call the factory’s computeWalletAddress function directly via TronBox:
// compute.js — TronBox exec script
require('dotenv').config();
const WalletFactory = artifacts.require("WalletFactory");
module.exports = async function (callback) {
try {
const factory = await WalletFactory.deployed();
const walletId = web3.utils.fromAscii("user_123").padEnd(66, '0');
console.log("Computing address for walletId:", walletId);
const predictedAddr = await factory.computeWalletAddress(walletId);
console.log("-----------------------------------------");
console.log("Address computed by contract:", predictedAddr);
console.log("-----------------------------------------");
callback();
} catch (e) {
console.error(e);
callback(e);
}
};
Run it with:
npx tronbox exec compute.js --network nile
Both methods should produce the same address. If they do not match, check that your .env has the correct factory and relayer addresses, and that the compiled bytecode has not changed since deployment.
Sweeping Funds
The sweep.js script handles the complete flow of checking balances, estimating energy costs, and executing the sweep transaction. It uses TronWeb’s triggerSmartContract API instead of the higher-level contract abstractions, which gives more control over parameter encoding (important for struct arrays in TronWeb v6).
#!/usr/bin/env node
/**
* scripts/sweep.js
* ─────────────────────────────────────────────────────────────────────────────
* Sweeps funds from a WalletReceiver on Tron.
*
* Based on triggerSmartContract to avoid struct encoding issues
* in TronWeb v6.
*/
require('dotenv').config();
const _TronWeb = require('tronweb');
const TronWeb = _TronWeb.TronWeb || _TronWeb;
const { ethers } = require('ethers');
const crypto = require('crypto');
// ─── Configuration ────────────────────────────────────────────────────────────
const FULL_NODE = process.env.FULL_NODE_URL_TRON || 'https://api.nileex.io';
const PRIVATE_KEY = process.env.PRIVATE_KEY_NILE;
const FACTORY_ADDRESS = process.env.WALLET_FACTORY_ADDRESS;
const TREASURY_ADDRESS = process.env.TREASURY_ADDRESS;
const TRC20_TOKEN_DEFAULT = process.env.TRC20_TOKEN_ADDRESS;
const TRON_ZERO_ADDRESS_HEX = '0000000000000000000000000000000000000000';
const tronWeb = new TronWeb({
fullHost: FULL_NODE,
privateKey: PRIVATE_KEY
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function base58ToHex20(base58) {
if (!base58 || base58 === 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb') return TRON_ZERO_ADDRESS_HEX;
const hex = tronWeb.address.toHex(base58); // Returns '41...' (21 bytes)
return hex.slice(2); // Return the 20 bytes (40 chars)
}
function hex20ToBase58(hex20) {
return tronWeb.address.fromHex('41' + hex20);
}
function resultToHex20(raw) {
return raw.replace(/^0x/, '').slice(-40);
}
/** Send transaction using triggerSmartContract */
async function sendTx(functionSignature, params) {
console.log(`Sending TX: ${functionSignature}`);
const transaction = await tronWeb.transactionBuilder.triggerSmartContract(
FACTORY_ADDRESS,
functionSignature,
{ feeLimit: 400_000_000 }, // 400 TRX
params
);
const signed = await tronWeb.trx.sign(transaction.transaction);
const result = await tronWeb.trx.sendRawTransaction(signed);
if (!result.result) {
throw new Error(result.message || JSON.stringify(result));
}
return result.txid;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const args = require('minimist')(process.argv.slice(2));
const walletIdRaw = args.wallet;
const tokenType = args.token; // 'native' or 'trc20' or address
const customTo = args.to;
if (!walletIdRaw) {
console.error('Missing --wallet <walletId>');
process.exit(1);
}
const treasuryBase58 = customTo || TREASURY_ADDRESS;
if (!PRIVATE_KEY || !FACTORY_ADDRESS || !treasuryBase58) {
console.error('Incomplete configuration in .env');
process.exit(1);
}
// Resolve Token
let tokenAddressHex20 = TRON_ZERO_ADDRESS_HEX;
let tokenLabel = 'Native TRX';
if (tokenType === 'trc20') {
if (!TRC20_TOKEN_DEFAULT) {
console.error('TRC20_TOKEN_ADDRESS not defined in .env');
process.exit(1);
}
tokenAddressHex20 = base58ToHex20(TRC20_TOKEN_DEFAULT);
tokenLabel = `TRC20 (${TRC20_TOKEN_DEFAULT})`;
} else if (tokenType && tokenType.startsWith('T')) {
tokenAddressHex20 = base58ToHex20(tokenType);
tokenLabel = `TRC20 (${tokenType})`;
}
// Convert walletId to bytes32
let walletIdHex;
if (walletIdRaw.startsWith('0x') && walletIdRaw.length === 66) {
walletIdHex = walletIdRaw;
} else {
const buf = Buffer.alloc(32, 0);
const src = Buffer.from(walletIdRaw, 'utf8');
if (src.length > 32) throw new Error('walletId exceeds 32 bytes');
src.copy(buf, 0);
walletIdHex = '0x' + buf.toString('hex');
}
const treasuryHex20 = base58ToHex20(treasuryBase58);
console.log('\n════════════════════════════════════════════════════════');
console.log(' Sweep — Tron');
console.log('════════════════════════════════════════════════════════');
console.log(` Wallet ID: ${walletIdRaw}`);
console.log(` Token: ${tokenLabel}`);
console.log(` Dest: ${treasuryBase58} (${treasuryHex20})`);
console.log('════════════════════════════════════════════════════════\n');
// 1. Pre-compute receiver by calling the contract
console.log('Pre-computing address on-chain...');
const computeResult = await tronWeb.transactionBuilder.triggerConstantContract(
FACTORY_ADDRESS,
'computeWalletAddress(bytes32)',
{},
[{ type: 'bytes32', value: walletIdHex }]
);
const receiverHex20 = resultToHex20(computeResult.constant_result[0]);
const receiverBase58 = hex20ToBase58(receiverHex20);
console.log(`Receiver: ${receiverBase58} (${receiverHex20})`);
// 2. Check balance if native
if (tokenAddressHex20 === TRON_ZERO_ADDRESS_HEX) {
const balanceSun = await tronWeb.trx.getBalance(receiverBase58);
console.log(`TRX Balance: ${balanceSun / 1_000_000} TRX`);
} else {
console.log('TRC20 sweep requested.');
}
// 3. Check if already deployed
const isDeployedResult = await tronWeb.transactionBuilder.triggerConstantContract(
FACTORY_ADDRESS,
'isDeployedReceiver(address)',
{},
[{ type: 'address', value: receiverHex20 }]
);
const isDeployed = parseInt(isDeployedResult.constant_result[0], 16) !== 0;
console.log(`Already deployed?: ${isDeployed ? 'YES' : 'NO'}`);
const recipients = [[treasuryHex20, 10000]]; // 10000 bps = 100%
// 4. Estimate energy
console.log('Estimating energy consumption...');
try {
const functionSelector = isDeployed
? 'sweepExisting(address,address,(address,uint256)[])'
: 'deployAndSweep(bytes32,address,(address,uint256)[])';
const params = isDeployed
? [
{ type: 'address', value: receiverHex20 },
{ type: 'address', value: tokenAddressHex20 },
{ type: '(address,uint256)[]', value: recipients }
]
: [
{ type: 'bytes32', value: walletIdHex },
{ type: 'address', value: tokenAddressHex20 },
{ type: '(address,uint256)[]', value: recipients }
];
const estimation = await tronWeb.transactionBuilder.triggerConstantContract(
FACTORY_ADDRESS,
functionSelector,
{},
params
);
if (estimation.result && estimation.result.result) {
const energyUsed = estimation.energy_used || 0;
// Current approximate price: 420 Sun per energy unit
const estTrx = (energyUsed * 420) / 1_000_000;
console.log(` Energy estimated: ${energyUsed.toLocaleString()}`);
console.log(` Estimated cost (burning TRX): ~${estTrx.toFixed(2)} TRX`);
}
} catch (e) {
console.log(' Could not estimate energy (can be ignored if wallet has no funds yet)');
}
// 5. Execute
try {
let txId;
if (isDeployed) {
console.log('\nCalling sweepExisting...');
txId = await sendTx(
'sweepExisting(address,address,(address,uint256)[])',
[
{ type: 'address', value: receiverHex20 },
{ type: 'address', value: tokenAddressHex20 },
{ type: '(address,uint256)[]', value: recipients }
]
);
console.log('sweepExisting sent');
} else {
console.log('\nCalling deployAndSweep...');
txId = await sendTx(
'deployAndSweep(bytes32,address,(address,uint256)[])',
[
{ type: 'bytes32', value: walletIdHex },
{ type: 'address', value: tokenAddressHex20 },
{ type: '(address,uint256)[]', value: recipients }
]
);
console.log('deployAndSweep sent');
}
console.log(' Tx hash:', txId);
console.log('Waiting for confirmation and cost report...');
// Wait for the network to process the Transaction Info
await new Promise(r => setTimeout(r, 5000));
const info = await tronWeb.trx.getTransactionInfo(txId);
if (info && info.id) {
console.log('\nReal Cost Report:');
console.log('════════════════════════════════════════════════════════');
const energyUsed = info.receipt ? (info.receipt.energy_usage_total || 0) : 0;
const netUsed = info.net_usage || 0;
const feePaid = info.fee || 0;
console.log(` Energy consumed : ${energyUsed.toLocaleString()}`);
console.log(` Bandwidth used : ${netUsed.toLocaleString()}`);
console.log(` TRX consumed : ${feePaid / 1_000_000} TRX`);
console.log('════════════════════════════════════════════════════════');
}
} catch (err) {
console.error('\nTransaction error:');
console.error(err?.output ? JSON.stringify(err.output) : err?.message || JSON.stringify(err));
throw err;
}
}
main().catch(err => {
console.error('\nError:', err);
process.exit(1);
});
Key TronWeb APIs used
triggerSmartContract — Builds and sends a state-changing transaction. This is the equivalent of calling a contract function that modifies state. The feeLimit parameter (set to 400 TRX = 400,000,000 SUN) caps the maximum energy cost. If the transaction exceeds this limit, it reverts.
triggerConstantContract — Makes a read-only call (equivalent to eth_call). Used to call computeWalletAddress() and isDeployedReceiver() without spending energy or creating a transaction. The result comes back in constant_result[0] as a hex string.
Struct encoding. TronWeb v6 expects struct arrays as nested arrays: [[address, uint256], [address, uint256]]. Each inner array represents one Recipient struct. The type is specified as (address,uint256)[].
Running sweeps
# Sweep TRC20 tokens (uses TRC20_TOKEN_ADDRESS from .env)
node scripts/sweep.js --wallet user_123 --token trc20
# Sweep native TRX
node scripts/sweep.js --wallet user_123 --token native
# Sweep a specific TRC20 token to a custom address
node scripts/sweep.js --wallet user_123 --token TTokenBase58Address --to TCustomDestination
The script automatically determines whether to call deployAndSweep (if the receiver is not yet deployed) or sweepExisting (if it is already deployed). After the transaction confirms, it reports the actual energy, bandwidth, and TRX consumed.
Energy Optimization
Tron’s resource model is fundamentally different from Ethereum’s gas model. Understanding it is essential for keeping costs low in production.
Energy vs Bandwidth
Tron has two computational resources:
Energy is consumed by smart contract execution (analogous to Ethereum gas for computation). Every opcode costs a specific amount of energy. You can obtain energy in two ways:
- Staking TRX — Lock TRX to receive a daily energy allowance proportional to your stake. This is free recurring energy.
- Burning TRX — If you do not have enough staked energy, TRX is burned at the current rate (approximately 420 SUN per energy unit, though this fluctuates).
Bandwidth is consumed by the transaction data size (the raw bytes of the transaction). You get 600 free bandwidth points per day per account. Beyond that, TRX is burned. A typical contract call uses 300-500 bandwidth points.
Cost structure for wallet operations
| Operation | Approximate Energy | Approximate Cost (burning) |
|---|---|---|
deployWallet | ~80,000-120,000 | ~40-55 TRX |
deployAndSweep (1 recipient) | ~150,000-200,000 | ~65-90 TRX |
sweepExisting (1 recipient) | ~30,000-50,000 | ~15-25 TRX |
sweepExisting (5 recipients) | ~80,000-120,000 | ~35-55 TRX |
These are approximate values. Actual costs depend on network conditions and the specific token being swept.
Optimization strategies
1. Stake TRX for energy. For a payment platform processing hundreds of sweeps per day, staking is essential. The break-even point is typically around 10-20 transactions per day. Beyond that, staking saves more TRX than it locks.
2. Batch when possible. The deployAndSweep function is cheaper than separate deployWallet + sweepExisting calls because it avoids paying bandwidth twice.
3. Use immutables. The WalletReceiver contract uses immutable variables instead of storage. Each SLOAD costs 2,100 energy; an immutable read costs 3. With three immutables per receiver, that saves 6,291 energy per sweep.
4. Set appropriate fee limits. The feeLimit caps the maximum TRX burned. Set it high enough to cover your worst-case scenario but not so high that a bug could drain your account. 400 TRX (400,000,000 SUN) is a reasonable limit for deploy+sweep operations.
5. Monitor the energy price. The burn rate (SUN per energy unit) changes based on network utilization. During high-usage periods, consider delaying non-urgent sweeps.
6. Use triggerConstantContract for estimation. Before executing a sweep, call the function as a constant (read-only) to estimate energy consumption. This is free and helps you predict costs.
Estimating costs programmatically
The sweep script already includes energy estimation. Here is the relevant pattern:
const estimation = await tronWeb.transactionBuilder.triggerConstantContract(
FACTORY_ADDRESS,
'deployAndSweep(bytes32,address,(address,uint256)[])',
{},
params
);
if (estimation.result && estimation.result.result) {
const energyUsed = estimation.energy_used || 0;
const estTrx = (energyUsed * 420) / 1_000_000; // Approximate
console.log(`Estimated energy: ${energyUsed}`);
console.log(`Estimated cost: ~${estTrx.toFixed(2)} TRX`);
}
The energy_used field from triggerConstantContract gives you a simulation of the energy that would be consumed. Multiply by the current burn rate (check tronscan.org for the latest rate) to estimate the TRX cost.
Security Considerations
The security model mirrors the EVM version with Tron-specific adaptations.
Access control
- Owner (deployer) — Can pause the factory, change the relayer, and trigger emergency sweeps. Should be a multisig in production.
- Relayer — An EOA authorized to deploy wallets and trigger sweeps. This is the hot key used by the backend. If compromised, the attacker can only sweep funds to the pre-specified recipients (though they could front-run legitimate sweeps).
- WalletReceiver — Accepts calls from both the relayer and the factory. This dual authorization enables atomic deploy+sweep.
ITRC20 quirks
Since we do not check transfer() return values, a malicious TRC20 token could silently fail transfers. In practice, the factory should maintain an allow-list of supported tokens (USDT, USDC, etc.) and reject unknown tokens at the backend level, before the on-chain call.
Energy griefing
An attacker could send very small amounts of many different TRC20 tokens to a receiver, forcing the relayer to spend energy sweeping dust. Mitigation: the backend should check balances before triggering sweeps and only sweep above a minimum threshold.
Reentrancy
The nonReentrant modifier on sweep() and sweepNative() prevents reentrancy attacks. This is critical for sweepNative(), which uses .call{value}() to send TRX. A malicious recipient contract could attempt to re-enter the sweep function during the TRX transfer. The ReentrancyGuard prevents this.
CREATE2 and front-running
Since the CREATE2 address is deterministic and publicly computable, an attacker who knows the walletId could compute the address and send funds to it before the legitimate user. This is not an issue for the wallet factory pattern because:
- Funds sent to the pre-computed address are safe: only the relayer or factory can deploy the receiver and sweep.
- The walletId should be unpredictable (use UUIDs or database IDs, not sequential integers).
Production checklist
- Deploy with a multisig as the owner
- Use a dedicated relayer EOA (not the owner)
- Maintain a backend allow-list of supported TRC20 tokens
- Set minimum sweep thresholds to avoid dust griefing
- Stake TRX for energy on the relayer account
- Monitor the relayer’s TRX balance for energy burns
- Set up alerts for
EmergencySweepevents - Test the full flow on Nile testnet before mainnet deployment
- Verify off-chain address computation matches on-chain for multiple walletIds
- Implement rate limiting on the backend to prevent relayer key abuse
Conclusion
You have built a complete deterministic wallet factory for the Tron network. The system pre-computes deposit addresses off-chain, deploys lightweight receiver contracts on demand via CREATE2, and sweeps funds to configurable destinations using BPS-based splits.
What makes the Tron version different from EVM:
- 0x41 prefix in CREATE2 instead of 0xff changes address computation entirely
- ITRC20 interface instead of IERC20/SafeERC20 because TRC20
transfer()does not return a bool - Energy + bandwidth model instead of gas requires different cost optimization strategies
- Base58check addresses (T…) instead of hex (0x…) require conversion utilities
- Ownable instead of Ownable2Step to save energy on ownership transfers
- TronBox + TronWeb instead of Hardhat + ethers.js for development and interaction
- Chain ID 728126428 hardcoded instead of using
block.chainid
The architecture is production-ready for USDT payment platforms, exchange deposit systems, or any application that needs to programmatically receive and route TRC20 tokens on Tron. The same walletId-based addressing scheme allows your backend to manage deposits across EVM chains and Tron with a unified interface, differing only in the address computation prefix and token interface.
The complete source code is available in the wallet-factory-multichain repository on GitHub.
Built by Beltsys Labs. Licensed under MIT.


