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:
- Usa CREATE2 para desplegar un contrato proxy minimal a una dirección determinista.
- El proxy ejecuta el
initCodecon CREATE, desplegando tu contrato real. - 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:
Flujo de operación:
- 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. - 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.
- Deploy + Sweep: cuando el backend detecta un depósito, llama a
deployAndSweep()que atomicamente despliega elWalletReceivery barre los fondos hacia la tesorería. - 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
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
Importante: agrega
.enva tu.gitignoreinmediatamente.
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;
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;
// ─── Constants ────────────────────────────────────────────────────────────
/// @notice Maximum number of recipients allowed per sweep to prevent out-of-gas.
/// Each ERC20 transfer costs ~25,000 gas. With 5 recipients the sweep
/// loop stays well within safe gas limits (~125,000 gas for transfers alone).
uint256 public constant MAX_RECIPIENTS = 5;
// ─── Structs ──────────────────────────────────────────────────────────────
/**
* @notice Defines a fund recipient and their share of the total balance.
* @param wallet Destination address
* @param bps Share in basis points (10000 = 100%)
*/
struct Recipient {
address wallet;
uint256 bps;
}
// ─── Immutables ───────────────────────────────────────────────────────────
/// @notice The only address authorized to execute sweeps (backend EOA)
address public immutable relayer;
/// @notice The factory contract that deployed this receiver
address public immutable factory;
/// @notice Unique identifier for on-chain traceability
bytes32 public immutable walletId;
// ─── Events ───────────────────────────────────────────────────────────────
/**
* @notice Emitted when an ERC20 sweep is executed successfully.
* @param walletId Unique identifier of the wallet
* @param token Address of the swept ERC20 token
* @param totalAmount Total amount swept
* @param recipientCount Number of recipients funds were distributed to
*/
event Swept(
bytes32 indexed walletId,
address indexed token,
uint256 totalAmount,
uint256 recipientCount
);
/**
* @notice Emitted when a native token sweep is executed successfully.
* @param walletId Unique identifier of the wallet
* @param totalAmount Total native amount swept
* @param recipientCount Number of recipients funds were distributed to
*/
event NativeSwept(
bytes32 indexed walletId,
uint256 totalAmount,
uint256 recipientCount
);
// ─── Errors ───────────────────────────────────────────────────────────────
/// @notice Caller is not the relayer or factory
error NotAuthorized();
/// @notice Token or native balance is zero, nothing to sweep
error NothingToSweep();
/// @notice Native token transfer to a recipient failed
error TransferFailed();
/// @notice A required address parameter is the zero address
error ZeroAddress();
/// @notice Recipients array is empty
error InvalidRecipients();
/// @notice Recipients array exceeds MAX_RECIPIENTS limit
error TooManyRecipients();
/// @notice Sum of all recipient bps does not equal 10000
error BpsDoNotSum();
/// @notice A single recipient bps value exceeds 10000
error BpsOverflow();
// ─── Constructor ──────────────────────────────────────────────────────────
/**
* @notice Initializes the receiver with immutable references.
* @param _relayer Address of the authorized relayer (backend EOA)
* @param _factory Address of the WalletFactory that deployed this contract
* @param _walletId Unique identifier for this wallet instance
*/
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 ────────────────────────────────────────────────────────────
/// @dev Restricts access to the relayer or the factory contract
modifier onlyAuthorized() {
if (msg.sender != relayer && msg.sender != factory) revert NotAuthorized();
_;
}
// ─── Main Functions ───────────────────────────────────────────────────────
/**
* @notice Transfers the entire ERC20 balance to one or multiple recipients.
* @dev The last recipient receives the remainder to avoid dust from rounding.
* All bps values must sum to exactly 10000.
* Maximum of MAX_RECIPIENTS recipients allowed to prevent out-of-gas.
* Examples:
* Single recipient: [{ wallet: treasury, bps: 10000 }]
* 98/2 split: [{ wallet: A, bps: 9800 }, { wallet: B, bps: 200 }]
* @param token Address of the ERC20 token to sweep
* @param recipients Array of recipients with their basis point shares
*/
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;
// Last recipient gets the remainder to avoid dust from rounding
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);
}
/**
* @notice Transfers the entire native token balance (ETH, MATIC, BNB, etc.)
* to one or multiple recipients.
* @dev Protected against reentrancy attacks via the nonReentrant modifier.
* The last recipient receives the remainder to avoid dust from rounding.
* All bps values must sum to exactly 10000.
* Maximum of MAX_RECIPIENTS recipients allowed to prevent out-of-gas.
* @param recipients Array of recipients with their basis point shares
*/
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);
}
/// @notice Allows the contract to receive native tokens (ETH, MATIC, BNB, etc.)
receive() external payable {}
}
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;
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 durantedeployAndSweep().walletId: un identificador unico de 32 bytes para trazabilidad on-chain. Típicamente, es el ID de usuario de tu base de datos convertido abytes32.
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.
Errores y Eventos Personalizados
error NotAuthorized();
error NothingToSweep();
error TransferFailed();
error ZeroAddress();
error InvalidRecipients();
error TooManyRecipients();
error BpsDoNotSum();
error BpsOverflow();
Desde Solidity 0.8.4, los errores personalizados (custom errors) reemplazan a los require(condition, "string message") tradicionales. Las ventajas son:
- Gas: un error personalizado usa ~24 bytes de calldata vs. cientos de bytes para un string de error.
- Tipado: los errores son parte del ABI del contrato, lo que permite al frontend decodificarlos automáticamente.
- Selectores: cada error tiene un selector de 4 bytes unico, facilitando el diagnóstico en exploradores de bloques.
El Constructor y el Modifier de Autorización
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();
_;
}
El constructor valida que ni el relayer ni la factory sean la dirección cero. Esto previene despliegues accidentales con parámetros vacíos que bloquearían los fondos permanentemente.
El modifier onlyAuthorized permite dos callers: el relayer (para operaciones normales) y la factory (para el deploy + sweep atómico). Este doble acceso es necesario porque durante deployAndSweep(), es la WalletFactory quien llama a sweep() inmediatamente después del despliegue.
La Función receive()
receive() external payable {}
Esta función vacía permite que el contrato reciba tokens nativos (ETH, MATIC, BNB). Sin ella, cualquier transferencia de token nativo al contrato revertiría. Es especialmente importante porque los fondos pueden llegar antes de que el contrato sea desplegado – la EVM permite enviar ETH a cualquier dirección, exista o no un contrato allí.
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 sistema de basis points ofrece precisión de 0.01% sin necesidad de números decimales. La suma de todos los bps debe ser exactamente 10,000 (100%).
El truco del remainder: cuando divides un entero entre 10,000, puedes perder decimales por redondeo. Por ejemplo, si el balance es 999 y hay dos destinatarios con 5,000 bps cada uno:
- Destinatario 1:
999 * 5000 / 10000 = 499(se pierden 0.5 tokens) - Destinatario 2:
999 - 499 = 500(recibe el remainder)
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 o revertir inesperadamente.
Sweep de Token Nativo
(bool ok, ) = recipients[i].wallet.call{value: amount}("");
if (!ok) revert TransferFailed();
Para tokens nativos (ETH, MATIC, BNB), usamos call{value} en lugar de transfer() o send(). La razón es importante:
| Método | Gas forwarded | Problema |
|---|---|---|
transfer() | 2,300 gas fijo | Falla si el destinatario es un contrato con fallback que usa mas de 2,300 gas |
send() | 2,300 gas fijo | Mismo problema, pero retorna false en vez de revertir |
call{value}() | Todo el gas restante | Compatible con cualquier destinatario, pero requiere protección contra reentrancia |
Usamos call{value} porque es el metodo recomendado desde el Istanbul hard fork que cambió los costos de gas de ciertos opcodes. 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.
* @dev Uses CREATE3 via CreateX for deterministic cross-chain addresses.
* Salt: [address(this)] [0x00] [11-byte hash of walletId].
*
* @custom:version 1.0.1
* @custom:security-contact info@beltsys.com
*/
contract WalletFactory is Ownable2Step, Pausable {
// ─── State ────────────────────────────────────────────────────────────────
/// @notice CreateX factory deployed at the same address on 30+ chains
ICreateX public constant CREATEX =
ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);
/// @notice Maximum number of recipients allowed per sweep to bound gas usage
/// @dev Each ERC20 transfer costs ~25,000 gas. 5 recipients ≈ 125,000 gas for transfers.
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
/// @dev Used to validate receivers in sweepExisting and emergencySweep
mapping(address => bool) public isDeployedReceiver;
// ─── Events ───────────────────────────────────────────────────────────────
/**
* @notice Emitted when a new WalletReceiver is deployed.
* @param walletId Unique identifier of the wallet
* @param receiver Address of the deployed WalletReceiver
* @param chainId Chain ID where the deployment occurred
*/
event WalletDeployed(
bytes32 indexed walletId,
address indexed receiver,
uint256 indexed chainId
);
/**
* @notice Emitted when a deploy and sweep are executed atomically.
* @param walletId Unique identifier of the wallet
* @param receiver Address of the deployed WalletReceiver
* @param token Address of the swept ERC20 token
* @param totalAmount Total amount swept
* @param recipientCount Number of recipients funds were distributed to
*/
event DeployedAndSwept(
bytes32 indexed walletId,
address indexed receiver,
address indexed token,
uint256 totalAmount,
uint256 recipientCount
);
/**
* @notice Emitted when an emergency sweep is executed by the owner.
* @param receiver Address of the WalletReceiver holding the funds
* @param token Address of the recovered token (address(0) for native)
* @param destination Address that received the recovered funds
*/
event EmergencySweep(
address indexed receiver,
address indexed token,
address indexed destination
);
/**
* @notice Emitted when the relayer address is updated.
* @param oldRelayer Previous relayer address
* @param newRelayer New relayer address
*/
event RelayerUpdated(
address indexed oldRelayer,
address indexed newRelayer
);
// ─── Errors ───────────────────────────────────────────────────────────────
/// @notice Caller is not the authorized relayer
error NotRelayer();
/// @notice A required address parameter is the zero address
error ZeroAddress();
/// @notice Token address is the zero address
error InvalidToken();
/// @notice Receiver was not deployed by this factory
error InvalidReceiver();
/// @notice Recipients array exceeds MAX_RECIPIENTS
error TooManyRecipients();
/// @notice Salt construction resulted in an invalid byte pattern
error InvalidSaltConstruction();
// ─── Constructor ──────────────────────────────────────────────────────────
/**
* @notice Initializes the factory with the authorized relayer address.
* @param _relayer Address of the backend EOA that will trigger deployments and sweeps
*/
constructor(address _relayer) Ownable(msg.sender) {
if (_relayer == address(0)) revert ZeroAddress();
relayer = _relayer;
}
// ─── Modifiers ────────────────────────────────────────────────────────────
/// @dev Restricts access to the authorized relayer
modifier onlyRelayer() {
if (msg.sender != relayer) revert NotRelayer();
_;
}
/// @dev Validates that a receiver was deployed by this factory
modifier onlyValidReceiver(address receiver) {
if (!isDeployedReceiver[receiver]) revert InvalidReceiver();
_;
}
/// @dev Validates the recipients array length
modifier validRecipients(uint256 count) {
if (count > MAX_RECIPIENTS) revert TooManyRecipients();
_;
}
// ─── Admin Functions ──────────────────────────────────────────────────────
/**
* @notice Updates the authorized relayer address.
* @dev Only callable by the contract owner.
* Emits RelayerUpdated for full on-chain traceability.
* @param _relayer New relayer address
*/
function setRelayer(address _relayer) external onlyOwner {
if (_relayer == address(0)) revert ZeroAddress();
emit RelayerUpdated(relayer, _relayer);
relayer = _relayer;
}
/**
* @notice Pauses all relayer-callable functions.
* @dev Use immediately if the relayer key is compromised.
* Only callable by the contract owner.
*/
function pause() external onlyOwner {
_pause();
}
/**
* @notice Unpauses all relayer-callable functions.
* @dev Only callable by the contract owner.
*/
function unpause() external onlyOwner {
_unpause();
}
// ─── Salt Helpers ───────────────────────────────────────────────────────── //
/**
* @notice Builds the salt in the exact format required by CreateX.
* @dev Format: [20 bytes: address(this)][0x00][11 bytes: keccak256(walletId)]
* The first 20 bytes enable permissioned protection so only this factory
* can deploy to the computed address.
* The 0x00 flag disables cross-chain redeploy protection, ensuring the
* same receiver address is generated on every chain.
* Exposed as public to allow off-chain salt computation.
* Ref: https://github.com/pcaversaccio/createx#special-salt-prefix
* @return salt Correctly formatted salt for CreateX
*/
function buildSalt(bytes32 walletId) public view returns (bytes32 salt) {
salt = bytes32(
abi.encodePacked(
address(this), // 20 bytes — permissioned protection (must equal msg.sender when calling CreateX)
hex"00", // 1 byte — 0x00 = no cross-chain redeploy protection (same address on ALL chains)
// 0x01 = cross-chain redeploy protection (different address per chain)
// >0x01 = CreateX reverts with InvalidSalt — never use!
bytes11(uint88(uint256(keccak256(abi.encodePacked(walletId))))) // 11 bytes — unique identifier derived from walletId
)
);
// Byte 21 must be exactly 0x00 — any other value causes CreateX to revert
// This assert guards against future refactors that might accidentally change the salt format
assert(uint8(salt[20]) == 0x00);
}
// ─── Precompute ───────────────────────────────────────────────────────────
/**
* @notice Returns the deterministic address for a given walletId without deploying anything.
* @dev This call is free (view function). The same address will be returned
* on every chain where this factory is deployed at the same address.
*
* IMPORTANT: CreateX applies an internal _guard() that re-hashes the salt
* when the first 20 bytes match msg.sender AND byte 21 is 0x00:
* guardedSalt = keccak256(abi.encode(msg.sender, salt))
* We replicate this here so computeWalletAddress matches the address
* that deployCreate3 will actually deploy to.
* Ref: https://github.com/pcaversaccio/createx/blob/main/src/CreateX.sol#L691
*
* @param walletId Unique identifier of the wallet
* @return walletAddress Precomputed address of the WalletReceiver
*/
function computeWalletAddress(bytes32 walletId)
external
view
returns (address walletAddress)
{
bytes32 salt = buildSalt(walletId);
// We MUST manually apply the _guard logic here because CreateX's
// computeCreate3Address (pure/view) does NOT apply it internally.
// We use the 1-parameter version of computeCreate3Address which
// correctly uses the CreateX factory address as the deployer.
bytes32 guardedSalt = keccak256(
abi.encodePacked(
bytes32(uint256(uint160(address(this)))),
salt
)
);
return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
}
// ─── Deploy Standalone ────────────────────────────────────────────────────
/**
* @notice Deploys a WalletReceiver without performing a sweep.
* @dev Useful for eager deployment on specific chains before a payment arrives.
* Registers the deployed address in isDeployedReceiver for future validation.
* Only callable by the relayer when not paused.
* @param walletId Unique identifier of the wallet
* @return receiver Address of the deployed WalletReceiver
*/
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);
}
// ─── Deploy + Sweep ───────────────────────────────────────────────────────
/**
* @notice Deploys a WalletReceiver and sweeps funds in a single atomic transaction.
* @dev Implements a lazy deploy pattern: the contract is only deployed when
* a real payment is detected. If the token balance is zero at deploy time,
* the sweep is skipped without reverting.
* Registers the deployed address in isDeployedReceiver for future validation.
* Only callable by the relayer when not paused.
* Examples:
* Single recipient: [{ wallet: treasury, bps: 10000 }]
* 98/2 split: [{ wallet: A, bps: 9800 }, { wallet: B, bps: 200 }]
* @param walletId Unique identifier of the wallet
* @param token Address of the ERC20 token to sweep (USDT, USDC, etc.)
* @param recipients Array of recipients with their basis point shares (must sum to 10000)
* @return receiver Address of the deployed WalletReceiver
*/
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);
}
}
}
/**
* @notice Sweeps funds from an already deployed WalletReceiver.
* @dev Used for subsequent payments to the same wallet after the initial deploy.
* Only accepts receivers deployed by this factory to prevent malicious input.
* Only callable by the relayer when not paused.
* @param receiver Address of the already deployed WalletReceiver
* @param token Address of the ERC20 token to sweep
* @param recipients Array of recipients with their basis point shares (must sum to 10000)
*/
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 ──────────────────────────────────────────────────
/**
* @notice Recovers ERC20 funds from a WalletReceiver if the relayer fails.
* @dev Only callable by the contract owner.
* Only accepts receivers deployed by this factory to prevent malicious input.
* Use this if the backend goes down and the relayer cannot execute a normal sweep.
* @param receiver Address of the WalletReceiver holding the stuck funds
* @param token Address of the ERC20 token to recover
* @param destination Address that will receive the recovered funds
*/
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);
}
/**
* @notice Recovers native tokens (ETH, MATIC, BNB, etc.) from a WalletReceiver
* if the relayer fails.
* @dev Only callable by the contract owner.
* Only accepts receivers deployed by this factory to prevent malicious input.
* @param receiver Address of the WalletReceiver holding the stuck native funds
* @param destination Address that will receive the recovered native funds
*/
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);
}
// ─── Internal ─────────────────────────────────────────────────────────────
/**
* @dev Builds the creation bytecode for a new WalletReceiver instance.
* @param walletId Unique identifier to embed in the receiver
* @return initCode ABI-encoded creation bytecode with constructor arguments
*/
function _buildInitCode(bytes32 walletId)
internal
view
returns (bytes memory initCode)
{
return abi.encodePacked(
type(WalletReceiver).creationCode,
abi.encode(relayer, address(this), walletId)
);
}
}
Desglose Sección por Sección
Interfaz CreateX y Estado
interface ICreateX {
function deployCreate3(bytes32 salt, bytes memory initCode)
external payable returns (address);
function computeCreate3Address(bytes32 salt, address deployer)
external view returns (address);
}
Declaramos solo las dos funciones que necesitamos de CreateX. No importamos el contrato completo porque CreateX ya está desplegado – solo necesitamos su interfaz para hacer llamadas externas.
contract WalletFactory is Ownable2Step, Pausable {
La factory hereda de dos contratos de OpenZeppelin:
- Ownable2Step: transferencia de propiedad en dos pasos. A diferencia de
Ownablesimple, requiere que el nuevo owner acepte explícitamente la transferencia. Esto previene perder el control del contrato por enviar la propiedad a una dirección incorrecta. - Pausable: permite pausar todas las operaciones del relayer en caso de emergencia (llave comprometida, bug detectado, etc.).
ICreateX public constant CREATEX =
ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);
La dirección de CreateX es una constante porque es la misma en todas las cadenas. Al declararla como constant, el compilador la incrusta directamente en el bytecode, ahorrando gas en cada lectura.
mapping(address => bool) public isDeployedReceiver;
Este mapping registra todos los receivers desplegados por esta factory. Es critico para seguridad: las funciones sweepExisting y emergencySweep solo aceptan receivers registrados, previniendo que un atacante pase una dirección maliciosa que simule la interfaz de WalletReceiver.
Funciones de Despliegue
La factory ofrece tres modos de operación:
1. deployWallet – Despliegue Anticipado (Eager Deploy)
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);
}
Despliega el WalletReceiver sin hacer barrido. util cuando quieres pre-desplegar en una cadena antes de que lleguen fondos.
2. deployAndSweep – Despliegue Perezoso (Lazy Deploy)
Este es el patron mas eficiente y el que usarás en la mayoría de los casos:
receiver = CREATEX.deployCreate3(buildSalt(walletId), initCode);
isDeployedReceiver[receiver] = true;
if (token == address(0)) {
uint256 balance = receiver.balance;
if (balance > 0) {
WalletReceiver(payable(receiver)).sweepNative(recipients);
}
} else {
uint256 balance = IERC20(token).balanceOf(receiver);
if (balance > 0) {
WalletReceiver(payable(receiver)).sweep(token, recipients);
}
}
El patrón “lazy deploy” funciona así:
- Tu backend precomputa la dirección con
computeWalletAddress(). - El usuario envía fondos a esa dirección (el contrato aun no existe).
- Tu backend detecta el depósito via eventos o polling.
- Llamas a
deployAndSweep()que despliega + barre en una sola transacción.
El beneficio principal es que solo pagas gas de despliegue cuando hay fondos reales. Si un usuario nunca deposita, nunca gastas gas.
Observa que si el balance es cero, el barrido simplemente se salta sin revertir. Esto es intencional: permite llamar a deployAndSweep() de forma segura incluso si el usuario aun no ha depositado.
3. sweepExisting – Barrido Posterior
function sweepExisting(
address receiver, address token,
WalletReceiver.Recipient[] calldata recipients
) external onlyRelayer whenNotPaused onlyValidReceiver(receiver) validRecipients(recipients.length)
Para pagos subsiguientes a la misma dirección. El contrato ya existe, así que solo ejecuta el barrido.
Funciones de Emergencia
function emergencySweep(address receiver, address token, address destination)
external onlyOwner onlyValidReceiver(receiver)
Las funciones emergencySweep y emergencySweepNative son el mecanismo de recuperación cuando el relayer falla (backend caído, llave rotada, etc.). Solo el owner puede ejecutarlas, y envían todos los fondos a un unico destino con 10,000 bps (100%).
_buildInitCode: Construyendo el Bytecode de Creación
function _buildInitCode(bytes32 walletId)
internal view returns (bytes memory initCode)
{
return abi.encodePacked(
type(WalletReceiver).creationCode,
abi.encode(relayer, address(this), walletId)
);
}
Esta función concatena:
- El bytecode de creación del WalletReceiver (generado por el compilador de Solidity).
- Los argumentos del constructor codificados en ABI:
(relayer, factory, walletId).
El resultado es el initCode completo que CreateX usará para desplegar el contrato. Nota importante: aunque el initCode incluye el relayer (que podría variar entre cadenas), la dirección final NO depende de el gracias a CREATE3.
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 de 32 bytes:
Los Tres Segmentos
Bytes 0-19: Protección de Permisos
Los primeros 20 bytes deben ser la dirección del contrato que llama a deployCreate3(). CreateX verifica que msg.sender coincida con estos bytes. Esto garantiza que solo nuestra WalletFactory puede desplegar a las direcciones que computa.
Si alguien intenta front-run nuestro despliegue usando el mismo salt, CreateX rechazará la transacción porque su dirección no coincide con los primeros 20 bytes del salt.
Byte 20: Flag de Protección Cross-Chain
Este unico byte controla el comportamiento cross-chain:
| Valor | Comportamiento |
|---|---|
0x00 | Sin protección cross-chain – misma dirección en todas las cadenas |
0x01 | Con protección – incluye block.chainid en el hash, dirección diferente por cadena |
> 0x01 | CreateX revierte con InvalidSalt |
Usamos 0x00 porque queremos exactamente la misma dirección en Ethereum, Polygon, BSC, Arbitrum y cualquier otra cadena EVM donde CreateX esté desplegado.
Bytes 21-31: Entropía Unica
Los ultimos 11 bytes se derivan del walletId usando keccak256. Tomamos los 11 bytes menos significativos del hash:
bytes11(uint88(uint256(keccak256(abi.encodePacked(walletId)))))
Esta conversión funciona así:
keccak256(walletId)produce un hash de 32 bytes.uint256(...)lo convierte a un entero de 256 bits.uint88(...)trunca a los 88 bits (11 bytes) menos significativos.bytes11(...)lo convierte al tipo correcto.
Aunque 11 bytes ofrecen “solo” 2^88 combinaciones posibles, en la práctica esto es mas que suficiente (mas de 300 sextillones de valores unicos).
Implementación en el Contrato
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);
}
El assert al final es una guarda defensiva: verifica que el byte 20 sea efectivamente 0x00. Si un refactor futuro accidentalmente cambia la estructura del salt, esta aserción detendrá el despliegue antes de generar una dirección incorrecta.
La Trampa del Guard: Por Qué Falla la Predicción de Direcciones
Este es probablemente el aspecto mas sutil de trabajar con CreateX, y el error que mas tiempo consume si no lo conoces de antemano.
El Problema
CreateX tiene una función interna llamada _guard() que re-hashea el salt bajo ciertas condiciones. Cuando los primeros 20 bytes del salt coinciden con msg.sender Y el byte 21 es 0x00, CreateX aplica:
// Dentro de CreateX._guard():
guardedSalt = keccak256(abi.encodePacked(
bytes32(uint256(uint160(msg.sender))),
salt
));
Esto significa que el salt que pasas a deployCreate3() no es el salt que realmente se usa para computar la dirección. CreateX lo transforma internamente.
El Impacto
La función computeCreate3Address() de CreateX es una función view que no aplica el guard. Si la llamas directamente con tu salt original, obtendras una dirección diferente a la que deployCreate3() realmente desplegará.
La Solución
En nuestro computeWalletAddress(), replicamos manualmente la lógica del guard:
function computeWalletAddress(bytes32 walletId)
external view returns (address walletAddress)
{
bytes32 salt = buildSalt(walletId);
// Replicamos la lógica de _guard() de CreateX
bytes32 guardedSalt = keccak256(
abi.encodePacked(
bytes32(uint256(uint160(address(this)))),
salt
)
);
return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
}
Pasos del cálculo:
- Construimos el salt con
buildSalt(). - Aplicamos el mismo re-hash que
_guard()haría durantedeployCreate3(). - Pasamos el salt transformado a
computeCreate3Address()conaddress(CREATEX)como deployer (porque es CreateX quien realmente ejecuta el CREATE2 interno).
Lección clave: si tus direcciones precomputadas no coinciden con las desplegadas, el guard es la causa mas probable. Siempre aplica el re-hash cuando uses el modo permissioned (bytes 0-19 = tu dirección).
Script de Despliegue
El script de despliegue maneja la inicialización del WalletFactory en cada cadena:
import { ethers } from 'hardhat'
import * as fs from 'fs'
import * as path from 'path'
import { WalletFactory } from '../../typechain-types'
const CREATEX_ADDRESS = ethers.getAddress('0xba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed')
async function main() {
const [deployer] = await ethers.getSigners()
const network = await ethers.provider.getNetwork()
console.log(`\n🚀 Deploy en Chain ID: ${network.chainId}`)
console.log(` Deployer: ${deployer.address}`)
console.log(` Balance: ${ethers.formatEther(await ethers.provider.getBalance(deployer.address))} ETH\n`)
// Verificar CreateX
const code = await ethers.provider.getCode(CREATEX_ADDRESS)
if (code === '0x') throw new Error('❌ CreateX no está deployado en esta red')
console.log(`✅ CreateX verificado en ${CREATEX_ADDRESS}`)
const RELAYER_ADDRESS = process.env.RELAYER_ADDRESS
if (!RELAYER_ADDRESS) throw new Error('❌ Falta RELAYER_ADDRESS en .env')
console.log(`📋 Relayer: ${RELAYER_ADDRESS}`)
// Deploy WalletFactory
const Factory = await ethers.getContractFactory('WalletFactory')
const factory = await Factory.deploy(RELAYER_ADDRESS) as unknown as WalletFactory
await factory.waitForDeployment()
const factoryAddress = await factory.getAddress()
console.log(`\n✅ WalletFactory deployado en: ${factoryAddress}`)
// Test precompute
const testSalt = ethers.id('test-wallet-001')
const testAddress = await factory.computeWalletAddress(testSalt)
console.log(`🧪 Test precompute: ${testAddress}`)
// Guardar deployments.json
const deploymentsPath = 'deployments.json'
const deployments: any = fs.existsSync(deploymentsPath)
? JSON.parse(fs.readFileSync(deploymentsPath, 'utf8'))
: {}
deployments[network.chainId.toString()] = {
WalletFactory: factoryAddress,
deployedAt: new Date().toISOString(),
deployer: deployer.address,
relayer: RELAYER_ADDRESS,
chainId: network.chainId.toString(),
}
fs.writeFileSync(deploymentsPath, JSON.stringify(deployments, null, 2))
console.log(`\n📁 Guardado en deployments.json`)
console.log(` Añadir al .env del backend: WALLET_FACTORY_ADDRESS=${factoryAddress}\n`)
}
main().catch(err => {
console.error(err)
process.exit(1)
})
Anatomía del Script
Verificación de CreateX: antes de desplegar, el script verifica que CreateX tenga código en la cadena objetivo. Si getCode() retorna '0x', significa que CreateX no está desplegado allí y nuestro factory no funcionaría.
const code = await ethers.provider.getCode(CREATEX_ADDRESS)
if (code === '0x') throw new Error('❌ CreateX no está deployado en esta red')
Test de Precomputo: inmediatamente después del despliegue, el script computa una dirección de prueba para verificar que todo funciona. Esta dirección debería ser idéntica en todas las cadenas donde despliegues el factory (asumiendo que la dirección del factory también sea idéntica).
Persistencia: los datos de despliegue se guardan en deployments.json, un archivo simple que mapea chainId a la dirección del factory. Tu backend lee este archivo para saber con qué contrato interactuar en cada cadena.
Ejecución
# En Sepolia (Ethereum testnet)
npx hardhat run scripts/deploy.ts --network sepolia
# En Amoy (Polygon testnet)
npx hardhat run scripts/deploy.ts --network amoy
Generando Direcciones de Wallet Off-Chain
Este script demuestra como precomputar una dirección de wallet sin gastar gas, ideal para integrar en tu API o dashboard:
import { ethers } from "ethers";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();
// ABI mínima necesaria de WalletFactory para computeWalletAddress
const FACTORY_ABI = [
"function computeWalletAddress(bytes32 walletId) external view returns (address walletAddress)"
];
async function main() {
// ID de Mongo que el usuario proporcionará
// Por ahora usamos uno como ejemplo si no se pasa por argumento
const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";
console.log(`\n--- Generación de Wallet Limpia ---`);
console.log(`ID de Mongo: ${mongoId}`);
// Configurar provider
const rpcUrl = process.env.RPC_URL_SEPOLIA;
if (!rpcUrl) throw new Error("Falta RPC_URL_SEPOLIA en .env");
const provider = new ethers.JsonRpcProvider(rpcUrl);
const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
const factoryAddress = deployments["11155111"].WalletFactory;
if (!factoryAddress) {
throw new Error(`No se encontró WalletFactory para Sepolia en deployments.json`);
}
const factory = new ethers.Contract(factoryAddress, FACTORY_ABI, provider);
// El walletId en el contrato es bytes32.
let walletId = mongoId;
if (!walletId.startsWith("0x")) {
walletId = "0x" + walletId.padStart(64, "0");
}
console.log(`WalletId (bytes32): ${walletId}`);
console.log(`Factory: ${factoryAddress}`);
// Precomputar dirección
const walletAddress = await factory.computeWalletAddress(walletId);
// Guardar en archivo para evitar problemas de terminal
fs.writeFileSync("generated_address.txt", walletAddress);
console.log(`\n--- RESULTADO DE PRECOMPUTE ---`);
console.log(`Valor retornado: ${walletAddress}`);
console.log(`Tipo: ${typeof walletAddress}`);
if (typeof walletAddress === 'string') {
console.log(`Longitud: ${walletAddress.length} caracteres`);
}
console.log(`\n✅ Dirección de Wallet: ${walletAddress}`);
console.log(`\nInstrucciones:`);
console.log(`1. Deposita fondos (USDT/USDC o ETH) en esta dirección.`);
console.log(`2. Avísame cuando el depósito esté confirmado.`);
console.log(`3. Ejecutaremos el script de retiro.`);
}
main().catch(error => {
console.error(error);
process.exit(1);
});
Conversión de ID a bytes32
El punto mas importante de este script es la conversión de un identificador de aplicación (como un MongoDB ObjectId de 24 caracteres hex) a bytes32:
let walletId = mongoId;
if (!walletId.startsWith("0x")) {
walletId = "0x" + walletId.padStart(64, "0");
}
Un MongoDB ObjectId como 65d4f1a2b3c4d5e6f7a8b9c0 tiene 24 caracteres hexadecimales (12 bytes). Para convertirlo a bytes32 (64 caracteres hex), lo rellenamos con ceros a la izquierda:
Este padding es determinista: el mismo ID siempre produce el mismo bytes32, lo que garantiza la misma dirección de wallet en todas las cadenas.
Ejecución
# Con un ID específico
npx ts-node scripts/generate_wallet.ts 65d4f1a2b3c4d5e6f7a8b9c0
# Con el ID por defecto
npx ts-node scripts/generate_wallet.ts
Nota: este script usa
ethersdirectamente (no a traves de Hardhat) porque es una operación de solo lectura que no necesita el entorno completo de Hardhat. Esto lo hace mas rápido y adecuado para integrar en un servicio backend.
Barrido de Fondos: Patrón Lazy Deploy
Este script implementa el flujo completo de retiro, incluyendo la lógica de decisión entre “primera vez” (deploy + sweep) y “ya desplegado” (solo sweep):
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 receiver)",
"function sweepExisting(address receiver, address token, tuple(address wallet, uint256 bps)[] recipients) external",
"function isDeployedReceiver(address receiver) external view returns (bool)",
"function computeWalletAddress(bytes32 walletId) external view returns (address)"
];
async function main() {
const mongoId = process.argv[2] || "69803138ebd1b9b38348c265"; // ID para prueba final unificada
const tokenAddress = process.argv[3] || "0x0000000000000000000000000000000000000000"; // Default a nativo
const rpcUrl = process.env.RPC_URL_SEPOLIA;
const privateKey = process.env.PRIVATE_KEY;
const treasury = process.env.TREASURY_ADDRESS;
if (!rpcUrl || !privateKey || !treasury) {
throw new Error("Faltan variables en .env (RPC_URL_SEPOLIA, PRIVATE_KEY, TREASURY_ADDRESS)");
}
const provider = new ethers.JsonRpcProvider(rpcUrl);
const relayer = new ethers.Wallet(privateKey, provider);
const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
const factoryAddress = deployments["11155111"].WalletFactory;
const factory = new ethers.Contract(factoryAddress, FACTORY_ABI, relayer);
let walletId = "0x" + mongoId.padStart(64, "0");
// Calcular dirección esperada
const walletAddress = await factory.computeWalletAddress(walletId);
// 1. Verificar balance y despliegue
const balance = await provider.getBalance(walletAddress);
const isDeployed = await factory.isDeployedReceiver(walletAddress);
console.log(`\n--- Retiro de Fondos (Flujo Estándar) ---`);
console.log(`Wallet: ${walletAddress}`);
console.log(`Token: ${tokenAddress === "0x0000000000000000000000000000000000000000" ? "ETH Nativo" : tokenAddress}`);
console.log(`Balance: ${ethers.formatEther(balance)} ETH (Solo si es Nativo)`);
console.log(`Estado: ${isDeployed ? "Desplegada" : "No desplegada"}`);
const recipients = [{ wallet: treasury, bps: 10000 }];
let tx;
if (isDeployed) {
console.log("Ejecutando sweepExisting (vía estándar)...");
tx = await factory.sweepExisting(walletAddress, tokenAddress, recipients);
} else {
console.log("Ejecutando deployAndSweep (vía estándar)...");
tx = await factory.deployAndSweep(walletId, tokenAddress, recipients);
}
console.log(`Transacción enviada: ${tx.hash}`);
await tx.wait();
console.log("✅ Retiro completado con éxito mediante el flujo estándar.");
}
main().catch(console.error);
La Lógica de Decisión
El corazón de este script es la bifurcación basada en isDeployedReceiver:
const isDeployed = await factory.isDeployedReceiver(walletAddress);
if (isDeployed) {
tx = await factory.sweepExisting(walletAddress, tokenAddress, recipients);
} else {
tx = await factory.deployAndSweep(walletId, tokenAddress, recipients);
}
Primera vez (!isDeployed): se llama a deployAndSweep() que despliega el contrato y barre fondos en una sola transacción. El costo de gas es mayor (~300,000-400,000 gas) porque incluye el despliegue del WalletReceiver.
Veces posteriores (isDeployed): se llama a sweepExisting() que solo ejecuta el barrido. El costo es mucho menor (~80,000-120,000 gas) porque el contrato ya existe.
Soporte Multi-Token
El script acepta una dirección de token como segundo argumento. Si se pasa 0x0000000000000000000000000000000000000000 (la dirección cero), el contrato interpreta que es un barrido de token nativo (ETH, MATIC, BNB). Para tokens ERC20, se pasa la dirección del contrato del token:
# Barrer ETH nativo
npx ts-node scripts/withdraw_funds.ts 65d4f1a2b3c4d5e6f7a8b9c0
# Barrer USDC (dirección en Sepolia)
npx ts-node scripts/withdraw_funds.ts 65d4f1a2b3c4d5e6f7a8b9c0 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
Configuración de Destinatarios
En este ejemplo, usamos un solo destinatario con 10,000 bps (100%):
const recipients = [{ wallet: treasury, bps: 10000 }];
Para un split de comisiones, podrías usar:
const recipients = [
{ wallet: merchantAddress, bps: 9700 }, // 97% al comerciante
{ wallet: platformFee, bps: 200 }, // 2% comisión plataforma
{ wallet: referralAddress, bps: 100 }, // 1% referido
];
Consideraciones de Seguridad
Diagrama de Control de Acceso
Checklist de Seguridad
| Riesgo | Mitigación | Estado |
|---|---|---|
| Relayer comprometido | pause() por el owner + setRelayer() para rotar | Implementado |
| Owner comprometido | Ownable2Step requiere aceptación explícita | Implementado |
| Reentrancia en sweep | ReentrancyGuard en WalletReceiver | Implementado |
| Receiver malicioso | isDeployedReceiver mapping valida origen | Implementado |
| Dust por redondeo | Ultimo destinatario recibe remainder | Implementado |
| Token no-estándar (USDT) | SafeERC20 para todas las transferencias | Implementado |
| Front-running de deploy | Salt permissioned (bytes 0-19 = factory address) | Implementado |
| bps inválidos | Validación totalBps == 10_000 + bps <= 10_000 | Implementado |
| address(0) como destinatario | Validación explícita en constructor y sweep | Implementado |
| Gas limit en sweep loop | MAX_RECIPIENTS = 5 limita iteraciones | Implementado |
Recomendaciones para Producción
1. Owner como Multisig
El owner del WalletFactory debería ser un contrato multisig (como Safe/Gnosis Safe), no una EOA individual. Esto asegura que las operaciones criticas (pausar, cambiar relayer, emergency sweep) requieran multiples firmas.
2. Relayer con Gas Limitado
El relayer es una hot wallet que ejecuta transacciones frecuentes. Mantenla con un balance de gas mínimo (solo lo necesario para N transacciones) y reponla automáticamente desde una cold wallet.
3. Monitoreo On-Chain
Configura alertas para los eventos:
WalletDeployed: cada nuevo receiver desplegado.RelayerUpdated: cambio de relayer (debería ser raro).Paused/Unpaused: cambios de estado del sistema.EmergencySweep: indica un problema operacional.
4. Rate Limiting Off-Chain
Aunque el contrato no implementa rate limiting (para mantenerlo simple), tu backend debería limitar la frecuencia de despliegues por usuario para prevenir abuso.
5. Auditoría
Antes de ir a mainnet con fondos significativos, considera una auditoría de seguridad. Los contratos son relativamente simples (~230 + ~420 líneas), lo que reduce tanto el costo como el tiempo de auditoría.
Despliegue en Producción
Pasos para Despliegue Multichain
Paso 1: Verificar CreateX en la cadena objetivo
Antes de desplegar, confirma que CreateX existe en la cadena. Consulta la lista oficial de cadenas soportadas.
# Verificar manualmente
cast code 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed --rpc-url $RPC_URL
Paso 2: Desplegar WalletFactory con el mismo deployer y nonce
Para que el WalletFactory tenga la misma dirección en todas las cadenas (y por lo tanto genere las mismas direcciones de receiver), debes desplegarlo con:
- La misma EOA (deployer address).
- El mismo nonce (contador de transacciones).
# Verificar nonce actual
cast nonce $DEPLOYER_ADDRESS --rpc-url $RPC_URL_ETHEREUM
cast nonce $DEPLOYER_ADDRESS --rpc-url $RPC_URL_POLYGON
Si los nonces ya divergieron, tienes dos opciones:
- Crear transacciones vacías para sincronizar nonces (enviar 0 ETH a ti mismo hasta que coincidan).
- Usar CREATE2 para desplegar la factory (a traves de otro CreateX
deployCreate2), lo que elimina la dependencia del nonce.
Paso 3: Desplegar en cada cadena
# Ethereum mainnet
npx hardhat run scripts/deploy.ts --network mainnet
# Polygon
npx hardhat run scripts/deploy.ts --network polygon
# BSC
npx hardhat run scripts/deploy.ts --network bsc
# Arbitrum
npx hardhat run scripts/deploy.ts --network arbitrum
Paso 4: Verificar contratos en exploradores
npx hardhat verify --network mainnet $FACTORY_ADDRESS $RELAYER_ADDRESS
npx hardhat verify --network polygon $FACTORY_ADDRESS $RELAYER_ADDRESS
Verificar Determinismo Cross-Chain
Después de desplegar en multiples cadenas, verifica que las direcciones precomputadas sean idénticas:
// test-determinism.ts
import { ethers } from "ethers";
const FACTORY_ABI = [
"function computeWalletAddress(bytes32 walletId) external view returns (address)"
];
const chains = [
{ name: "Ethereum", rpc: process.env.RPC_URL_ETHEREUM, factory: "0x..." },
{ name: "Polygon", rpc: process.env.RPC_URL_POLYGON, factory: "0x..." },
{ name: "BSC", rpc: process.env.RPC_URL_BSC, factory: "0x..." },
];
async function main() {
const testWalletId = "0x" + "deadbeef".padStart(64, "0");
const addresses: string[] = [];
for (const chain of chains) {
const provider = new ethers.JsonRpcProvider(chain.rpc);
const factory = new ethers.Contract(chain.factory, FACTORY_ABI, provider);
const addr = await factory.computeWalletAddress(testWalletId);
addresses.push(addr);
console.log(`${chain.name}: ${addr}`);
}
const allEqual = addresses.every(a => a === addresses[0]);
console.log(`\nDeterminismo: ${allEqual ? "VERIFICADO" : "FALLIDO"}`);
}
main().catch(console.error);
Si todas las direcciones coinciden, el sistema está correctamente configurado para operar de forma multichain.
Conclusión
Lo Que Construimos
En este tutorial implementamos un sistema completo de wallets deterministas multichain compuesto por:
- WalletReceiver: contrato minimalista que recibe y redistribuye fondos usando basis points, con soporte para tokens ERC20 y nativos.
- WalletFactory: orquestador que utiliza CreateX para desplegar receivers en la misma dirección across cadenas EVM, con patrón lazy deploy para optimizar gas.
- Scripts de operación: despliegue, generación de direcciones off-chain, y barrido de fondos con lógica de decisión automática.
Lecciones Clave
- CREATE3 desacopla la dirección del bytecode: a diferencia de CREATE2, los argumentos del constructor no afectan la dirección final. Esto es fundamental para infraestructura multichain donde los parámetros pueden variar entre cadenas.
- El guard de CreateX transforma el salt silenciosamente: siempre replica la lógica de
_guard()cuando computes direcciones off-chain o en funciones view. - El patrón lazy deploy ahorra gas significativamente: solo despliegas cuando hay fondos reales, evitando gastar gas en wallets que nunca reciben depósitos.
- SafeERC20 no es opcional: tokens como USDT rompen el estándar ERC20 y sin SafeERC20 tu contrato fallará silenciosamente en producción.
- Ownable2Step previene pérdida de control: la transferencia en dos pasos es una inversión mínima de complejidad que puede salvar millones en un error de dedo.
Próximos Pasos
- Soporte para ERC721/ERC1155: extender el sweep para recuperar NFTs atrapados.
- Batch deploy: desplegar multiples receivers en una sola transacción usando un contrato multicall.
- Integración con relayers de gas: usar servicios como OpenZeppelin Defender o Gelato para automatizar y abstraer el gas del relayer.
- Deploy de la factory con CREATE2: usar CreateX para desplegar la propia factory determinísticamente, eliminando la dependencia del nonce del deployer.
El código fuente completo está disponible en el repositorio wallet-factory-multichain en GitHub.
Desarrollado por Beltsys Labs. Licencia MIT.


