BchainPay logoBchainPay
EngineeringEVMSolidityPayments

CREATE2 deterministic deposit addresses for EVM payment routing

Generate unique per-order EVM deposit addresses with CREATE2, deploy minimal inbox contracts on demand, and batch-sweep funds without managing per-address keys.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for CREATE2 deterministic deposit addresses for EVM payment routing

Every crypto payment gateway assigns a unique deposit address per order. Without this, two customers paying simultaneously to the same address require off-chain correlation to disambiguate. The two dominant patterns for EVM chains are: HD wallet key derivation (BIP-32 child keys, one per order) and CREATE2 — the opcode that lets you predict a contract address before it is deployed.

HD derivation is the right choice for Bitcoin and UTXO chains. For EVM, CREATE2 is usually better: no per-address private key ever exists, the inbox contract is the custodian of funds, and sweeping is a single privileged call rather than one signed transaction per address. This post covers the mechanics end-to-end.

The address-per-order requirement#

A stablecoin transfer carries no payment reference. When a customer sends 120 USDC to an address, nothing in the transaction body says "this is for order 9f2a1b". Your matching logic depends entirely on which address receives the funds. Reusing addresses makes the matching ambiguous and leaks information: any observer can correlate separate orders to the same merchant customer.

You need a fresh address per order. On EVM, two ways exist to get one without a centralized address registry: derive a new key from an HD wallet root, or predict a CREATE2 contract address using an order-specific salt. Both are deterministic; only CREATE2 eliminates key material entirely.

What CREATE2 buys you over HD wallets#

The HD wallet derivation approach gives you a private key at each derivation path. To sweep funds, your service must sign a transaction from each child address, which means your KMS or HSM must service one signing request per inbox. At ten thousand orders a day that is ten thousand individual signing operations per sweep cycle.

CREATE2 inboxes invert the model. The inbox contract is deployed to a deterministic address; the factory contract owns every inbox. To sweep N inboxes, the factory signs exactly one transaction. Gas cost for each inbox is proportional to inbox bytecode deployment (first sweep only) plus a transfer call; no per-address key exists to protect or rotate.

A secondary benefit: addresses are not enumerable from an extended public key. An HD wallet's xpub lets any observer regenerate every past and future deposit address. CREATE2 addresses depend on a factory address and a salt derived from your internal order IDs; an attacker would need both to enumerate anything useful.

CREATE2 address formula#

EIP-1014 (shipped in Constantinople, 2019) defines the opcode. The resulting address is:

address = keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))[12:]
  • 0xff — a single-byte prefix that prevents collision with CREATE addresses.
  • factory — the 20-byte address of the deployer contract.
  • salt — a 32-byte value you control. Use keccak256(orderId) with a UUID-based order ID.
  • keccak256(initCode) — the hash of the deployed contract's creation bytecode.

All four inputs are known before any transaction is sent, so you can compute the address entirely off-chain. There is no nonce, no sequencing, no derivation path to track.

The PaymentInbox contract#

Keep the inbox as small as possible. Smaller bytecode means a cheaper initCodeHash, a lower deployment gas cost, and a smaller attack surface:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}
 
contract PaymentInbox {
    address public immutable factory;
 
    constructor() {
        factory = msg.sender;
    }
 
    // Sweep ETH and one ERC-20 token to treasury.
    // Only the factory that deployed this inbox may call sweep.
    function sweep(address token, address treasury) external {
        require(msg.sender == factory, "only factory");
        if (token != address(0)) {
            uint256 bal = IERC20(token).balanceOf(address(this));
            if (bal > 0) IERC20(token).transfer(treasury, bal);
        }
        uint256 ethBal = address(this).balance;
        if (ethBal > 0) {
            (bool ok,) = treasury.call{value: ethBal}("");
            require(ok, "eth transfer failed");
        }
    }
 
    receive() external payable {}
}

factory is an immutable set once at construction: no storage slot, no upgrade path, no ambiguity about who controls the inbox. The receive fallback makes the inbox accept native token (ETH, MATIC, BNB); ERC-20 transfers arrive without it because the token contract updates its own internal balance mapping.

The factory contract#

The factory predicts addresses with addressFor, deploys inboxes on demand, and sweeps multiple inboxes in a single batch transaction:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "./PaymentInbox.sol";
 
contract PaymentFactory {
    address public immutable treasury;
 
    bytes   public constant INBOX_CODE = type(PaymentInbox).creationCode;
    bytes32 public constant INBOX_HASH = keccak256(INBOX_CODE);
 
    event Deployed(bytes32 indexed salt, address inbox);
    event Swept(address indexed inbox, address token, uint256 amount);
 
    constructor(address _treasury) {
        treasury = _treasury;
    }
 
    function addressFor(bytes32 salt) public view returns (address) {
        return address(uint160(uint256(keccak256(
            abi.encodePacked(bytes1(0xff), address(this), salt, INBOX_HASH)
        ))));
    }
 
    function sweepBatch(bytes32[] calldata salts, address token) external {
        for (uint256 i; i < salts.length; ++i) {
            address inbox = _ensureDeployed(salts[i]);
            PaymentInbox(payable(inbox)).sweep(token, treasury);
        }
    }
 
    function _ensureDeployed(bytes32 salt) internal returns (address inbox) {
        inbox = addressFor(salt);
        if (inbox.code.length == 0) {
            bytes memory code = INBOX_CODE;
            assembly {
                inbox := create2(0, add(code, 0x20), mload(code), salt)
            }
            require(inbox != address(0), "deploy failed");
            emit Deployed(salt, inbox);
        }
    }
}

sweepBatch is idempotent: if an inbox was already swept and holds a zero balance, the transfer calls are no-ops and the iteration costs only empty balance checks plus storage reads. This makes it safe to include previously-swept inboxes in a batch without pre-filtering off-chain.

Note that INBOX_CODE and INBOX_HASH are constant, not immutable. They are evaluated at compile time and embedded in the factory's own bytecode. The salt-to-address mapping is therefore stable as long as the factory contract and the inbox bytecode remain unchanged.

Computing the address off-chain#

Reimplement the formula in your backend using viem:

import { keccak256, toBytes, getContractAddress } from 'viem';
 
// Precomputed at deploy time from PaymentInbox creation bytecode.
// Must match PaymentFactory.INBOX_HASH on-chain.
const INBOX_INIT_CODE_HASH =
  '0x...' as `0x${string}`;  // fill from build output
 
const FACTORY_ADDRESS = '0xYourDeployedFactory' as `0x${string}`;
 
export function depositAddressFor(orderId: string): `0x${string}` {
  const salt = keccak256(toBytes(orderId));
  return getContractAddress({
    opcode:       'CREATE2',
    from:         FACTORY_ADDRESS,
    salt,
    bytecodeHash: INBOX_INIT_CODE_HASH,
  });
}

getContractAddress with opcode: 'CREATE2' handles the 0xff ++ deployer ++ salt ++ initCodeHash formula internally. The function is pure: no async, no network, no state. You can call it synchronously in your order-creation path and return the deposit address to the client without a round trip to any RPC node.

Store INBOX_INIT_CODE_HASH as an environment variable or build-time constant; confirm it matches PaymentFactory.INBOX_HASH in a deployment test before going to production.

Integrating with BchainPay#

When creating the payment intent, pass the precomputed deposit_address:

curl -X POST https://api.bchainpay.com/v1/payment_intents \
  -H "Authorization: Bearer $BCHAINPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "120.00",
    "currency": "USDC",
    "chain": "polygon",
    "deposit_address": "0xa3f9...d92b",
    "metadata": {
      "order_id": "ord_9f2a1b",
      "inbox_salt": "0xc3ab8ff13720320a8a872c0328b37..."
    },
    "webhook_url": "https://yourstore.com/hooks/bchainpay"
  }'

The API monitors deposit_address for incoming ERC-20 transfers regardless of whether the inbox contract has been deployed. ERC-20 tokens do not require the recipient to be a deployed contract: the token's internal balance mapping is updated on transfer without executing any code at the recipient address. ETH and native tokens are held in the EVM balance table by the same logic.

{
  "id": "pi_H7g3k1",
  "status": "awaiting_payment",
  "deposit_address": "0xa3f9...d92b",
  "amount_due": "120.000000",
  "expires_at": "2026-04-27T16:00:00Z"
}

When payment_intent.succeeded fires, your sweep worker reads the inbox_salt from the intent's metadata, bundles it with other pending salts, and calls sweepBatch once per interval (or when the batch reaches a target size such as 50 inboxes). On Polygon at 50 inboxes per batch, the per-inbox sweep cost is typically under 30,000 gas units — a few cents at current gas prices — amortized across the entire batch.

Security considerations#

Salt entropy. keccak256(orderId) is safe when order IDs are non-guessable — UUID v4 or a CSPRNG-backed identifier. A sequential integer salt lets any observer enumerate your future deposit addresses and correlate payment volume from the public mempool. Use UUIDs.

INBOX_HASH is immutable by design. The salt-to-address mapping depends on the factory address and inbox bytecode hash. If you need to upgrade the inbox (add a new token path, fix a bug), deploy a new factory contract at a new address. Do not attempt to make the factory upgradeable: a mutable INBOX_HASH would allow whoever controls the upgrade to redirect swept funds to an attacker-controlled treasury.

Front-running _ensureDeployed. An attacker can call your factory's public sweepBatch with a salt they observe in the mempool. If the inbox is not yet deployed, it gets deployed during their call — but factory inside the inbox is set to msg.sender, which is your factory. The attacker's invocation of sweep is then blocked by require(msg.sender == factory). They pay the deployment gas; your next sweepBatch finds the inbox already deployed and sweeps the funds normally. No funds are at risk.

Reorg safety. Apply the same confirmation-depth policy to sweep transactions that you apply to incoming payments. A reorged sweep leaves funds sitting in the inbox; the next sweepBatch call recollects them without manual intervention. Never release order fulfillment until the sweep has reached confirmation depth.

Multi-token inboxes. The contract above sweeps one token per call. If an inbox might receive both USDC and ETH (a common edge case when customers accidentally attach a gas tip), call sweepBatch twice: once with token = USDC_ADDRESS and once with token = address(0). Both calls check existing balances; the second call costs minimal gas if one token was already empty.

Key takeaways#

  • CREATE2 eliminates per-address key material. The factory, secured by your treasury multisig, is the only key that can sweep any inbox. HSM signing requests scale with batch count, not inbox count.
  • Addresses are fully predictable off-chain. No nonce, no RPC call, no derivation path. Compute 0xff ++ factory ++ salt ++ initCodeHash in your order-creation service and return the result synchronously.
  • ERC-20 deposits survive before deployment. Tokens sent to a CREATE2 address land in the token contract's balance mapping immediately. The inbox contract is deployed during sweep, not during payment.
  • sweepBatch amortizes costs. At 50 inboxes per call, fixed transaction overhead is shared 50-fold. A well-tuned batch interval keeps sweep costs well below 0.1% of transaction value on any L2.
  • Use UUID-derived salts, not sequential integers. Guessable salts allow address enumeration and payment-volume inference from the public mempool.
  • Pin INBOX_HASH at factory deploy time. The address mapping is only stable as long as the factory contract and inbox bytecode do not change. Maintain a separate factory per inbox version; never upgrade a factory in place.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading