Issuing deposit addresses without holding a private key
A custodial service that shows a user a deposit address has to answer a question the user does not ask: which process produced that address, and what else can that process do? If the answer is that the node generated it, then the component reachable from an HTTP request is also the component that can spend. Deriving deposit addresses from an account-level extended public key splits those two jobs apart. The issuer walks derivation paths and encodes public keys, and it holds nothing that could move a coin.
What asking the node for an address couples together#
Asking a node for a fresh address is the shortest path to a working deposit flow, and it hands the node three jobs at once. It owns the keys. It owns the address gap, meaning how many addresses have been issued and how far ahead the wallet is willing to look for funds. It also owns the recovery story, which becomes a wallet file you restore and cannot verify by reading.
None of that has to be a node problem. Which index was handed to which user is application state, and it belongs in the same database as the user. How far ahead the issuer may go before it refuses is an issuance policy, and the gap limit is where that policy gets written down. Recovery, done properly, is re-derivation from a seed that never touched the server.
The first job is the one that shapes the architecture. If issuing an address is a call into something that can sign, then every route that reaches the issuer is a route that reaches spending authority, and the only thing standing in between is configuration: an unlocked wallet, an RPC allowlist, a method filter. Configuration can be wrong for months without anything failing visibly... That is the property I want to remove, not tighten.
The boundary is arithmetic, not configuration#
An extended public key can derive its non-hardened children and nothing else. Hardened derivation mixes the private key into the child, so a process holding only public material cannot perform it. Not because it is forbidden, but because the input does not exist. That turns a policy into a fact about the code path, and a fact is reviewable.
This is the whole interface of chain-addresses (https://github.com/polycratia/chain-addresses), a small package I maintain for exactly this job:
from chain_addresses import ExtendedPublicKey, get_encoder
account = ExtendedPublicKey.parse(account_xpub)
deposit = account.derive("0/17")
encoder = get_encoder("bitcoin-p2wpkh")
encoder.encode(deposit.public_key) # bc1q...Nothing in the package accepts or stores a private key. Passing "0'/17" or an xprv raises instead of silently doing something else: a hardened path and a private key are both refused at the edge rather than half-handled. The signer stays offline and exports one account-level extended public key, and the service that answers "give me a deposit address" imports this and nothing else.
The reason to care about the refusal rather than about a convention is code review. "The issuer cannot sign" becomes something you confirm by reading imports, instead of a claim about how a node was configured on a host you are not looking at.
One key, many address formats#
Every encoder in the package takes a 33-byte compressed public key and returns a string. Which encoding that is (Base58Check, bech32, bech32m, or EIP-55 hex) stays inside the encoder:
from chain_addresses import ENCODERS
child = account.derive("0/17")
addresses = {name: e.encode(child.public_key) for name, e in ENCODERS.items()}That maps one derived key to bitcoin-p2pkh, bitcoin-p2sh, bitcoin-p2wpkh, bitcoin-p2tr, their testnet variants, and evm. The package's own test asserts that all of those addresses come out distinct, and that is the part worth internalising: the address format is a presentation decision downstream of derivation, not a key decision. Moving deposits from P2SH-wrapped segwit to native segwit, or adding taproot, does not touch the account key, the derivation policy, or the offline signer's export. It changes which script the signer will later have to satisfy, and nothing before that.
The evm encoder is the sharper case. There is exactly one of it because an EVM address carries no chain identity at all, so the same key produces the same twenty bytes on every EVM chain. Your database owns the chain and the address does not. A deposit record without a chain field is unfinished, and a user who sends a stablecoin on a network you are not watching has sent it to an address that is genuinely theirs, on a chain nobody is polling. On the stablecoin rails I have run in production this is a support queue, not a thought experiment. The issuer cannot prevent it, but it can refuse to render an address unless the deposit intent named the chain it is for.
Re-derivation is a free integrity check#
Because the issuer holds no secret, everything it does can be repeated anywhere: in a second process, in another language, in a test. That changes what a stored address is. It is a cache of a pure function of account key, path and format, rather than a fact you have to preserve.
Application-side, issuance is two lines and a database write:
def derive_address(account: ExtendedPublicKey, path: str, address_format: str) -> str:
child = account.derive(path)
return get_encoder(address_format).encode(child.public_key)Store the path and the format next to the address. A periodic job can then walk the table, re-derive, and compare. A mismatch means the configured account key is not the one that row was issued under, and that is the failure I actually worry about.
It has no natural signal. A wrong extended public key in configuration produces perfectly valid, perfectly checksummed addresses for a wallet you cannot spend from, and there is nothing in the string to inspect. Two cheap defences, both built from what the extended key already carries: check at boot that the parsed key's depth and parent_fingerprint match what the offline signer exported, and keep one known index (an address the signer itself produced during setup) as a fixture the issuer must reproduce before it serves traffic.
The package takes the same approach internally. Its test suite carries its own private-key derivation and checks that public derivation reaches the same children, so the public-only path is verified against the private one it is meant to replace, and the encoders are checked against published vectors rather than against themselves. It is pre-alpha, and honest about the edges: BIP32 public derivation and the address formats above are implemented, while chain metadata and gap-limit scanning are not written yet.
What I would do differently#
On the first custodial wallet work I did, the node handed out addresses because that was the fast path and deriving them myself was an afternoon of work. The cost did not arrive as an incident. It arrived as a permanent constraint: the deposit service could not be deployed anywhere the keys could not go, so its blast radius quietly set the security posture of everything sitting next to it.
Two things I would set up from the start now. First, treat the account extended public key as a pinned configuration object verified at boot, using depth, parent fingerprint, and one reproduced fixture address, rather than a string that gets trusted the first time somebody requests a deposit. Second, keep the issuer format-agnostic from day one. Per-chain issuer services look reasonable until the third chain, and at that point you probably have three copies of the index-allocation logic and three separate ways to be wrong about which address was handed to whom.
Close#
HD derivation being elegant is beside the point for me. The property worth having is that a service can hand out an unbounded number of deposit addresses while being unable to spend from any of them, and that this reduces to two things a reviewer can check in an afternoon: the issuer only ever sees an extended public key, and non-hardened derivation is the only derivation it is capable of performing.