PAY-005
Derive deposit addresses from an xpub without a hot wallet
Give every user their own address while the private keys stay offline. The watch-only pattern, and why the gap limit matters.
Assigning each user a permanent deposit address makes attribution trivial: coin arriving at that address belongs to that account, with no memo field and no support ticket about a missing reference.
Doing it with a hot wallet means private keys on a web server. The alternative is an extended public key. The xpub derives an unlimited sequence of addresses; the private keys stay wherever you generated them. A compromised server exposes the address list and nothing spendable.
The trap is the gap limit. Wallets scan a fixed number of unused addresses ahead — twenty by default — and stop. Hand out a hundred addresses that nobody uses and a deposit at number 101 will not appear when you restore.
// Derivation itself needs secp256k1 arithmetic; use a maintained BIP32 library
// rather than writing point multiplication yourself. What matters here is the
// bookkeeping around it.
function assign_deposit_address(PDO $pdo, int $userId): string
{
$pdo->beginTransaction();
try {
// Reuse the one already issued: a user must not accumulate addresses.
$st = $pdo->prepare('SELECT address FROM deposit_addresses WHERE user_id = ? LIMIT 1');
$st->execute([$userId]);
$existing = $st->fetchColumn();
if ($existing !== false) {
$pdo->rollBack();
return (string) $existing;
}
// Lock the counter so two signups cannot take the same index.
$st = $pdo->prepare('SELECT next_index FROM wallet_meta WHERE id = 1 FOR UPDATE');
$st->execute();
$index = (int) $st->fetchColumn();
$address = derive_from_xpub(setting('deposit_xpub'), $index); // library call
$pdo->prepare('INSERT INTO deposit_addresses (user_id, address, idx, created_at) VALUES (?, ?, ?, NOW())')
->execute([$userId, $address, $index]);
$pdo->prepare('UPDATE wallet_meta SET next_index = next_index + 1 WHERE id = 1')->execute();
$pdo->commit();
return $address;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
// Watch for arrivals by polling a block explorer or your own node, then credit
// through the same idempotent path used by every other deposit.
Using it
Issue an address only when a user asks to deposit, not at signup. That keeps the used indexes dense and stays inside the gap limit.
Record the derivation index on every address. Restoring the wallet later means knowing how far you went.
Require confirmations before crediting. One is enough for small amounts on most chains; zero is not.
What bites people
An xpub reveals every address you will ever derive from it. That is a privacy loss, not a security one, but publish it nowhere.
Reusing an address across users makes attribution impossible the moment two deposits arrive together.
Watch-only means you cannot spend. Sweeping the deposits still needs the offline key, which is exactly the point.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.