Developing a Unique Payment Address Generation System
One address for all payments with comment — works until users forget to specify memo. But when 5% of payments arrive without identifier and require manual reconciliation — that's an operational problem. Unique address per payment solves it completely: each order gets its own address, any incoming payment is uniquely identified.
HD Wallet Derivation: The Math Behind
Foundation of the system — hierarchical deterministic wallets (BIP-32/BIP-44). From one master seed you can deterministically derive unlimited child keys — addresses are reproducible, any child key's private key recovers from seed at any time.
Master Seed (128/256 bits)
↓ BIP-39
Master Mnemonic (12/24 words)
↓ BIP-32 HMAC-SHA512
Master Extended Key (xprv)
↓ BIP-44 derivation
m / purpose' / coin_type' / account' / change / index
For Ethereum (coin_type = 60):
m/44'/60'/0'/0/0 → first address
m/44'/60'/0'/0/1 → second address
m/44'/60'/0'/0/N → (N+1)-th address
Key point: address-only derivation doesn't require private key. Extended public key (xpub) is enough for address generation. Allows separation of concerns: address generation server holds only xpub (compromise = address leak, not funds), transaction signing server holds xprv in isolated environment (HSM, offline).
Implementation
import { HDNodeWallet, Mnemonic } from 'ethers';
class PaymentAddressGenerator {
private hdNode: HDNodeWallet;
private currentIndex: number;
constructor(xpub: string, startIndex: number = 0) {
// From xpub — public derivation only
this.hdNode = HDNodeWallet.fromExtendedKey(xpub);
this.currentIndex = startIndex;
}
generateAddress(orderId: string): { address: string; index: number } {
// Use atomic counter from DB, not local state
const index = this.currentIndex++;
const childNode = this.hdNode.deriveChild(index);
return {
address: childNode.address.toLowerCase(),
index,
};
}
}
// Atomic generation: get next index from PostgreSQL sequence
async function getNextIndex(db: Pool): Promise<number> {
const result = await db.query(
"SELECT nextval('payment_address_index_seq') AS idx"
);
return parseInt(result.rows[0].idx);
}
Why not just increment in code? With horizontal scaling, multiple service instances can simultaneously get the same index. PostgreSQL sequence is atomic — nextval always returns unique value.
Database: Schema
CREATE SEQUENCE payment_address_index_seq START 1;
CREATE TABLE payment_addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
address VARCHAR(42) NOT NULL UNIQUE,
derivation_index INTEGER NOT NULL UNIQUE,
order_id UUID NOT NULL REFERENCES orders(id),
network VARCHAR(20) NOT NULL, -- 'ethereum', 'bsc', 'polygon'...
currency VARCHAR(20) NOT NULL, -- 'ETH', 'USDT', 'USDC'...
expected_amount NUMERIC(36, 18),
received_amount NUMERIC(36, 18) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ NOT NULL,
confirmed_tx VARCHAR(66), -- tx hash
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_payment_addresses_address ON payment_addresses(address);
CREATE INDEX idx_payment_addresses_status ON payment_addresses(status)
WHERE status = 'pending';
Index by address is critical for O(1) lookup when receiving transaction from blockchain listener.
Monitoring: Track Only Active Addresses
Naive approach — subscribe to all generated addresses. In large system this is thousands of addresses. Better: keep in-memory set of active (pending) addresses, updated on payment creation/closure.
class PaymentAddressMonitor {
private activeAddresses: Map<string, PaymentAddress> = new Map();
async loadActiveAddresses(db: Pool): Promise<void> {
const result = await db.query(
`SELECT address, order_id, expected_amount, currency, expires_at
FROM payment_addresses
WHERE status = 'pending' AND expires_at > NOW()`
);
for (const row of result.rows) {
this.activeAddresses.set(row.address.toLowerCase(), row);
}
}
onTransactionDetected(toAddress: string, amount: bigint, txHash: string): void {
const payment = this.activeAddresses.get(toAddress.toLowerCase());
if (!payment) return; // Not our address
// Amount check with 1% tolerance
const tolerance = payment.expectedAmount * 99n / 100n;
if (amount >= tolerance) {
this.confirmPayment(payment, txHash);
}
}
}
Multi-Network: One Index, Different Addresses
EVM-compatible networks use the same private key — address is identical on Ethereum, BNB Chain, Polygon, Arbitrum. Convenient: one index in table can receive payments in different networks to same address, but monitoring must be separate for each network.
For non-EVM (TON, Solana, Bitcoin) — separate HD derivations with different master seed or different derivation paths.
Sweeping: Funds Consolidation
Funds on child addresses must periodically gather to main address (cold wallet or multisig). Sweep should happen after payment confirmation:
async function sweepAddress(index: number): Promise<void> {
// Private key derivation — only on signing server
const privateKey = masterHdNode.deriveChild(index).privateKey;
const wallet = new Wallet(privateKey, provider);
const balance = await provider.getBalance(wallet.address);
const gasEstimate = 21000n;
const gasPrice = await provider.getFeeData().then(d => d.gasPrice!);
const gasCost = gasEstimate * gasPrice;
if (balance <= gasCost) return; // Nothing to sweep
await wallet.sendTransaction({
to: HOT_WALLET_ADDRESS,
value: balance - gasCost,
gasLimit: gasEstimate,
});
}
For ERC-20 tokens sweep is more complex: first ensure address has ETH for gas, send it from main address, then withdraw tokens. Or use Permit/EIP-2612 pattern — then gas paid by main address.
Security
- Master seed — in HSM or AWS KMS. Never in environment variables.
- xpub for address generation — separate from xprv for signing.
- Signing service — isolated microservice with minimal privileges.
- Audit all sweep operations — each transaction logged with justification.
- Gap limit — BIP-44 recommends not looking deeper than 20 consecutive unused addresses during wallet recovery.







