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 withCREATEaddresses.factory— the 20-byte address of the deployer contract.salt— a 32-byte value you control. Usekeccak256(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 ++ initCodeHashin 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.
sweepBatchamortizes 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_HASHat 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.