Wallet Factory Determinista para Cadenas EVM con CreateX (CREATE3)

Wallet Factory Determinista para Cadenas EVM con CreateX (CREATE3)

# solidity# ethereum# web3# smartcontracts
Wallet Factory Determinista para Cadenas EVM con CreateX (CREATE3)Beltsys Labs

Despliega la misma dirección de wallet en Ethereum, Polygon, BSC, Arbitrum y cualquier cadena EVM usando despliegues deterministas CREATE3. Guía completa con Solidity, Hardhat y TypeScript.

El Problema: Un Usuario, Muchas Cadenas, Muchas Direcciones

Imagina que estás construyendo una plataforma de pagos cripto. Un comerciante necesita recibir pagos en USDT, pero sus clientes usan diferentes cadenas: uno paga en Ethereum, otro en Polygon, otro en BSC. El enfoque ingenuo es generar una dirección diferente para cada cadena, lo que crea varios problemas:

  • Experiencia de usuario fragmentada: el comerciante debe compartir 5+ direcciones diferentes y monitorear cada una por separado.
  • Complejidad operacional: tu backend necesita mapear cada dirección a cada cadena, gestionar llaves privadas múltiples y sincronizar estados.
  • Errores costosos: un usuario envía fondos a la dirección de Ethereum pero en la cadena de Polygon. Si las direcciones fueran idénticas, los fondos llegarían sin problema. Con direcciones diferentes, podrían perderse para siempre.

La solución ideal es una sola dirección que funcione en todas las cadenas EVM. Cuando un usuario deposita fondos en 0xABC...123, ya sea en Ethereum, Polygon o Arbitrum, los fondos siempre llegan al mismo contrato controlado por tu plataforma.

Esto es exactamente lo que construiremos en este tutorial: una Wallet Factory determinista que genera la misma dirección de contrato en cualquier cadena EVM compatible, usando despliegues CREATE3 a través del contrato CreateX.


La Solución: Despliegues Deterministas CREATE3

Para entender por qué CREATE3 es la herramienta correcta, primero necesitamos comparar los tres mecanismos de despliegue disponibles en la EVM:

Característica CREATE CREATE2 CREATE3 (vía CreateX)
Determinismo No -- depende del nonce del deployer Parcial -- depende del bytecode Total -- solo depende del salt
Fórmula de dirección keccak256(rlp(deployer, nonce)) keccak256(0xff, deployer, salt, keccak256(initCode)) keccak256(0xff, proxy, ...) donde proxy se crea con CREATE2
Cambia si el bytecode cambia N/A Si -- cualquier cambio al constructor args modifica la dirección No -- la dirección es independiente del bytecode
Cross-chain con misma dirección Imposible sin sincronizar nonces Requiere exactamente el mismo bytecode en todas las cadenas Garantizado con el mismo salt y deployer
Caso de uso Despliegues normales Factories, counterfactual wallets Infraestructura multichain

Por que CREATE2 no es suficiente

CREATE2 parece determinista, pero la dirección depende del initCode (bytecode de creación + argumentos del constructor). Si tu contrato acepta una dirección de relayer como parámetro del constructor, y ese relayer es diferente en cada cadena, la dirección resultante sera diferente. CREATE3 resuelve esto al desacoplar completamente la dirección final del bytecode del contrato.

CreateX: La Factory Canonica

CreateX es un contrato factory desplegado en la misma dirección (0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed) en mas de 30 cadenas EVM. Fue creado por pcaversaccio (el autor de Snekmate y contribuidor de OpenZeppelin) y funciona como una infraestructura publica y sin permisos.

Su funcion deployCreate3 recibe un salt de 32 bytes y el initCode del contrato a desplegar. Internamente:

  1. Usa CREATE2 para desplegar un contrato proxy minimal a una dirección determinista.
  2. El proxy ejecuta el initCode con CREATE, desplegando tu contrato real.
  3. La dirección final solo depende del salt y la dirección de CreateX -- nunca del bytecode de tu contrato.

Arquitectura General

El sistema completo tiene tres componentes principales que interactuan de la siguiente manera:

┌─────────────┐         ┌──────────────────┐         ┌─────────────────┐
│             │         │                  │         │                 │
│   Backend   │────────>│  WalletFactory   │────────>│  WalletReceiver │
│  (Relayer)  │  deploy │  (Ownable2Step)  │ CREATE3 │   (Immutable)   │
│             │  sweep  │                  │   via   │                 │
└─────────────┘         │  ┌────────────┐  │ CreateX │  ┌───────────┐  │
                        │  │  buildSalt │  │         │  │  sweep()  │  │
                        │  │  compute   │  │         │  │  sweepNat │  │
                        │  └────────────┘  │         │  │  receive  │  │
                        └──────────────────┘         └─────────────────┘
                                │                            │
                                │ computeWalletAddress       │ Fondos
                                ▼                            ▼
                        ┌──────────────────┐         ┌─────────────────┐
                        │     CreateX      │         │   Tesorería /   │
                        │  0xba5Ed...a5Ed  │         │  Destinatarios  │
                        └──────────────────┘         └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Flujo de operación:

  1. Precomputo: el backend llama a computeWalletAddress(walletId) para obtener la dirección determinista. Esto es una lectura gratuita (view function) que no requiere transacción.
  2. Depósito: el usuario envía fondos (USDT, USDC, ETH, etc.) a la dirección precomputada. El contrato aun no existe, pero las cadenas EVM permiten enviar tokens ERC20 a cualquier dirección.
  3. Deploy + Sweep: cuando el backend detecta un depósito, llama a deployAndSweep() que atomicamente despliega el WalletReceiver y barre los fondos hacia la tesorería.
  4. Sweeps posteriores: si llegan mas fondos a la misma dirección, el backend usa sweepExisting() sin necesidad de redesplegar.

Configuración del Proyecto

Inicialización

mkdir wallet-factory-evm && cd wallet-factory-evm
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts dotenv
npx hardhat init
Enter fullscreen mode Exit fullscreen mode

Selecciona "Create a TypeScript project" cuando Hardhat te lo pregunte.

Variables de Entorno

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

# Llaves privadas (NUNCA las subas a git)
PRIVATE_KEY=0x_tu_llave_privada_del_relayer

# RPCs
RPC_URL_SEPOLIA=https://sepolia.infura.io/v3/TU_API_KEY
RPC_URL_AMOY=https://rpc-amoy.polygon.technology

# Direcciones
RELAYER_ADDRESS=0x_tu_direccion_de_relayer
TREASURY_ADDRESS=0x_tu_direccion_de_tesoreria

# Verificación
ETHER_SCAN_API=tu_etherscan_api_key
Enter fullscreen mode Exit fullscreen mode

Importante: agrega .env a tu .gitignore inmediatamente.

Configuración de Hardhat

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "dotenv/config";

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.28",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
      evmVersion: "paris",
    },
  },
  networks: {
    hardhat: {},
    amoy: {
      url: process.env.RPC_URL_AMOY,
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
      chainId: 80002,
      timeout: 120000,
      gasPrice: 35000000000,
    },
    sepolia: {
      url: process.env.RPC_URL_SEPOLIA,
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
      chainId: 11155111,
    }
  },
  etherscan: {
    apiKey: process.env.ETHER_SCAN_API || "",
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

Puntos clave de esta configuración:

  • Solidity 0.8.28 con optimizer habilitado en 200 runs -- un buen equilibrio entre costo de despliegue y costo de ejecución.
  • evmVersion: "paris" para máxima compatibilidad cross-chain (evita opcodes de Shanghai/Cancun que algunas L2 aun no soportan).
  • Dos testnets: Sepolia (Ethereum) y Amoy (Polygon) para validar el determinismo cross-chain antes de ir a mainnet.

Contrato 1: WalletReceiver

El WalletReceiver es el contrato que se despliega en la dirección determinista. Su responsabilidad es simple: recibir fondos y permitir que el relayer autorizado los barra hacia uno o mas destinatarios.

Contrato Completo

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title  WalletReceiver
 * @author Beltsys Labs
 * @notice Minimal contract that receives ERC20 or native tokens and allows
 *         the relayer to sweep funds to one or multiple destinations.
 * @dev    Deployed via CreateX (CREATE3), which guarantees the same address
 *         across all supported chains. All parameters are immutable to minimize
 *         gas usage and storage costs.
 * @custom:version 1.0.1
 * @custom:security-contact info@beltsys.com
 */
contract WalletReceiver is ReentrancyGuard {
    using SafeERC20 for IERC20;

    uint256 public constant MAX_RECIPIENTS = 5;

    struct Recipient {
        address wallet;
        uint256 bps;
    }

    address public immutable relayer;
    address public immutable factory;
    bytes32 public immutable walletId;

    event Swept(
        bytes32 indexed walletId,
        address indexed token,
        uint256 totalAmount,
        uint256 recipientCount
    );

    event NativeSwept(
        bytes32 indexed walletId,
        uint256 totalAmount,
        uint256 recipientCount
    );

    error NotAuthorized();
    error NothingToSweep();
    error TransferFailed();
    error ZeroAddress();
    error InvalidRecipients();
    error TooManyRecipients();
    error BpsDoNotSum();
    error BpsOverflow();

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

    modifier onlyAuthorized() {
        if (msg.sender != relayer && msg.sender != factory) revert NotAuthorized();
        _;
    }

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

        uint256 balance = IERC20(token).balanceOf(address(this));
        if (balance == 0) revert NothingToSweep();

        uint256 distributed;

        for (uint256 i = 0; i < recipients.length; i++) {
            uint256 amount;

            if (i == recipients.length - 1) {
                amount = balance - distributed;
            } else {
                amount = (balance * recipients[i].bps) / 10_000;
            }

            if (amount > 0) {
                IERC20(token).safeTransfer(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 {}
}
Enter fullscreen mode Exit fullscreen mode

Desglose Sección por Sección

Esqueleto e Inmutables

contract WalletReceiver is ReentrancyGuard {
    using SafeERC20 for IERC20;

    address public immutable relayer;
    address public immutable factory;
    bytes32 public immutable walletId;
Enter fullscreen mode Exit fullscreen mode

El contrato hereda de ReentrancyGuard de OpenZeppelin para proteger las funciones de barrido contra ataques de reentrancia. Los tres parámetros principales se almacenan como immutable:

  • relayer: la EOA del backend autorizada para ejecutar barridos. Al ser immutable, su valor se almacena directamente en el bytecode del contrato, ahorrando ~2,100 gas en cada lectura (comparado con una variable de storage que cuesta un SLOAD).
  • factory: la dirección del WalletFactory. Permite que la factory ejecute barridos directamente durante deployAndSweep().
  • walletId: un identificador unico de 32 bytes para trazabilidad on-chain. Típicamente, es el ID de usuario de tu base de datos convertido a bytes32.

El patrón using SafeERC20 for IERC20 envuelve todas las llamadas a funciones ERC20 con verificaciones adicionales. Esto es critico porque tokens como USDT no retornan bool en transfer(), lo que causaría un revert silencioso sin SafeERC20.

Sweep ERC20 con Basis Points

La función sweep() implementa un sistema de distribución basado en basis points (bps):

Valor bps Porcentaje Uso tipico
10,000 100% Destinatario unico (tesorería)
9,800 98% Comerciante en un split 98/2
200 2% Comisión de plataforma
5,000 50% Split 50/50 entre socios

El truco del remainder: cuando divides un entero entre 10,000, puedes perder decimales por redondeo. El ultimo destinatario recibe balance - distributed en lugar de calcular su porcentaje, asegurando que no quede "polvo" (dust) atrapado en el contrato.

SafeERC20: la línea IERC20(token).safeTransfer(...) es critica. Tokens como USDT en Ethereum no cumplen exactamente con el estándar ERC20 -- su función transfer() no retorna un bool. Sin SafeERC20, la llamada podría fallar silenciosamente.

Sweep de Token Nativo

(bool ok, ) = recipients[i].wallet.call{value: amount}("");
if (!ok) revert TransferFailed();
Enter fullscreen mode Exit fullscreen mode

Usamos call{value} en lugar de transfer() porque es el método recomendado desde el Istanbul hard fork. La protección contra reentrancia la provee el modifier nonReentrant de OpenZeppelin.


Contrato 2: WalletFactory

El WalletFactory es el contrato central que orquesta los despliegues deterministas y las operaciones de barrido.

Contrato Completo

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "./WalletReceiver.sol";

interface ICreateX {
    function deployCreate3(bytes32 salt, bytes memory initCode)
        external payable returns (address);

    function computeCreate3Address(bytes32 salt, address deployer)
        external view returns (address);
}

/**
 * @title  WalletFactory
 * @author Beltsys Labs
 * @notice Factory contract that wraps CreateX to deploy deterministic
 *         WalletReceiver instances with the same address across all supported chains.
 */
contract WalletFactory is Ownable2Step, Pausable {

    ICreateX public constant CREATEX =
        ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);

    uint256 public constant MAX_RECIPIENTS = 5;
    address public relayer;
    mapping(address => bool) public isDeployedReceiver;

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

    error NotRelayer();
    error ZeroAddress();
    error InvalidToken();
    error InvalidReceiver();
    error TooManyRecipients();
    error InvalidSaltConstruction();

    constructor(address _relayer) Ownable(msg.sender) {
        if (_relayer == address(0)) revert ZeroAddress();
        relayer = _relayer;
    }

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

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

    function buildSalt(bytes32 walletId) public view returns (bytes32 salt) {
        salt = bytes32(
            abi.encodePacked(
                address(this),
                hex"00",
                bytes11(uint88(uint256(keccak256(abi.encodePacked(walletId)))))
            )
        );
        assert(uint8(salt[20]) == 0x00);
    }

    function computeWalletAddress(bytes32 walletId)
        external view returns (address walletAddress)
    {
        bytes32 salt = buildSalt(walletId);
        bytes32 guardedSalt = keccak256(
            abi.encodePacked(
                bytes32(uint256(uint160(address(this)))),
                salt
            )
        );
        return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
    }

    function deployWallet(bytes32 walletId)
        external onlyRelayer whenNotPaused returns (address receiver)
    {
        bytes memory initCode = _buildInitCode(walletId);
        receiver = CREATEX.deployCreate3(buildSalt(walletId), initCode);
        isDeployedReceiver[receiver] = true;
        emit WalletDeployed(walletId, receiver, block.chainid);
    }

    function deployAndSweep(
        bytes32 walletId,
        address token,
        WalletReceiver.Recipient[] calldata recipients
    )
        external onlyRelayer whenNotPaused validRecipients(recipients.length)
        returns (address receiver)
    {
        bytes memory initCode = _buildInitCode(walletId);
        receiver = CREATEX.deployCreate3(buildSalt(walletId), initCode);
        isDeployedReceiver[receiver] = true;
        emit WalletDeployed(walletId, receiver, block.chainid);

        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 {
            uint256 balance = IERC20(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);
        }
    }

    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 _buildInitCode(bytes32 walletId) internal view returns (bytes memory initCode) {
        return abi.encodePacked(
            type(WalletReceiver).creationCode,
            abi.encode(relayer, address(this), walletId)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Puntos Clave

La factory hereda de Ownable2Step (transferencia de propiedad en dos pasos) y Pausable (permite pausar operaciones en emergencia).

La dirección de CreateX (0xba5Ed...a5Ed) es constante porque es la misma en todas las cadenas EVM.

El mapping isDeployedReceiver registra todos los receivers desplegados, previniendo que un atacante pase una dirección maliciosa.


El Salt: Entendiendo el Formato de CreateX

El salt es la pieza mas critica del sistema. CreateX espera un bytes32 con un formato específico:

┌──────────────────────┬────────┬─────────────────────────┐
│     20 bytes         │ 1 byte │       11 bytes          │
│  address(factory)    │  0x00  │  keccak256(walletId)    │
│  Permissioned guard  │  Flag  │  Unique entropy         │
└──────────────────────┴────────┴─────────────────────────┘
        Byte 0-19        Byte 20       Byte 21-31
Enter fullscreen mode Exit fullscreen mode
  • Bytes 0-19: dirección del factory. CreateX verifica que msg.sender coincida, previniendo front-running.
  • Byte 20: 0x00 = misma dirección en todas las cadenas. 0x01 = dirección diferente por cadena.
  • Bytes 21-31: 11 bytes derivados del walletId via keccak256.

La Trampa del Guard

CreateX tiene una función interna _guard() que re-hashea el salt cuando los primeros 20 bytes coinciden con msg.sender. Esto significa que computeCreate3Address() (que es view y no aplica el guard) dará una dirección diferente si no replicas manualmente el re-hash:

bytes32 guardedSalt = keccak256(
    abi.encodePacked(
        bytes32(uint256(uint160(address(this)))),
        salt
    )
);
return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
Enter fullscreen mode Exit fullscreen mode

Lección clave: si tus direcciones precomputadas no coinciden con las desplegadas, el guard es la causa mas probable.


Scripts de Operación

Script de Despliegue

import { ethers } from 'hardhat'
import * as fs from 'fs'

const CREATEX_ADDRESS = ethers.getAddress('0xba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed')

async function main() {
  const [deployer] = await ethers.getSigners()
  const network = await ethers.provider.getNetwork()

  // Verificar CreateX
  const code = await ethers.provider.getCode(CREATEX_ADDRESS)
  if (code === '0x') throw new Error('CreateX no está deployado en esta red')

  const RELAYER_ADDRESS = process.env.RELAYER_ADDRESS
  if (!RELAYER_ADDRESS) throw new Error('Falta RELAYER_ADDRESS en .env')

  const Factory = await ethers.getContractFactory('WalletFactory')
  const factory = await Factory.deploy(RELAYER_ADDRESS)
  await factory.waitForDeployment()

  const factoryAddress = await factory.getAddress()
  console.log(`WalletFactory deployado en: ${factoryAddress}`)
}

main().catch(err => { console.error(err); process.exit(1) })
Enter fullscreen mode Exit fullscreen mode

Generación de Direcciones Off-Chain

import { ethers } from "ethers";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();

const FACTORY_ABI = [
    "function computeWalletAddress(bytes32 walletId) external view returns (address)"
];

async function main() {
    const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";

    const provider = new ethers.JsonRpcProvider(process.env.RPC_URL_SEPOLIA);
    const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
    const factory = new ethers.Contract(
        deployments["11155111"].WalletFactory,
        FACTORY_ABI,
        provider
    );

    // MongoDB ObjectId (24 hex chars) → bytes32 (64 hex chars, zero-padded)
    let walletId = "0x" + mongoId.padStart(64, "0");

    const walletAddress = await factory.computeWalletAddress(walletId);
    console.log(`Dirección de Wallet: ${walletAddress}`);
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Barrido de Fondos (Lazy Deploy)

import { ethers } from "ethers";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();

const FACTORY_ABI = [
    "function deployAndSweep(bytes32 walletId, address token, tuple(address wallet, uint256 bps)[] recipients) external returns (address)",
    "function sweepExisting(address receiver, address token, tuple(address wallet, uint256 bps)[] recipients) external",
    "function isDeployedReceiver(address) external view returns (bool)",
    "function computeWalletAddress(bytes32 walletId) external view returns (address)"
];

async function main() {
    const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";
    const tokenAddress = process.argv[3] || "0x0000000000000000000000000000000000000000";

    const provider = new ethers.JsonRpcProvider(process.env.RPC_URL_SEPOLIA);
    const relayer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
    const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
    const factory = new ethers.Contract(deployments["11155111"].WalletFactory, FACTORY_ABI, relayer);

    let walletId = "0x" + mongoId.padStart(64, "0");
    const walletAddress = await factory.computeWalletAddress(walletId);
    const isDeployed = await factory.isDeployedReceiver(walletAddress);

    const recipients = [{ wallet: process.env.TREASURY_ADDRESS, bps: 10000 }];

    let tx;
    if (isDeployed) {
        tx = await factory.sweepExisting(walletAddress, tokenAddress, recipients);
    } else {
        tx = await factory.deployAndSweep(walletId, tokenAddress, recipients);
    }

    await tx.wait();
    console.log("Retiro completado.");
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Consideraciones de Seguridad

┌─────────────────────────────────────────────────────────────┐
│                    OWNER (Multisig)                          │
│  - setRelayer()         - pause() / unpause()               │
│  - emergencySweep()     - emergencySweepNative()            │
│  - transferOwnership()  (Ownable2Step: requiere accept)     │
└──────────────────────────┬──────────────────────────────────┘
                           │ onlyOwner
┌──────────────────────────┼──────────────────────────────────┐
│                    RELAYER (Backend EOA)                     │
│  - deployWallet()       - deployAndSweep()                  │
│  - sweepExisting()                                          │
│  Todas requieren: whenNotPaused + onlyRelayer               │
└──────────────────────────┬──────────────────────────────────┘
                           │ onlyAuthorized
┌──────────────────────────┼──────────────────────────────────┐
│                    WALLET RECEIVER                           │
│  - sweep()              - sweepNative()                     │
│  - receive()  (payable, sin restricción)                    │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
Riesgo Mitigación
Relayer comprometido pause() + setRelayer() para rotar
Owner comprometido Ownable2Step requiere aceptación explícita
Reentrancia en sweep ReentrancyGuard en WalletReceiver
Receiver malicioso isDeployedReceiver mapping valida origen
Dust por redondeo Ultimo destinatario recibe remainder
Token no-estándar (USDT) SafeERC20 para todas las transferencias
Front-running Salt permissioned (bytes 0-19 = factory address)

Conclusión

En este tutorial implementamos un sistema completo de wallets deterministas multichain:

  1. WalletReceiver: contrato minimalista que recibe y redistribuye fondos usando basis points.
  2. WalletFactory: orquestador que utiliza CreateX para desplegar receivers en la misma dirección across cadenas EVM.
  3. Scripts de operación: despliegue, generación de direcciones off-chain, y barrido de fondos.

Lecciones Clave

  • CREATE3 desacopla la dirección del bytecode: los argumentos del constructor no afectan la dirección final.
  • El guard de CreateX transforma el salt silenciosamente: siempre replica la lógica de _guard().
  • El patrón lazy deploy ahorra gas significativamente: solo despliegas cuando hay fondos reales.
  • SafeERC20 no es opcional: tokens como USDT rompen el estándar ERC20.
  • Ownable2Step previene pérdida de control: la transferencia en dos pasos puede salvar millones.

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


Desarrollado por Beltsys Labs. Licencia MIT.