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:
| Feature | CREATE | CREATE2 | CREATE3 (via CreateX) |
|---|---|---|---|
| Address depends on | sender nonce | sender + salt + initCode hash | sender + salt only |
| Same address cross-chain | No (nonce varies) | Only if initCode is identical | Yes, always |
| Constructor args affect address | Yes (via nonce) | Yes (part of initCode hash) | No |
| Requires factory at same address | No | No | Yes (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:
It provides deployCreate3(bytes32 salt, bytes memory initCode) which:
- Derives a proxy address from
keccak256(salt) - Deploys a minimal proxy using CREATE2 (fixed bytecode, so always the same address)
- The proxy then deploys your actual
initCodeusing CREATE (nonce is always 1) - 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:
Flow:
- Backend generates a unique
walletIdfor each user (e.g., a MongoDB ObjectId encoded asbytes32) - Backend calls
computeWalletAddress(walletId)– a freeviewcall – to get the deterministic address - User deposits tokens (ERC20 or native) to that address on any supported chain
- 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 - 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 (avoidsPUSH0opcode 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
.envto 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 [email protected]
*/
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:
immutablevariables instead ofstorage: Therelayer,factory, andwalletIdare set once in the constructor and never change. Usingimmutablesaves ~2,100 gas per read (noSLOAD) because the values are embedded directly in the contract bytecode.- ReentrancyGuard: Protects the
sweepNativefunction against reentrancy attacks. When we send native ETH viacall{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
safeTransfercosts 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:
| Split | Recipient A (bps) | Recipient B (bps) |
|---|---|---|
| 100% to treasury | 10,000 | – |
| 98/2 split | 9,800 | 200 |
| 70/20/10 three-way | 7,000 | 2,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.
| Method | Gas forwarded | Safe for smart contract recipients | Reentrancy-safe by default |
|---|---|---|---|
transfer() | 2,300 (fixed) | No – can break | Yes |
send() | 2,300 (fixed) | No – can break | Yes (returns bool) |
call{value}() | All remaining | Yes | No – 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 [email protected]
*/
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:
- Precompute the deterministic address (free
viewcall) - Show it to the user as their deposit address
- Wait for a deposit event on-chain
- 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:
| Bytes | Content | Purpose |
|---|---|---|
| 0-19 | address(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. |
| 20 | 0x00 | Cross-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-31 | keccak256(walletId) truncated to 11 bytes | Entropy: 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:
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 tocomputeCreate3Addressif 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:
- 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.
- 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).
- Tests precompute by calling
computeWalletAddresswith a test wallet ID. This validates that the factory can communicate with CreateX correctly. - 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:
- Compute the wallet address from the walletId – same as the generation script
- Check if already deployed via
isDeployedReceiver(). This is a free view call that reads the factory’s mapping. - 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
- Not deployed: Call
- Wait for confirmation –
tx.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:
Security Checklist
| Threat | Mitigation | Contract |
|---|---|---|
| Relayer key compromised | Owner calls pause() to freeze all relayer functions | WalletFactory |
| Ownership transfer to wrong address | Ownable2Step requires explicit acceptOwnership() | WalletFactory |
| Reentrancy on native sweeps | nonReentrant modifier on all sweep functions | WalletReceiver |
| Non-standard ERC20 (USDT) | SafeERC20.safeTransfer handles missing return values | WalletReceiver |
| Rounding dust on splits | Last recipient gets balance - distributed | WalletReceiver |
| Out-of-gas on large recipient arrays | MAX_RECIPIENTS = 5 enforced in modifier | Both |
| Arbitrary contract calls via sweepExisting | isDeployedReceiver allowlist validation | WalletFactory |
| Invalid bps configuration | Validates totalBps == 10_000 and bps <= 10_000 per recipient | WalletReceiver |
| Zero-address recipients | Explicit ZeroAddress() check on every recipient | WalletReceiver |
| Salt format corruption | assert(uint8(salt[20]) == 0x00) in buildSalt | WalletFactory |
Production Recommendations
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.
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.
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.
Set up event listeners for
WalletDeployed,Swept,NativeSwept, andEmergencySweepevents. These provide a complete audit trail of all fund movements.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.
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 at0xAAA...and the salt starts with0xAAA.... On Chain B, the factory is at0xBBB...and the salt starts with0xBBB.... 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
- CREATE3 via CreateX gives you the same contract address on every EVM chain, regardless of constructor arguments or bytecode changes
- 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
- The guard trap is the most dangerous pitfall: always replicate CreateX’s internal
_guard()rehash when precomputing addresses, or you will lose user funds - Two-tier access control (owner + relayer) with Pausable provides a clear security boundary: the relayer handles day-to-day operations, the owner handles emergencies
- 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.


