Beltsys Labs
Beltsys Labs
Advanced Solidity EVM CreateX

Building a Deterministic Wallet Factory for EVM Chains with CreateX

Daniel
Daniel · Full Stack Developer
42 min read
Building a Deterministic Wallet Factory for EVM Chains with CreateX

The Problem: One User, Many Chains, Many Addresses

Imagine you are building a crypto payment platform. A user wants to receive USDT on Ethereum, USDC on Polygon, and native BNB on BSC. In the traditional approach, you would deploy a separate receiver contract on each chain, and each deployment would produce a different address. Your user now has three addresses to manage, your backend needs a mapping table per chain, and your UX suffers.

What if you could give every user a single deposit address that works identically on Ethereum, Polygon, BSC, Arbitrum, Optimism, Avalanche, Base, zkSync, and any other EVM chain?

That is exactly what deterministic deployments enable. By using the CREATE3 opcode pattern through the CreateX factory, we can guarantee the same contract address on every chain, derived purely from a salt we control. The contract does not even need to exist yet – funds can be sent to the precomputed address, and we deploy the contract later to sweep them out. This is the lazy deploy pattern.

In this tutorial, we will build the complete system from scratch:

  • WalletReceiver – a minimal contract that receives tokens and forwards them to one or more destinations with configurable splits
  • WalletFactory – an admin contract that deploys WalletReceiver instances deterministically via CreateX
  • TypeScript scripts – for deploying the factory, precomputing wallet addresses off-chain, and sweeping funds

By the end, you will have a production-ready wallet infrastructure that works identically across 30+ EVM chains.


The Solution: CREATE3 Deterministic Deployments

To understand why CREATE3 is the right tool, let us compare the three deployment mechanisms available on EVM:

FeatureCREATECREATE2CREATE3 (via CreateX)
Address depends onsender noncesender + salt + initCode hashsender + salt only
Same address cross-chainNo (nonce varies)Only if initCode is identicalYes, always
Constructor args affect addressYes (via nonce)Yes (part of initCode hash)No
Requires factory at same addressNoNoYes (CreateX is pre-deployed)

The key insight is that CREATE2 addresses depend on the contract’s bytecode, including constructor arguments. If your constructor takes an address relayer parameter, and that relayer is different on each chain, your CREATE2 address changes. CREATE3 eliminates this problem by deploying a minimal proxy first (with a fixed bytecode), which then deploys your actual contract. The final address depends only on the deployer and the salt.

Introducing CreateX

CreateX is a permissionless factory contract deployed at the same address on 30+ EVM chains:

0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed

It provides deployCreate3(bytes32 salt, bytes memory initCode) which:

  1. Derives a proxy address from keccak256(salt)
  2. Deploys a minimal proxy using CREATE2 (fixed bytecode, so always the same address)
  3. The proxy then deploys your actual initCode using CREATE (nonce is always 1)
  4. Returns the final deterministic address

Because the proxy bytecode is always the same and the nonce is always 1, the final address depends only on the salt and the CreateX factory address – not on your contract’s bytecode or constructor arguments.


Architecture Overview

Here is how all the pieces fit together:

WW(Ea(C(aStlsr0lAhlaexlMeemabeErtetateFe5RauaaXEedmcd.cdtd.eror)i!r)v)12345ye.....rRGCSMCeeahoalnlonlaelwilyrteacaodrtodreemdpEprflOWWwueooAaa(atsryPl((lSTrwlesA(olsC0lAreilWinhlearxeMeocteatndoytmebtEarihBtlocStgFeaaRspaIlowoat5eauNibcdeumewncaeEcdrepktsieatdX.edynse(Aenplod.irt)nedrg(lrr)v!sd.d)ey)e)grdtr.eew)sphMsoeo(snnwigatfolsuDlnBedtsWWOIaa(bdal((lSj)rlsC0lAerBearxeMc[iStmebtEtvvCFeaaRIieat5eadecaeEcd)wtdX.ed]od.irrr)v!y)e)r

Flow:

  1. Backend generates a unique walletId for each user (e.g., a MongoDB ObjectId encoded as bytes32)
  2. Backend calls computeWalletAddress(walletId) – a free view call – to get the deterministic address
  3. User deposits tokens (ERC20 or native) to that address on any supported chain
  4. Backend detects the deposit and calls deployAndSweep() via the relayer – this deploys the WalletReceiver contract and sweeps funds to the treasury in a single atomic transaction
  5. For subsequent deposits to the same address, backend calls sweepExisting() (no redeployment needed)

Project Setup

Initialize the project

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

Select “Create a TypeScript project” when prompted.

Hardhat Configuration

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;

Key settings:

  • Solidity 0.8.28 – latest stable compiler with built-in overflow checks
  • Optimizer at 200 runs – balanced between deployment cost and runtime gas
  • EVM version paris – ensures compatibility across all target chains (avoids PUSH0 opcode issues on some L2s)

Environment Variables

Create a .env file:

PRIVATE_KEY=0xYOUR_RELAYER_PRIVATE_KEY
RELAYER_ADDRESS=0xYOUR_RELAYER_ADDRESS
TREASURY_ADDRESS=0xYOUR_TREASURY_ADDRESS
RPC_URL_SEPOLIA=https://sepolia.infura.io/v3/YOUR_KEY
RPC_URL_AMOY=https://rpc-amoy.polygon.technology
ETHER_SCAN_API=YOUR_ETHERSCAN_KEY

Security note: Never commit .env to version control. Add it to .gitignore.


Contract 1: WalletReceiver

The WalletReceiver is a minimal contract deployed at the deterministic address. Its sole purpose is to receive tokens and allow the relayer to sweep them to one or more destinations. Let us walk through the complete contract section by section.

Skeleton and Immutables

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

Design decisions:

  • immutable variables instead of storage: The relayer, factory, and walletId are set once in the constructor and never change. Using immutable saves ~2,100 gas per read (no SLOAD) because the values are embedded directly in the contract bytecode.
  • ReentrancyGuard: Protects the sweepNative function against reentrancy attacks. When we send native ETH via call{value}, the recipient could be a malicious contract that calls back into our sweep function.
  • SafeERC20: Handles tokens like USDT that do not return a boolean on transfer(). Without it, the transaction would revert on any non-standard ERC20.
  • MAX_RECIPIENTS = 5: Each ERC20 safeTransfer costs approximately 25,000 gas. With 5 recipients, the sweep loop uses about 125,000 gas for transfers alone, well within safe limits.

Errors, Events, Constructor, and Modifier

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

Why custom errors instead of require strings? Custom errors (introduced in Solidity 0.8.4) use only 4 bytes of calldata for the selector, compared to a full string encoding with require. They save gas on both deployment and revert, and are easier to decode in frontend/backend code.

Why authorize both the relayer and the factory? The factory needs to call sweep() directly inside deployAndSweep() – the atomic deploy-and-sweep pattern. If we only authorized the relayer, the factory (which is msg.sender during the internal call) would be rejected.

ERC20 Sweep with Basis Points

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

Understanding Basis Points (bps):

Basis points are a standard financial unit where 10,000 bps = 100%. This avoids floating-point math entirely:

SplitRecipient A (bps)Recipient B (bps)
100% to treasury10,000
98/2 split9,800200
70/20/10 three-way7,0002,000 + 1,000

The remainder trick: When splitting 1,000 USDC at 98/2, the math gives us 1000 * 9800 / 10000 = 980 and 1000 * 200 / 10000 = 20. Clean. But with 1,001 USDC: 1001 * 9800 / 10000 = 980 (integer division truncates) and 1001 * 200 / 10000 = 20. That leaves 1 USDC as dust. The fix: the last recipient gets balance - distributed instead of the calculated amount. This ensures every wei is accounted for.

SafeERC20: OpenZeppelin’s SafeERC20 wraps the low-level transfer call with proper return value handling. Tokens like USDT (Tether) famously do not return a bool from their transfer() function. Without safeTransfer, calling IERC20(usdt).transfer() would revert because Solidity expects a return value to decode. SafeERC20.safeTransfer checks if there is return data and, if so, decodes it; if there is no return data, it assumes success (as long as the call did not revert).

Native Token Sweep

    /**
     * @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 {}
}

Why call{value} instead of transfer?

The transfer() function forwards exactly 2,300 gas to the recipient. This was designed as a reentrancy guard, but it breaks when the recipient is a smart contract wallet (like Gnosis Safe) or a contract behind a proxy that needs more gas. The call{value} approach forwards all available gas, making it compatible with any recipient. We protect against reentrancy using OpenZeppelin’s nonReentrant modifier instead.

MethodGas forwardedSafe for smart contract recipientsReentrancy-safe by default
transfer()2,300 (fixed)No – can breakYes
send()2,300 (fixed)No – can breakYes (returns bool)
call{value}()All remainingYesNo – need nonReentrant

The receive() function at the bottom is a special Solidity function that is called when the contract receives native tokens without any calldata (a plain ETH transfer). Without it, any attempt to send ETH to the WalletReceiver address would revert.


Contract 2: WalletFactory

The WalletFactory is the admin layer that wraps CreateX. It manages relayer authorization, deployment tracking, and provides both eager and lazy deployment patterns.

CreateX Interface and State

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

Why Ownable2Step instead of Ownable? Ownable2Step requires the new owner to explicitly call acceptOwnership() before the transfer completes. This prevents accidentally transferring ownership to a wrong address – a mistake that would be irreversible with plain Ownable. In a system that controls user funds, this extra step is critical.

Why Pausable? If the relayer’s private key is compromised, the owner can immediately call pause() to freeze all relayer-callable functions. The attacker cannot deploy new receivers or sweep existing ones. Emergency functions remain available to the owner for fund recovery.

The isDeployedReceiver mapping serves as an allowlist. Without it, an attacker could pass any contract address to sweepExisting() and potentially trigger unexpected behavior on arbitrary contracts.

Events, Errors, Constructor, and Modifiers

    // ─── 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

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

Deployment Functions

This is where the core logic lives. The factory provides three deployment/sweep patterns:

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

The Lazy Deploy Pattern explained:

The key insight is that on EVM chains, any address can receive tokens before a contract is deployed there. ERC20 tokens are just entries in the token contract’s internal balanceOf mapping – the recipient address does not need to have any code. Similarly, native tokens (ETH, MATIC, BNB) can be sent to any address.

This enables a powerful optimization:

  1. Precompute the deterministic address (free view call)
  2. Show it to the user as their deposit address
  3. Wait for a deposit event on-chain
  4. Deploy + sweep in a single transaction when funds arrive

The contract is never deployed if the user never deposits. This saves gas for inactive wallets. And when a deposit does arrive, the deploy and sweep happen atomically – there is no window where funds sit in a deployed contract waiting to be swept.

Notice that deployAndSweep handles both ERC20 and native tokens. When token == address(0), it sweeps native tokens. When it is a real token address, it sweeps ERC20. If the balance is zero (for example, if someone called deploy before the deposit), the sweep is silently skipped without reverting.

Sweep Existing and Emergency Functions

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

Why separate emergency functions? The regular sweepExisting requires the relayer. If the relayer’s infrastructure goes down (server crash, key rotation, cloud outage), user funds could be stuck indefinitely. The emergency functions allow the owner (typically a cold wallet or multisig) to recover funds directly. They bypass the whenNotPaused check so they work even if the contract is paused.

Notice that emergency functions always send to a single destination with 10,000 bps (100%). There is no split logic – in an emergency, you just want to get the funds to a safe address as quickly as possible.

Internal Helper: Building InitCode

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

The _buildInitCode function concatenates the WalletReceiver’s creation bytecode with its ABI-encoded constructor arguments. This is the standard way to prepare bytecode for deployment – the EVM reads the creation code, executes it (which runs the constructor), and the returned bytes become the deployed runtime bytecode.

With CREATE3, this initCode does not affect the final address. That is the entire point. You can change the relayer address on each chain, use different constructor arguments, or even modify the WalletReceiver contract itself – the deterministic address stays the same.


The Salt: Understanding CreateX’s Format

CreateX uses a special 32-byte salt format that encodes access control directly into the salt value:

f(am2cs0tgo.brsyyetneadsdedrr)ess1f0blxya0tg0ekter1cu1cnacbkay(ttweeadslletId)
BytesContentPurpose
0-19address(this) (the WalletFactory)Permissioned protection: CreateX checks that msg.sender matches these 20 bytes. This means only our factory can deploy to addresses derived from this salt.
200x00Cross-chain flag: 0x00 disables cross-chain redeploy protection, ensuring the same address on ALL chains. 0x01 would enable it (different address per chain). Any value > 0x01 causes CreateX to revert.
21-31keccak256(walletId) truncated to 11 bytesEntropy: Unique per wallet. 11 bytes = 88 bits of entropy, giving us 2^88 (~3 x 10^26) possible addresses per factory.

Here is the buildSalt function:

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

The assert at the end is a development safety net. If a future refactor accidentally changes the byte layout (for example, by reordering fields), the assertion will catch it immediately. In production, the assert should never fail – if it does, it indicates a serious bug.

Why bytes11(uint88(uint256(keccak256(...))))? We need exactly 11 bytes of entropy. keccak256 returns 32 bytes, so we cast it to uint256, then truncate to uint88 (11 bytes = 88 bits), and finally cast to bytes11. The truncation is safe because we are using keccak256 – all bits have uniform distribution.


The Guard Trap: Why Address Prediction Breaks

This is the single most important gotcha when working with CreateX, and it cost us hours of debugging. Here is the problem:

When you call CREATEX.computeCreate3Address(salt, deployer), you would expect it to return the same address that CREATEX.deployCreate3(salt, initCode) will deploy to. It does not, unless you account for the internal _guard() rehash.

CreateX has an internal function called _guard() that modifies the salt before using it for deployment. When the first 20 bytes of the salt match msg.sender (our factory) AND byte 21 is 0x00, CreateX rehashes the salt:

guardedSalt=keccak256(abi.encodePacked(bytes32(uint256(uint160(msg.sender))),salt))

The problem is that computeCreate3Address is a view function – it does NOT apply this rehash internally. So if you pass the raw salt to computeCreate3Address, you get a different address than what deployCreate3 will actually produce.

Our computeWalletAddress function replicates the guard logic:

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

What happens if you skip the guard? You precompute address 0xABC..., give it to the user, the user deposits 10,000 USDC to 0xABC..., you call deployAndSweep(), and the contract deploys to 0xDEF... instead. The funds at 0xABC... are now stuck at an address with no contract and no way to recover them. This is a critical bug that would result in permanent loss of user funds.

Key takeaway: Always replicate CreateX’s _guard() logic when precomputing addresses. Never pass a raw salt to computeCreate3Address if the first 20 bytes match your deployer address.


Deployment Script

With both contracts ready, let us deploy the WalletFactory. The WalletReceiver does not get deployed separately – it will be deployed on-demand by the factory.

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(`\nDeploy on Chain ID: ${network.chainId}`)
  console.log(`   Deployer: ${deployer.address}`)
  console.log(`   Balance:  ${ethers.formatEther(await ethers.provider.getBalance(deployer.address))} ETH\n`)

  // Verify CreateX exists on this chain
  const code = await ethers.provider.getCode(CREATEX_ADDRESS)
  if (code === '0x') throw new Error('CreateX is not deployed on this network')
  console.log(`CreateX verified at ${CREATEX_ADDRESS}`)

  const RELAYER_ADDRESS = process.env.RELAYER_ADDRESS
  if (!RELAYER_ADDRESS) throw new Error('Missing RELAYER_ADDRESS in .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(`\nWalletFactory deployed at: ${factoryAddress}`)

  // Test precompute
  const testSalt = ethers.id('test-wallet-001')
  const testAddress = await factory.computeWalletAddress(testSalt)
  console.log(`Test precompute: ${testAddress}`)

  // Save to 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(`\nSaved to deployments.json`)
  console.log(`   Add to backend .env: WALLET_FACTORY_ADDRESS=${factoryAddress}\n`)
}

main().catch(err => {
  console.error(err)
  process.exit(1)
})

Run the deployment:

npx hardhat run scripts/deploy.ts --network sepolia

What the script does:

  1. Verifies CreateX exists on the target network by checking if there is code at the canonical address. If CreateX is not deployed, we cannot proceed.
  2. Deploys WalletFactory using the standard Hardhat deployer. This is a regular CREATE deployment – the factory itself does not need a deterministic address (though you could use CreateX for that too).
  3. Tests precompute by calling computeWalletAddress with a test wallet ID. This validates that the factory can communicate with CreateX correctly.
  4. Saves the deployment to deployments.json, organized by chain ID. This file is used by the other scripts to find the factory address.

Generating Wallet Addresses Off-Chain

This script demonstrates how your backend would precompute a wallet address without deploying anything. The call to computeWalletAddress is a free view call – no gas, no transaction.

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

// Minimal ABI needed from WalletFactory
const FACTORY_ABI = [
    "function computeWalletAddress(bytes32 walletId) external view returns (address walletAddress)"
];

async function main() {
    // In production, this would come from your database (e.g., MongoDB ObjectId)
    const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";

    console.log(`\n--- Wallet Address Generation ---`);
    console.log(`Mongo ID: ${mongoId}`);

    // Configure provider (read-only, no signer needed)
    const rpcUrl = process.env.RPC_URL_SEPOLIA;
    if (!rpcUrl) throw new Error("Missing RPC_URL_SEPOLIA in .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(`WalletFactory not found for Sepolia in deployments.json`);
    }

    const factory = new ethers.Contract(factoryAddress, FACTORY_ABI, provider);

    // Convert the MongoDB ObjectId to bytes32
    // MongoDB ObjectIds are 24 hex characters (12 bytes)
    // We left-pad with zeros to fill 32 bytes
    let walletId = mongoId;
    if (!walletId.startsWith("0x")) {
        walletId = "0x" + walletId.padStart(64, "0");
    }

    console.log(`WalletId (bytes32): ${walletId}`);
    console.log(`Factory: ${factoryAddress}`);

    // Precompute the deterministic address
    const walletAddress = await factory.computeWalletAddress(walletId);

    console.log(`\n--- PRECOMPUTE RESULT ---`);
    console.log(`Wallet Address: ${walletAddress}`);
    console.log(`\nInstructions:`);
    console.log(`1. Deposit tokens (USDT/USDC or ETH) to this address.`);
    console.log(`2. Notify when deposit is confirmed.`);
    console.log(`3. Run the withdrawal script.`);
}

main().catch(error => {
    console.error(error);
    process.exit(1);
});

Run it:

npx ts-node scripts/generate_wallet.ts 65d4f1a2b3c4d5e6f7a8b9c0

Key detail: Converting IDs to bytes32. Most databases use string IDs (MongoDB ObjectId is 24 hex characters = 12 bytes). Since Solidity’s bytes32 is 32 bytes, we left-pad with zeros: "0x" + mongoId.padStart(64, "0"). This conversion must be consistent across all scripts and your backend – if you pad differently, you will compute a different address.

Why this is a view call: computeWalletAddress reads no state that changes between calls. Given the same walletId and the same factory address, it will always return the same address on every chain. This means you can call it once on any chain and use the result on all chains. In production, you would call this in your API endpoint when a user requests a deposit address, cache the result in your database, and return it instantly for future requests.


Sweeping Funds: Lazy Deploy Pattern

This script implements the full sweep flow – it checks if the WalletReceiver is already deployed, and either calls deployAndSweep (lazy deploy) or sweepExisting (subsequent sweeps):

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";
    const tokenAddress = process.argv[3] || "0x0000000000000000000000000000000000000000"; // Default to native

    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("Missing variables in .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");

    // Compute expected address
    const walletAddress = await factory.computeWalletAddress(walletId);

    // Check balance and deployment status
    const balance = await provider.getBalance(walletAddress);
    const isDeployed = await factory.isDeployedReceiver(walletAddress);

    console.log(`\n--- Fund Withdrawal (Standard Flow) ---`);
    console.log(`Wallet:   ${walletAddress}`);
    console.log(`Token:    ${tokenAddress === "0x0000000000000000000000000000000000000000" ? "Native ETH" : tokenAddress}`);
    console.log(`Balance:  ${ethers.formatEther(balance)} ETH (native only)`);
    console.log(`Status:   ${isDeployed ? "Deployed" : "Not deployed"}`);

    const recipients = [{ wallet: treasury, bps: 10000 }];

    let tx;
    if (isDeployed) {
        console.log("Executing sweepExisting (standard flow)...");
        tx = await factory.sweepExisting(walletAddress, tokenAddress, recipients);
    } else {
        console.log("Executing deployAndSweep (standard flow)...");
        tx = await factory.deployAndSweep(walletId, tokenAddress, recipients);
    }

    console.log(`Transaction sent: ${tx.hash}`);
    await tx.wait();
    console.log("Withdrawal completed successfully via standard flow.");
}

main().catch(console.error);

Run it:

npx ts-node scripts/withdraw_funds.ts 65d4f1a2b3c4d5e6f7a8b9c0

Or with a specific ERC20 token:

npx ts-node scripts/withdraw_funds.ts 65d4f1a2b3c4d5e6f7a8b9c0 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48

The flow in detail:

  1. Compute the wallet address from the walletId – same as the generation script
  2. Check if already deployed via isDeployedReceiver(). This is a free view call that reads the factory’s mapping.
  3. Branch on deployment status:
    • Not deployed: Call deployAndSweep() – deploys the WalletReceiver via CreateX and sweeps funds in one atomic transaction
    • Already deployed: Call sweepExisting() – just sweeps, no deployment needed
  4. Wait for confirmationtx.wait() blocks until the transaction is mined

For ERC20 tokens, pass the token contract address as the second argument. For native tokens (ETH, MATIC, BNB), pass address(0) or omit it (defaults to 0x000...000).

In a production backend, this logic would run in a background job triggered by a deposit event listener. You would use something like:

// Pseudo-code for production
eventListener.on('deposit', async (walletAddress, token, amount) => {
    const walletId = await db.getWalletId(walletAddress);
    const isDeployed = await factory.isDeployedReceiver(walletAddress);

    if (isDeployed) {
        await factory.sweepExisting(walletAddress, token, recipients);
    } else {
        await factory.deployAndSweep(walletId, token, recipients);
    }
});

Security Considerations

Access Control Architecture

The system uses a two-tier access control model:

CCaann:nCCBAPoaalursettnnot--oemr::nchtteaokorfeRrnddste(reacegseew:dpilct(lefppeezatecanellepebWreyodoycro(opamya-derleyOyhyEue:lurybdrSwoWxsrlscy,wnwRtaiegweea(:weeaels,ehtrldvOaperllwltneRliinwlapslaaeiccneceraonlu,heyltnhyNcoreneesitel,gaoenscdRrteepsrenstit:tee,m,tdgwPvrpe/eeeeaeaslnursEpeurcwotmngwOlrpsteyrupeeAoee)eAalane)yldpnntucpAasdtisyny)SseSndewi,woSregerw,e)emeppae)Nlpal,tyive,

Security Checklist

ThreatMitigationContract
Relayer key compromisedOwner calls pause() to freeze all relayer functionsWalletFactory
Ownership transfer to wrong addressOwnable2Step requires explicit acceptOwnership()WalletFactory
Reentrancy on native sweepsnonReentrant modifier on all sweep functionsWalletReceiver
Non-standard ERC20 (USDT)SafeERC20.safeTransfer handles missing return valuesWalletReceiver
Rounding dust on splitsLast recipient gets balance - distributedWalletReceiver
Out-of-gas on large recipient arraysMAX_RECIPIENTS = 5 enforced in modifierBoth
Arbitrary contract calls via sweepExistingisDeployedReceiver allowlist validationWalletFactory
Invalid bps configurationValidates totalBps == 10_000 and bps <= 10_000 per recipientWalletReceiver
Zero-address recipientsExplicit ZeroAddress() check on every recipientWalletReceiver
Salt format corruptionassert(uint8(salt[20]) == 0x00) in buildSaltWalletFactory

Production Recommendations

  1. Use a multisig for the owner role. A 2-of-3 or 3-of-5 Gnosis Safe is standard. The owner controls pause/unpause and emergency recovery – a single compromised key should not be able to exercise these powers.

  2. Use a dedicated hot wallet for the relayer. This wallet should hold minimal ETH (just enough for gas) and should not hold any other tokens. If compromised, the attacker can only deploy wallets and sweep to the pre-configured recipients – they cannot change the recipients or extract funds to arbitrary addresses.

  3. Monitor the relayer wallet balance. If it runs out of gas, sweeps will fail and user funds will sit in WalletReceivers until the relayer is refunded or the owner uses emergency recovery.

  4. Set up event listeners for WalletDeployed, Swept, NativeSwept, and EmergencySweep events. These provide a complete audit trail of all fund movements.

  5. Test the full flow on testnets first. Deploy to Sepolia and Amoy (Polygon testnet), verify that the same walletId produces the same address on both networks, deposit test tokens, and sweep them.

  6. Verify contracts on Etherscan (and equivalents) after deployment. This builds trust with users and allows anyone to audit the code.


Deploying to Production

Step 1: Deploy to your first chain

npx hardhat run scripts/deploy.ts --network sepolia

Note the factory address from the output. Let us call it 0xFactoryABC....

Step 2: Deploy to additional chains

npx hardhat run scripts/deploy.ts --network amoy

Important: The WalletFactory itself will have a different address on each chain (it is deployed via regular CREATE, which depends on the deployer’s nonce). This is fine – the WalletReceiver addresses are deterministic because they are deployed via CreateX with a salt that embeds the factory address. As long as the factory address is embedded in the salt, and CreateX is at the same address on every chain, the receiver addresses will match.

Wait – does that mean factory address differences break determinism? No! Here is why: the salt format is [factory_address][0x00][hash]. On Chain A, the factory is at 0xAAA... and the salt starts with 0xAAA.... On Chain B, the factory is at 0xBBB... and the salt starts with 0xBBB.... These are different salts, so they would normally produce different addresses. However, for cross-chain determinism to work, you must deploy the WalletFactory itself at the same address on every chain. You can achieve this by using CreateX to deploy the WalletFactory, or by ensuring the deployer account has the same nonce on every chain (deploy as the first transaction from a fresh wallet).

Step 3: Verify cross-chain determinism

# Generate a wallet address on Sepolia
npx ts-node scripts/generate_wallet.ts test-user-001

# Compare with the address computed on Amoy
# (modify the script to use Amoy RPC and chain ID)
# Both should return the EXACT same address

Step 4: Verify contracts on block explorers

npx hardhat verify --network sepolia FACTORY_ADDRESS "RELAYER_ADDRESS"

Conclusion

What We Built

A complete deterministic wallet infrastructure for EVM chains:

  • WalletReceiver: A minimal, gas-optimized contract that receives any ERC20 or native token and supports multi-recipient sweeps with basis point splits
  • WalletFactory: An admin contract that deploys receivers deterministically via CreateX’s CREATE3, with pause capability, emergency recovery, and relayer management
  • Off-chain scripts: TypeScript tools for deployment, address generation, and fund sweeping

Key Takeaways

  1. CREATE3 via CreateX gives you the same contract address on every EVM chain, regardless of constructor arguments or bytecode changes
  2. The lazy deploy pattern saves gas by only deploying contracts when real payments arrive – funds can be sent to precomputed addresses before any contract exists
  3. The guard trap is the most dangerous pitfall: always replicate CreateX’s internal _guard() rehash when precomputing addresses, or you will lose user funds
  4. Two-tier access control (owner + relayer) with Pausable provides a clear security boundary: the relayer handles day-to-day operations, the owner handles emergencies
  5. Basis point splits enable flexible fund distribution without floating-point math, with the remainder trick preventing rounding dust

Next Steps

  • Add EIP-712 meta-transactions so the relayer can batch multiple sweeps in a single transaction
  • Implement a Gelato/OpenZeppelin Defender relayer instead of a raw EOA for automated, reliable sweep execution
  • Add support for ERC-721 and ERC-1155 recovery in the emergency functions
  • Build a monitoring dashboard that tracks all deployed receivers, their balances, and sweep history using indexed events

The complete source code is available in the wallet-factory-multichain repository on GitHub.


Built by Beltsys Labs. Licensed under MIT.

Disclaimer: The code in this tutorial is for educational purposes only. Before deploying to a mainnet, a professional security audit is strongly recommended. Beltsys Labs assumes no responsibility for any use made of this code.

Solidity EVM CreateX CREATE3 Hardhat

Need help with your project?

Our team of experts can implement these solutions for you.

Contact Us