"Derive an address, watch the mempool, fire a webhook" — that is how every crypto payment tutorial starts. What they skip is: where does the address come from?
The naive answer: generate a random keypair per payment, store the private key in your database. This works for a weekend project. It becomes a catastrophic key-management problem once you have several hundred merchants each running thousands of orders per day. A gateway with 300 merchants averaging 500 orders per day accumulates 150,000 new private keys every 24 hours. That is a database breach waiting to happen, a backup strategy nobody has thought through, and an audit nightmare.
Hierarchical deterministic (HD) wallets solve this. BIP-32 lets you derive an unlimited number of addresses from one master seed, deterministically. The seed can live in a hardware security module (HSM); the API workers that hand out deposit addresses never see it. This post is the derivation architecture behind BchainPay.
BIP-32: one seed, unlimited child keys#
A BIP-32 extended key is a 512-bit value: a 256-bit scalar (private or
public key) plus a 256-bit chain code that prevents cross-branch
derivation attacks. Given a parent extended key, you derive a child at
index i with:
HMAC-SHA512(key=chain_code, data=parent_key_material || i)
→ (left 32 bytes) tweak
→ (right 32 bytes) child_chain_code
child_private_key = (parent_private_key + tweak) mod n
child_public_key = parent_public_key + tweak·G
The second line is the critical property. Because secp256k1 public keys
are just scalar multiples of the generator point G, adding the same
tweak to a private key or directly to a public key produces the same
child public key. You can derive a child public key from the parent
public key alone — no private key material required.
This is what makes xpub-based watch-only wallets work.
Hardened vs normal children#
Indices below 2^31 (written 0 through 2,147,483,647) are normal
children: derivable from the parent public key. Indices from 2^31
upward (written 0' through 2,147,483,647', or equivalently
2147483648 through 4294967295) are hardened: they require the
parent private key to derive.
Hardened children are isolated. Knowing a hardened child's private key tells an attacker nothing useful about siblings or the parent. Normal children have no such isolation — more on that in the threat model.
The BIP-44 convention uses hardened derivation for the first three path segments (where humans make naming decisions) and normal derivation for the last two (where workers need high-frequency access from an xpub).
BIP-44 path structure#
BIP-44 defines a five-level hierarchy on top of BIP-32:
m / purpose' / coin_type' / account' / change / index
For a payment gateway the segments mean:
| Segment | Value | Notes |
|---|---|---|
purpose' |
44' |
BIP-44 scheme |
coin_type' |
60' (ETH/EVM), 195' (TRON) |
SLIP-44 registry |
account' |
0' |
One per merchant namespace |
change |
0 |
Always 0 for receive addresses |
index |
0, 1, 2, … |
Monotonic counter, one per payment intent |
The first three segments are hardened and live in the HSM. Merchants
register an account-level xpub — the key at m/44'/60'/0' — and from
that point workers derive the leaf nodes m/0/0, m/0/1, m/0/N on
demand, with no HSM round-trip required.
m/44'/60'/0' ← account xpub registered by merchant (or BchainPay-managed)
└─ m/0/ ← external (receive) chain
├─ m/0/0 → 0xA4dB9a3F… (payment intent #4820)
├─ m/0/1 → 0xB7eF31c2… (payment intent #4821)
└─ m/0/N → 0xC9aA6e7d… (payment intent #4820+N)
The xpub isolation model#
The security split:
- HSM (cold): holds the master seed. Derives per-merchant account-level xpubs on onboarding. Signs sweep transactions when BchainPay consolidates funds to the merchant's treasury. Never exposed to API request handlers.
- Hot workers: receive only the account-level xpub. Derive deposit addresses at each new index. Watch for incoming transfers. Cannot produce a signature and cannot spend funds.
The critical operation — moving funds out of a deposit address — requires the child private key, which no hot worker can compute from an xpub.
xpub threat model#
An xpub is not a secret but it is sensitive:
- Compromised xpub reveals every deposit address you have issued or will ever issue on that branch. An attacker can enumerate your full payment graph: transaction volume, merchant activity, and timing.
- Compromised child private key + xpub is worse. Because the child
was derived via normal (non-hardened) derivation, an attacker who
holds both can arithmetically compute the parent account private key.
This is precisely why workers receive the xpub at
m/44'/60'/0'— the account level — not at the root. Even if a leaf key leaks, the attacker cannot climb above the account boundary.
If an xpub is suspected compromised, rotate by incrementing the account
index. The HSM exports a fresh m/44'/60'/1' xpub; the master seed is
unchanged. Existing deposit addresses remain derivable from the old xpub
for historical reconciliation.
TypeScript: deriving EVM addresses from an xpub#
import { HDKey } from '@scure/bip32';
import { keccak_256 } from '@noble/hashes/sha3';
import { bytesToHex } from '@noble/hashes/utils';
// Account-level xpub registered at onboarding.
// Workers receive this; they never touch xprv material.
const merchantXpub =
'xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZ...';
const accountKey = HDKey.fromExtendedKey(merchantXpub);
function deriveDepositAddress(intentIndex: number): string {
// Relative path: `m/0/${intentIndex}` means change=0, index=N
// from the already-account-level root held in accountKey.
const child = accountKey.derive(`m/0/${intentIndex}`);
if (!child.publicKey) throw new Error('derivation failed');
// Ethereum address: keccak256 of the uncompressed public key,
// take the last 20 bytes, apply EIP-55 checksum.
const uncompressed = decompressPubkey(child.publicKey); // 65 bytes
const hash = keccak_256(uncompressed.slice(1)); // drop 0x04 prefix
return toChecksumAddress(`0x${bytesToHex(hash.slice(12))}`);
}Every operation here is public-key arithmetic. No secret material, no HSM call. The same xpub produces the same address for the same index on any machine, making derived addresses fully reproducible from the xpub alone.
Solana: why xpub isolation does not apply#
Solana uses ed25519, not secp256k1. The homomorphic property that makes
normal BIP-32 derivation work — child_pub = parent_pub + tweak·G — does
not hold on the ed25519 curve.
SLIP-0010 defines ed25519 HD derivation but mandates that every path segment be hardened. There is no such thing as an ed25519 "xpub" from which a worker can derive child public keys without the corresponding private key.
For Solana, BchainPay uses one of two approaches:
-
HSM-backed derivation: the hot worker sends a derivation request to the HSM —
{ merchant_id, chain: "solana", index: N }— and the HSM returns only the derived public key. The private key material never leaves. Sweep-signing requests follow the same model. -
Program-derived addresses (PDAs): a Solana program owned by BchainPay derives unique receiving accounts from a nonce. PDAs are verifiably unique and carry on-chain routing rules, but they require rent management and add implementation complexity.
For EVM and TRON merchants the xpub path is the default. Solana merchants go through HSM-backed derivation.
TRON: same xpub model, different address encoding#
TRON uses secp256k1 with coin type 195' in SLIP-44. The derivation
from an account-level xpub is mathematically identical to EVM:
m/44'/195'/0'/0/N → derive from xpub, same secp256k1 arithmetic
The address encoding is different: compute Keccak-256 of the public key,
replace the first byte with 0x41 (TRON mainnet prefix), then
Base58Check-encode the result. The worker library handles this
transparently; the derivation path is the only difference from EVM.
Index management#
Derivation indices are 32-bit unsigned integers, range 0 to 4,294,967,295. Do not use payment intent ID strings as indices. The correct model is a monotonic counter per merchant per chain family, atomically incremented when a new payment intent is created:
async function allocateIndex(
merchantId: string,
chainFamily: 'evm' | 'tron',
): Promise<number> {
return db.transaction(async (tx) => {
const { next_index } = await tx.queryOne(
`SELECT next_index
FROM merchant_chain_keys
WHERE merchant_id = $1 AND chain_family = $2
FOR UPDATE`,
[merchantId, chainFamily],
);
await tx.execute(
`UPDATE merchant_chain_keys
SET next_index = $1
WHERE merchant_id = $2 AND chain_family = $3`,
[next_index + 1, merchantId, chainFamily],
);
return next_index;
});
}Store the allocated index on the payment intent record. Even if the intent expires or the customer never pays, do not reuse the index. The derived address is publicly visible the moment a customer loads the payment page, and index reuse creates reconciliation ambiguity that is very hard to untangle from transaction history.
Gap limit and disaster recovery#
BIP-44 defines a gap limit of 20: a wallet scanner stops probing when it finds 20 consecutive addresses with no on-chain activity. For a live gateway this number is irrelevant — you track every issued index in your own database. It matters only for disaster recovery.
If your database is lost:
- Start from index 0 on the account xpub.
- For each derived address, query the chain for any incoming transfers.
- Match transaction amounts and timestamps against surviving order records (email logs, bank statements, merchant exports).
- Stop when 20 consecutive derived addresses show no on-chain activity — that is your high-water mark.
This recovery path only exists because derivation is deterministic. A gateway that stored random private keys per payment has no equivalent; it must rely entirely on backup integrity.
Practical implication: keep gaps small. If you allocate an index but the intent is cancelled before the address is ever shown to a customer (no on-chain footprint, no external visibility), that index becomes invisible to a scan-based recovery. Track all allocated indices in the database, not only the confirmed ones.
BchainPay integration#
Register an account-level xpub on onboarding:
curl -X POST https://api.bchainpay.com/v1/merchants/me/chain-keys \
-H "Authorization: Bearer $BCHAINPAY_SECRET" \
-H "Content-Type: application/json" \
-d '{
"chain_family": "evm",
"xpub": "xpub6CUGRUonZSQ4...",
"account_path": "m/44h/60h/0h"
}'After that, every POST /v1/payment-intents call derives deposit
addresses server-side and returns the derivation index for your records:
{
"id": "pi_01HW9XK2…",
"status": "awaiting_payment",
"deposit_addresses": {
"USDC.ethereum": "0xA4dB9a3F…",
"USDT.polygon": "0xB7eF31c2…"
},
"_meta": {
"USDC.ethereum": {
"derivation_index": 4821,
"derivation_path": "m/0/4821"
},
"USDT.polygon": {
"derivation_index": 4821,
"derivation_path": "m/0/4821"
}
}
}Both EVM assets share index 4821 — same logical payment, one deposit
address per chain. Your accounting system can use derivation_index to
cross-reference any deposit address back to its originating order
without a secondary lookup.
If you prefer BchainPay to manage the key material entirely (no xpub
upload), the gateway derives addresses from its own HSM-managed seed on
your behalf. The _meta.derivation_index field is still returned so
your records stay portable.
Key takeaways#
- BIP-32 child derivation from an xpub requires only public-key arithmetic — hot workers never need the private key to generate deposit addresses.
- Upload an account-level xpub (
m/44'/coin_type'/0'): workers derivem/0/Nfrom it; the hardened segments never leave the HSM. - xpub compromise exposes your full address graph and — if combined with any child private key leak — can reconstruct the account private key. Rotate by incrementing the account index, not the seed.
- ed25519 (Solana) cannot use xpub watch-only derivation; all ed25519 segments must be hardened. Use HSM-backed derivation or PDAs instead.
- TRON secp256k1 derivation is identical to EVM; only the final address encoding (0x41 prefix + Base58Check) differs.
- Use a monotonic
next_derivation_indexper merchant per chain family. Never reuse an index; never leave unnecessary gaps. - Disaster recovery from xpub + chain history is possible and is the strongest argument for HD wallets over random keypair storage.