Beltsys Labs
Beltsys Labs
Avanzado Tron TRC20 Solidity

Wallet Factory Determinista en Tron con TronBox

Daniel
Daniel · Full Stack Developer
42 min de lectura
Wallet Factory Determinista en Tron con TronBox

Introducción

Tron procesa más transferencias de USDT que cualquier otra blockchain. Con más de 50 mil millones de dólares en volumen diario de TRC20-USDT, comisiones bajas y un tiempo de bloque de tres segundos, la red se ha convertido en la columna vertebral de los pagos con stablecoins en mercados emergentes. Si estás construyendo una plataforma de pagos, un exchange o cualquier sistema que necesite recibir y enrutar fondos de forma programática, Tron es una red que no puedes ignorar.

Este tutorial te guía paso a paso en la construcción de una wallet factory determinista en Tron: un sistema que pre-computa direcciones de depósito off-chain usando CREATE2, despliega contratos receptores ligeros bajo demanda y barre los tokens TRC20 o TRX entrantes hacia una o más wallets de tesorería. La arquitectura sigue el mismo patrón utilizado en la versión EVM de este sistema, pero adaptada a las particularidades de la Tron Virtual Machine (TVM).

Lo que vas a construir:

  • Un contrato WalletFactory que despliega instancias deterministas de WalletReceiver usando CREATE2
  • Un contrato WalletReceiver que retiene fondos y permite barridos autorizados con splits basados en BPS
  • Un script off-chain que pre-computa direcciones Tron antes del despliegue
  • Un script de barrido que ejecuta la recolección de fondos vía TronWeb

¿Por qué wallets deterministas?

En una plataforma de pagos, cada cliente recibe una dirección de depósito única. El enfoque ingenuo es desplegar un contrato por cada cliente de antemano, lo cual desperdicia energía y TRX. Con CREATE2, calculas la dirección off-chain usando una fórmula determinista, se la entregas al cliente, y solo despliegas el contrato cuando realmente llegan los fondos. Se garantiza que la dirección coincida porque la fórmula es determinista: misma factory, mismo salt, mismo bytecode, misma dirección. Siempre.

Prerequisitos:

  • Conocimiento sólido de Solidity (0.8.x)
  • Familiaridad con conceptos EVM (CREATE2, codificación ABI)
  • Node.js 18+ instalado
  • Comprensión básica de la red Tron

Tron vs EVM: Diferencias Clave

Antes de entrar en el código, necesitas entender las diferencias fundamentales entre Tron y las cadenas EVM estándar. Tron ejecuta una EVM modificada llamada TVM (Tron Virtual Machine), que es mayormente compatible con Ethereum pero diverge en varias áreas críticas.

CaracterísticaTron (TVM)EVM (Ethereum, Polygon, etc.)
Formato de direcciónBase58check, empieza con T (ej., TJRab...)Hex, empieza con 0x (ej., 0x1234...)
Prefijo de dirección0x41 (representación hex interna)0x (sin prefijo adicional al estándar)
Prefijo CREATE20x410xff
Estándar de tokenTRC20ERC20
TRC20 transfer()Retorna void (sin bool)Retorna bool
Modelo de gasEnergía + BandwidthGas
Moneda nativaTRX (1 TRX = 1,000,000 SUN)ETH (1 ETH = 10^18 Wei)
Herramientas de desarrolloTronBoxHardhat / Foundry
Chain ID728126428 (mainnet)1 (Ethereum mainnet)
Tiempo de bloque~3 segundos~12 segundos (Ethereum)
Librería CREATE2Opcode nativo (integrado)Nativo o vía CreateX

Las dos diferencias que más afectarán tu código son el prefijo 0x41 en el cálculo de direcciones CREATE2 y el valor de retorno de TRC20 transfer(). Cubriremos ambas en detalle.

El Prefijo 0x41

Toda dirección Tron tiene una representación hex interna que comienza con 0x41. Cuando la TVM calcula una dirección CREATE2, usa 0x41 como byte de prefijo en lugar de 0xff como lo hace Ethereum. Esta diferencia de un solo byte significa que el mismo contrato factory, salt y bytecode producirán una dirección completamente diferente en Tron que en una cadena EVM. Tus scripts de cálculo de direcciones off-chain deben tener esto en cuenta.

Fórmula CREATE2 de Tron:

address=keccak256(0x41factory_addresssaltkeccak256(init_code))[12:]

Fórmula CREATE2 de EVM:

address=keccak256(0xfffactory_addresssaltkeccak256(init_code))[12:]

La dirección resultante de 20 bytes se prefija con 0x41 y se codifica en base58check para producir la familiar dirección Tron con formato T....

El Problema TRC20

Este es el problema específico de Tron más importante que encontrarás. En Ethereum y todas las cadenas EVM estándar, la función transfer() de ERC20 retorna un bool:

// ERC20 standard
function transfer(address to, uint256 amount) external returns (bool);

En Tron, los tokens TRC20 nativos (incluyendo USDT, el más importante) no retornan un bool:

// TRC20 on Tron — no return value
function transfer(address to, uint256 amount) external;

Esto significa que la librería SafeERC20 de OpenZeppelin, que envuelve las llamadas de transferencia y verifica el valor de retorno, revertirá cuando se use con tokens TRC20 nativos de Tron. La función safeTransfer espera un valor de retorno true o datos de retorno vacíos que pueda interpretar. Los tokens TRC20 de Tron retornan datos que no cumplen con esta expectativa, causando que la verificación de seguridad falle.

La solución es directa: definir una interfaz ITRC20 personalizada que coincida con el comportamiento real de Tron.

// 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);
}

Esta interfaz declara transfer() como retornando void. Cuando llamas a ITRC20(token).transfer(to, amount), Solidity no intentará decodificar un valor de retorno, y la llamada tendrá éxito siempre que la lógica interna del token no revierta.

La contrapartida es que pierdes la capacidad de verificar si una transferencia tuvo éxito mediante el valor de retorno. En la práctica, esto es aceptable porque los tokens TRC20 de Tron revierten en caso de fallo en lugar de retornar false. El comportamiento es efectivamente igual a una transferencia verificada: si no revierte, fue exitosa.

Visión General de la Arquitectura

El sistema sigue una arquitectura de tres contratos con pre-cálculo de direcciones off-chain:

CRE(c.W(AW(TRNojaOPTairfwreBomslwaElmeaaecadplnu2lmlclaiceueaseuatlspk.ttbattyoeuiejeFlbRaertrens-aelebryIynd)wc,ecl,,dtaUpt)see)/sAlsroaiPleerlvIer-ytetcrso=emnpkduestcOueOvcMR1FfsTdniaie-ifiR-aknc5n-nCTc2ieacg2.hC5mirlh0.aR6aveaC.iE(lecdiRTnAwsienERa:Tacps:AXdElofitTdd2lnueipEtre,etnnnr2oeptrdtaesltIasst-fsordc,icoyi)tvoorsgsinmmgpwa(pureeesulerreB)tacspPeeusSwissaivwetdteerodhrerspe0ssxs41

Flujo:

  1. El backend llama a compute-wallet.js con un walletId (ej., "user_123") para obtener una dirección Tron determinista
  2. Al cliente se le entrega esa dirección T... para depositar tokens TRC20 o TRX
  3. Cuando llegan los fondos, el backend llama a deployAndSweep() en la factory, que despliega el receiver y barre los fondos hacia la tesorería de forma inmediata en una sola transacción
  4. Para depósitos posteriores en la misma dirección, se llama a sweepExisting() ya que el receiver ya está desplegado

Configuración del Proyecto

Inicializar el proyecto TronBox

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

Configurar TronBox

Reemplaza el contenido de tronbox-config.js con la configuración completa:

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'
      }
    }
  }
};

Desglose de redes:

  • mainnet — Red de producción de Tron. userFeePercentage: 100 significa que el llamante paga el 100% del costo de energía.
  • shasta — Testnet pública con TRX de prueba gratuitos en shasta.tronex.io.
  • nile — Otra testnet, generalmente más estable. Obtén TRX de prueba en nileex.io.
  • development — Entorno local Docker de TronBox (imagen tronbox/tre).

Variables de entorno

Crea un archivo .env en la raíz del proyecto:

# 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"
  }
}

Ten en cuenta que ethers no aparece como dependencia directa pero está disponible de forma transitiva a través de TronBox. El script compute-wallet.js lo usa para el hashing keccak256.

Contrato 1: Interfaz ITRC20

El primer contrato es el más simple pero posiblemente la adaptación más importante específica de Tron:

// 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);
}

Por qué existe esto:

En cadenas EVM, el estándar ERC20 define transfer() como retornando bool. La librería SafeERC20 de OpenZeppelin envuelve las llamadas para manejar tokens que no siguen el estándar (el USDT en Ethereum, por ejemplo, no retorna un valor en versiones antiguas). En Tron, la situación es diferente: la implementación estándar de TRC20 no retorna bool en transfer(). Esto no es un bug en tokens específicos; es cómo funciona TRC20 en Tron.

Usar la interfaz IERC20 de OpenZeppelin compilaría sin problemas, pero en tiempo de ejecución el decodificador ABI intentaría leer un valor de retorno bool de la llamada. Como el token TRC20 no retorna uno, la transacción revertirá con un error de decodificación. Al declarar transfer() como external sin tipo de retorno, Solidity genera una llamada que ignora los datos de retorno por completo.

balanceOf() retorna uint256 tanto en ERC20 como en TRC20, por lo que funciona de manera idéntica.

Contrato 2: WalletReceiver

El WalletReceiver es un contrato mínimo desplegado vía CREATE2 para cada wallet de usuario. Retiene fondos hasta que el relayer o la factory ejecutan un barrido. Toda la configuración se almacena en variables inmutables, que se incrustan en el bytecode del contrato en el momento del despliegue y no cuestan lecturas de almacenamiento en tiempo de ejecución.

// 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 info@beltsys.com
 */

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 {}
}

Decisiones de diseño clave

Inmutables sobre almacenamiento. Las variables relayer, factory y walletId son todas immutable. Se establecen una vez en el constructor y se incrustan directamente en el bytecode desplegado. Leer un inmutable cuesta 3 gas (una instrucción PUSH) versus 2,100 gas para un SLOAD. Dado que cada WalletReceiver se despliega por usuario, minimizar las lecturas de almacenamiento reduce directamente los costos de energía en miles de wallets.

ITRC20 en lugar de SafeERC20. Como se explicó en la sección ITRC20, llamamos a _token.transfer() directamente sin verificar un valor de retorno. El comentario en el código lo hace explícito: los tokens TRC20 nativos de Tron no retornan un bool.

Distribución basada en BPS. Los destinatarios se especifican como pares (address wallet, uint256 bps) donde bps significa puntos base (basis points). 10,000 BPS = 100%. El contrato verifica que todos los valores BPS sumen exactamente 10,000, previniendo distribuciones parciales. El último destinatario recibe balance - distributed en lugar de un cálculo por BPS para evitar polvo de redondeo.

Doble autorización. El modificador onlyAuthorized permite tanto al relayer (llamadas directas desde EOA) como a la factory (llamadas vía deployAndSweep) ejecutar barridos. Este diseño de doble vía habilita tanto flujos atómicos de deploy+sweep como barridos independientes de receivers ya desplegados.

ReentrancyGuard. Tanto sweep() como sweepNative() están protegidos por el modificador nonReentrant de OpenZeppelin. Esto es especialmente importante para sweepNative(), que usa .call{value}() de bajo nivel para enviar TRX y podría ser vulnerable a reentrancia si un destinatario es un contrato malicioso.

MAX_RECIPIENTS = 5. Esto limita las iteraciones del bucle para prevenir un consumo de energía ilimitado. En la práctica, la mayoría de los barridos van a una sola dirección de tesorería (1 destinatario, 10,000 BPS).

Contrato 3: WalletFactory

El WalletFactory es el orquestador central. Despliega instancias de WalletReceiver vía CREATE2, gestiona la dirección del relayer y proporciona operaciones atómicas de deploy+sweep.

// 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 info@beltsys.com
 */

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);
    }
}

Desglose función por función

Ownable + Pausable (no Ownable2Step). En cadenas EVM, usamos Ownable2Step para transferencias de propiedad más seguras. En Tron, Ownable2Step agrega una transacción extra que consume energía y bandwidth para el paso de aceptación. Dado que las transacciones en Tron cuestan TRX reales (la energía no es gratuita a menos que hagas staking), se prefiere el Ownable más simple. El propietario aún puede ser transferido, pero sin la confirmación en dos pasos. Para despliegues en producción donde el propietario es un multisig, esta compensación es aceptable.

computeWalletAddress(). Esta es la versión on-chain del pre-cálculo de direcciones. Recibe un walletId (bytes32), calcula el salt como keccak256(abi.encodePacked(walletId)), construye el init code completo (bytecode de creación + argumentos del constructor), y aplica la fórmula CREATE2 de Tron con el prefijo 0x41. Esta función es view y no cuesta energía al llamarla.

deployWallet(). Despliegue independiente sin barrido. Usa la sintaxis nativa de Solidity new WalletReceiver{salt: salt}(...), que se mapea al opcode CREATE2. La dirección desplegada se registra en isDeployedReceiver y se emite el evento WalletDeployed con el chain ID hardcodeado 728126428 (Tron mainnet). Este chain ID está hardcodeado en lugar de usar block.chainid porque los entornos TronBox pueden reportar chain IDs diferentes durante las pruebas.

deployAndSweep(). La operación atómica: despliega un receiver e inmediatamente barre sus fondos en una sola transacción. Esta es la función principal usada en producción. El flujo es:

  1. Desplegar el WalletReceiver con CREATE2
  2. Registrarlo en isDeployedReceiver
  3. Verificar si el receiver tiene balance del token especificado
  4. Si tiene, llamar a sweep() o sweepNative() en el receiver

Si token es address(0), barre TRX nativo. De lo contrario, usa ITRC20 para verificar el balance TRC20 y barrer tokens. Nota el uso de ITRC20 en lugar de IERC20 en la verificación de balance.

sweepExisting(). Para receivers ya desplegados (segundo depósito en adelante). Valida la dirección del receiver contra isDeployedReceiver para prevenir llamadas a contratos arbitrarios.

emergencySweep() y emergencySweepNative(). Funciones exclusivas del propietario para recuperar fondos en caso de que la clave del relayer sea comprometida o se pierda. Omiten la verificación del relayer y barren el 100% de los fondos a un solo destino (10,000 BPS a una dirección). emergencySweep maneja tokens TRC20 y rechaza explícitamente address(0) como token, mientras que emergencySweepNative maneja TRX.

El Prefijo 0x41: La Fórmula CREATE2 de Tron

El cálculo de direcciones CREATE2 es el núcleo del patrón de wallets deterministas. Entender exactamente cómo funciona en Tron es fundamental para que las direcciones off-chain y on-chain coincidan.

La fórmula estándar de EVM

En Ethereum y todas las cadenas compatibles con EVM, CREATE2 calcula la dirección como:

address=keccak256(0xffdeployersaltkeccak256(init_code))[12:]

El byte de prefijo 0xff previene colisiones con el opcode CREATE estándar (que usa el nonce del deployer). El resultado son los últimos 20 bytes del hash keccak256.

La fórmula de Tron

Tron reemplaza 0xff con 0x41:

address=keccak256(0x41deployersaltkeccak256(init_code))[12:]

¿Por qué 0x41? Porque 0x41 es el byte de prefijo de direcciones Tron. Todas las direcciones Tron en su representación hex comienzan con 41. Cuando ves una dirección T..., es una codificación base58check de 41 + 20 bytes. La TVM usa este mismo prefijo en la preimagen de CREATE2 para mantener la derivación de direcciones consistente con el espacio de direcciones de Tron.

Cálculo paso a paso

Así es como funciona internamente la función computeWalletAddress:

1234567891.........0.wscciipraaaronnradtlleniiewdrltasttiHroettCCmaentirooassAIouddghsddnceeedCtHrooa===drs=eAh[krreabg0cwasx[[[cHs4aae======1fsiks5aan2h8bkWack]cli5[cyeabrettt61htcliecoC(2eecl.acr]op:csaeetaydr]k3ktnikee(22Rco2aHi"(5eon5dam4"6cdC6dsa1u(eeo(rhg"swi(dieeeavrens])rleeis_lrlt1e'aC]h2tsyoe3Iecdx"dcroe()o,n)apmsdapftddiarrdlcueeetcsddot1sro233T3l)wbyrb022o2a)iy,Aytsttrtbbbabthewgeyyylycasttt:t2zoleeee0edlsss8sTree5b.otTy.sIrbt.)doye)ntsfeopsrrmeaftix

El detalle importante es que el initCode incluye los argumentos del constructor concatenados al bytecode de creación. Si cualquier argumento del constructor cambia (diferente relayer, diferente dirección de factory, diferente walletId), el hash del init code cambia, y por lo tanto la dirección resultante cambia. Esto es lo que hace que cada walletId produzca una dirección única y determinista.

Despliegue

El script de migración maneja el despliegue del WalletFactory y convierte la dirección hex resultante al formato base58check de Tron.

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 explicado

TronBox retorna las direcciones de contratos en formato hex (41...). Para obtener la dirección legible T..., necesitas:

  1. Tomar el payload hex de 21 bytes (prefijo 41 + 20 bytes de dirección)
  2. Hacer doble hash SHA-256: sha256(sha256(payload))
  3. Tomar los primeros 4 bytes como checksum
  4. Concatenar el checksum al payload (25 bytes en total)
  5. Codificar los 25 bytes en base58

Esta es la misma codificación que Bitcoin usa para direcciones (base58check), solo con un byte de versión diferente (0x41 para Tron en lugar de 0x00 para Bitcoin mainnet).

Ejecutar el despliegue

# Compile contracts
npx tronbox compile

# Deploy to Nile testnet
npx tronbox migrate --network nile

# Deploy to mainnet
npx tronbox migrate --network mainnet

Después del despliegue, copia la dirección Tron de la salida y configúrala como WALLET_FACTORY_ADDRESS en tu archivo .env.

Cálculo de Direcciones Off-Chain

El script compute-wallet.js reproduce la fórmula CREATE2 completamente off-chain. Este es el script que tu backend llama para generar direcciones de depósito para clientes sin realizar ninguna transacción on-chain.

#!/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);
    }
})();

Cómo funciona el script

Paso 1: Cargar el bytecode compilado. El script lee build/contracts/WalletReceiver.json, que TronBox genera durante la compilación. El campo bytecode contiene el bytecode de creación (el código que se ejecuta durante el despliegue y retorna el bytecode de ejecución).

Paso 2: Convertir direcciones. Las direcciones Tron vienen en formato base58 (T...). El script las convierte a buffers de 20 bytes usando addressToBytes20(). Esta función maneja múltiples formatos de entrada: base58 (T...), hex con prefijo Tron (41...) y hex con prefijo 0x.

Paso 3: Codificar walletId. Si el walletId es un string como "user_123", se convierte a bytes UTF-8 y se rellena con ceros a la derecha hasta completar 32 bytes. Si ya es un string hex con prefijo 0x de 66 caracteres (32 bytes), se usa directamente.

Paso 4: Codificar argumentos del constructor en ABI. El constructor recibe (address _relayer, address _factory, bytes32 _walletId). La codificación ABI empaqueta cada argumento en una palabra de 32 bytes: las direcciones se rellenan con 12 bytes cero a la izquierda, y bytes32 se usa tal cual.

Paso 5: Construir el init code. El init code es el bytecode de creación concatenado con los argumentos del constructor codificados en ABI. Esto es lo que el opcode CREATE2 hashea.

Paso 6: Calcular el salt. El salt es keccak256(walletId), coincidiendo con lo que hace el contrato Solidity con keccak256(abi.encodePacked(walletId)).

Paso 7: Aplicar la fórmula CREATE2 de Tron. Concatenar [0x41, factory(20), salt(32), keccak256(initCode)(32)] y hashear con keccak256. Tomar los últimos 20 bytes como la dirección raw.

Paso 8: Convertir al formato Tron. Anteponer 41 a la dirección hex de 20 bytes y codificar en base58check para obtener la dirección final T....

Ejecutar el 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

La salida mostrará la dirección Tron que se generará cuando se llame a deployWallet() o deployAndSweep() con el mismo walletId en la factory desplegada.

Verificación contra la función on-chain

También puedes llamar a la función computeWalletAddress de la factory directamente vía 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);
    }
};

Ejecútalo con:

npx tronbox exec compute.js --network nile

Ambos métodos deberían producir la misma dirección. Si no coinciden, verifica que tu .env tiene las direcciones correctas de factory y relayer, y que el bytecode compilado no ha cambiado desde el despliegue.

Barrido de Fondos

El script sweep.js maneja el flujo completo de verificar balances, estimar costos de energía y ejecutar la transacción de barrido. Usa la API triggerSmartContract de TronWeb en lugar de las abstracciones de contrato de nivel superior, lo que da más control sobre la codificación de parámetros (importante para arrays de structs en 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);
});

APIs clave de TronWeb utilizadas

triggerSmartContract — Construye y envía una transacción que modifica estado. Es el equivalente a llamar una función de contrato que modifica el estado. El parámetro feeLimit (establecido en 400 TRX = 400,000,000 SUN) limita el costo máximo de energía. Si la transacción excede este límite, revierte.

triggerConstantContract — Realiza una llamada de solo lectura (equivalente a eth_call). Se usa para llamar a computeWalletAddress() e isDeployedReceiver() sin gastar energía ni crear una transacción. El resultado viene en constant_result[0] como un string hex.

Codificación de structs. TronWeb v6 espera arrays de structs como arrays anidados: [[address, uint256], [address, uint256]]. Cada array interno representa un struct Recipient. El tipo se especifica como (address,uint256)[].

Ejecutar barridos

# 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

El script determina automáticamente si llamar a deployAndSweep (si el receiver aún no está desplegado) o sweepExisting (si ya está desplegado). Después de que la transacción se confirma, reporta la energía, bandwidth y TRX realmente consumidos.

Optimización de Energía

El modelo de recursos de Tron es fundamentalmente diferente al modelo de gas de Ethereum. Entenderlo es esencial para mantener los costos bajos en producción.

Energía vs Bandwidth

Tron tiene dos recursos computacionales:

Energía se consume por la ejecución de contratos inteligentes (análogo al gas de Ethereum para computación). Cada opcode cuesta una cantidad específica de energía. Puedes obtener energía de dos formas:

  • Staking de TRX — Bloquea TRX para recibir una asignación diaria de energía proporcional a tu stake. Es energía gratuita y recurrente.
  • Quema de TRX — Si no tienes suficiente energía por staking, se quema TRX a la tasa actual (aproximadamente 420 SUN por unidad de energía, aunque esto fluctúa).

Bandwidth se consume por el tamaño de datos de la transacción (los bytes raw de la transacción). Obtienes 600 puntos de bandwidth gratuitos por día por cuenta. Más allá de eso, se quema TRX. Una llamada de contrato típica usa 300-500 puntos de bandwidth.

Estructura de costos para operaciones de wallet

OperaciónEnergía aproximadaCosto aproximado (quema)
deployWallet~80,000-120,000~40-55 TRX
deployAndSweep (1 destinatario)~150,000-200,000~65-90 TRX
sweepExisting (1 destinatario)~30,000-50,000~15-25 TRX
sweepExisting (5 destinatarios)~80,000-120,000~35-55 TRX

Estos son valores aproximados. Los costos reales dependen de las condiciones de la red y del token específico que se esté barriendo.

Estrategias de optimización

1. Hacer staking de TRX para energía. Para una plataforma de pagos que procesa cientos de barridos por día, el staking es esencial. El punto de equilibrio suele estar alrededor de 10-20 transacciones por día. Más allá de eso, el staking ahorra más TRX de los que bloquea.

2. Agrupar cuando sea posible. La función deployAndSweep es más barata que llamadas separadas de deployWallet + sweepExisting porque evita pagar bandwidth dos veces.

3. Usar inmutables. El contrato WalletReceiver usa variables inmutables en lugar de almacenamiento. Cada SLOAD cuesta 2,100 de energía; una lectura de inmutable cuesta 3. Con tres inmutables por receiver, eso ahorra 6,291 de energía por barrido.

4. Establecer fee limits apropiados. El feeLimit limita el máximo de TRX quemados. Establécelo lo suficientemente alto para cubrir tu peor escenario pero no tan alto como para que un bug pueda vaciar tu cuenta. 400 TRX (400,000,000 SUN) es un límite razonable para operaciones de deploy+sweep.

5. Monitorear el precio de energía. La tasa de quema (SUN por unidad de energía) cambia según la utilización de la red. Durante períodos de alto uso, considera retrasar barridos no urgentes.

6. Usar triggerConstantContract para estimación. Antes de ejecutar un barrido, llama a la función como constante (solo lectura) para estimar el consumo de energía. Es gratuito y te ayuda a predecir costos.

Estimación de costos programáticamente

El script de barrido ya incluye estimación de energía. Aquí está el patrón relevante:

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`);
}

El campo energy_used de triggerConstantContract te da una simulación de la energía que se consumiría. Multiplica por la tasa de quema actual (consulta tronscan.org para la tasa más reciente) para estimar el costo en TRX.

Consideraciones de Seguridad

El modelo de seguridad refleja la versión EVM con adaptaciones específicas de Tron.

Control de acceso

  • Owner (deployer) — Puede pausar la factory, cambiar el relayer y ejecutar barridos de emergencia. Debería ser un multisig en producción.
  • Relayer — Un EOA autorizado para desplegar wallets y ejecutar barridos. Es la clave caliente usada por el backend. Si se compromete, el atacante solo puede barrer fondos hacia los destinatarios pre-especificados (aunque podría anticiparse a barridos legítimos).
  • WalletReceiver — Acepta llamadas tanto del relayer como de la factory. Esta doble autorización habilita el deploy+sweep atómico.

Particularidades de ITRC20

Dado que no verificamos los valores de retorno de transfer(), un token TRC20 malicioso podría hacer que las transferencias fallen silenciosamente. En la práctica, la factory debería mantener una lista blanca de tokens soportados (USDT, USDC, etc.) y rechazar tokens desconocidos a nivel de backend, antes de la llamada on-chain.

Griefing de energía

Un atacante podría enviar cantidades muy pequeñas de muchos tokens TRC20 diferentes a un receiver, forzando al relayer a gastar energía barriendo polvo. Mitigación: el backend debería verificar balances antes de ejecutar barridos y solo barrer por encima de un umbral mínimo.

Reentrancia

El modificador nonReentrant en sweep() y sweepNative() previene ataques de reentrancia. Esto es crítico para sweepNative(), que usa .call{value}() para enviar TRX. Un contrato destinatario malicioso podría intentar re-entrar la función de barrido durante la transferencia de TRX. El ReentrancyGuard previene esto.

CREATE2 y front-running

Dado que la dirección CREATE2 es determinista y computable públicamente, un atacante que conozca el walletId podría calcular la dirección y enviar fondos a ella antes que el usuario legítimo. Esto no es un problema para el patrón de wallet factory porque:

  1. Los fondos enviados a la dirección pre-computada están seguros: solo el relayer o la factory pueden desplegar el receiver y barrer.
  2. El walletId debería ser impredecible (usa UUIDs o IDs de base de datos, no enteros secuenciales).

Checklist de producción

  • Desplegar con un multisig como propietario
  • Usar un EOA de relayer dedicado (no el propietario)
  • Mantener una lista blanca de tokens TRC20 soportados en el backend
  • Establecer umbrales mínimos de barrido para evitar griefing de polvo
  • Hacer staking de TRX para energía en la cuenta del relayer
  • Monitorear el balance de TRX del relayer para quema de energía
  • Configurar alertas para eventos EmergencySweep
  • Probar el flujo completo en la testnet Nile antes del despliegue en mainnet
  • Verificar que el cálculo de direcciones off-chain coincida con el on-chain para múltiples walletIds
  • Implementar rate limiting en el backend para prevenir abuso de la clave del relayer

Conclusión

Has construido una wallet factory determinista completa para la red Tron. El sistema pre-computa direcciones de depósito off-chain, despliega contratos receptores ligeros bajo demanda vía CREATE2, y barre fondos hacia destinos configurables usando splits basados en BPS.

Lo que hace diferente a la versión Tron respecto a EVM:

  • Prefijo 0x41 en CREATE2 en lugar de 0xff cambia el cálculo de direcciones por completo
  • Interfaz ITRC20 en lugar de IERC20/SafeERC20 porque transfer() de TRC20 no retorna un bool
  • Modelo de energía + bandwidth en lugar de gas requiere estrategias de optimización de costos diferentes
  • Direcciones base58check (T…) en lugar de hex (0x…) requieren utilidades de conversión
  • Ownable en lugar de Ownable2Step para ahorrar energía en transferencias de propiedad
  • TronBox + TronWeb en lugar de Hardhat + ethers.js para desarrollo e interacción
  • Chain ID 728126428 hardcodeado en lugar de usar block.chainid

La arquitectura está lista para producción en plataformas de pagos con USDT, sistemas de depósito de exchanges, o cualquier aplicación que necesite recibir y enrutar tokens TRC20 en Tron de forma programática. El mismo esquema de direccionamiento basado en walletId permite a tu backend gestionar depósitos a través de cadenas EVM y Tron con una interfaz unificada, diferenciándose únicamente en el prefijo de cálculo de direcciones y la interfaz de tokens.

El código fuente completo está disponible en el repositorio wallet-factory-multichain en GitHub.


Desarrollado por Beltsys Labs. Licencia MIT.

Aviso: El código presentado en este tutorial tiene fines educativos. Antes de desplegarlo en una red principal (mainnet), se recomienda realizar una auditoría de seguridad profesional. Beltsys Labs no se responsabiliza del uso que se haga de este código.

Tron TRC20 Solidity TronBox CREATE2

¿Necesitas ayuda con tu proyecto?

Nuestro equipo de expertos puede implementar estas soluciones por ti.

Contacte con nosotros