A testnet xpub under a mainnet encoder: addresses nobody scans
A deposit service pairs an extended public key with an address encoder, and there is exactly one way to get that pair wrong that produces no error at any layer. A testnet key under a mainnet encoder parses cleanly, derives cleanly, and hands back a well-formed mainnet address with a valid checksum, while the signer holding the seed watches a different chain and never sees the deposit. The defect is not in the key. It is not in the address either. It sits in the pair, which is why the pair is the only place a check can live.
The failure that reports success#
Three steps stand between an account-level key and an address a customer pastes into a wallet, and each one has a narrow, honest contract.
Parsing validates structure. In bip32.py, ExtendedPublicKey.parse rejects a payload that is not 78 bytes, rejects the six private versions by name, and rejects any version not in PUBLIC_VERSIONS; __post_init__ then checks depth, fingerprint length, chain code length and the 33-byte compressed point. A tpub passes all of it, because a tpub is a perfectly valid extended public key.
Derivation is arithmetic. A chain code and a curve point go in, a child point comes out. There is no network anywhere in that computation: the same scalar addition serves every chain that runs on secp256k1.
Encoding is a lookup and a string. The AddressEncoder protocol in addresses.py has two members, a name and encode(public_key) -> str. P2WPKH takes a 33-byte compressed key, hashes it, and emits bech32 under its own hrp. It does not ask where the key came from, and that is what the protocol buys you: code holding an encoder never has to know which chain it is serving.
So the mismatch is invisible to every step on its own. It is also invisible to address validation after the fact, and that part is worth sitting with. Hand the resulting string to validate_address with network="mainnet" and it comes back valid, because it genuinely is a well-formed mainnet address. Nothing is wrong with the address. The wrong part is that no signer on mainnet derived it, so no scanner on mainnet is looking for it. Validating the output cannot catch a defect that exists only in the relationship between two inputs.
Where each network is written down#
Both facts are present, just sitting on different objects.
On the key side, the fact is the version prefix. PUBLIC_VERSIONS maps the six serialisation versions to xpub, ypub, zpub, tpub, upub and vpub, and gap.py maps those names to networks:
_KEY_NETWORKS = {
"xpub": Network.MAINNET,
"ypub": Network.MAINNET,
"zpub": Network.MAINNET,
"tpub": Network.TESTNET,
"upub": Network.TESTNET,
"vpub": Network.TESTNET,
}On the encoder side, the fact is the name. The registry is flat and the names carry their own scope (bitcoin-p2wpkh, bitcoin-testnet-p2wpkh, evm), so you can read the network straight off the string:
def _encoder_network(encoder: AddressEncoder) -> Network:
if not encoder.name.startswith(_BITCOIN_PREFIX):
return Network.ANY
if encoder.name.startswith(_TESTNET_PREFIX):
return Network.TESTNET
return Network.MAINNET
def _check_networks_agree(
account: ExtendedPublicKey, encoder: AddressEncoder
) -> None:
prefix = PUBLIC_VERSIONS[account.version]
key_network = _KEY_NETWORKS[prefix]
encoder_network = _encoder_network(encoder)
if encoder_network is Network.ANY or encoder_network is key_network:
return
raise LedgerError(...)The PUBLIC_VERSIONS lookup is unguarded on purpose: __post_init__ has already refused any version that is not in that table, so by the time a key reaches the ledger its prefix is known to exist. An invariant established at construction is worth spending, not re-checking.
The message on that LedgerError names the prefix, both networks, and the encoder. That is not padding. A network mismatch is a configuration mistake, and whoever fixes it is reading a log line while deciding whether to roll back. tpub alone does not say what it was paired with, and network mismatch says nothing at all.
Networks, not script types#
The comparison stops at networks, deliberately. A zpub is allowed to drive bitcoin-p2pkh, and that is not an oversight.
Version bytes describe the address format the exporting signer expects, not the derivation. The chain code and the point under a zpub are the same chain code and point you would get under an xpub at the same path; the prefix is a hint about the signer's preference, carried along in the serialisation. A key can produce every format in the table, and sometimes it has to. A signer that only exports one prefix, or a service still issuing legacy addresses for an old integration, is a legitimate setup rather than a mistake. Refusing it would turn the check into an obstacle, and an obstacle that refuses correct configurations is the kind of check people learn to route around.
Network is a different sort of claim. It is not a preference about formatting, it is a statement about which chain the watch-only wallet on the other side is scanning. Get it wrong and you do not get an ugly address, you get an unscanned one.
Network.ANY is the same reasoning applied to EVM chains. The evm encoder takes the key's x and y coordinates and produces EIP-55 hex; nothing in that path distinguishes one EVM network from another, so there is no claim to contradict and either key is accepted. validate.py treats the same format the same way: EIP-55 hex belongs to Network.ANY and passes any network check.
The check runs before the first derivation#
In AddressLedger.__init__ the refusals are ordered, and the order is the design:
if gap_limit < 1:
raise LedgerError(f"gap limit must be at least 1, got {gap_limit}")
_check_index(next_index, "next index")
if last_used is not None:
_check_index(last_used, "last used index")
if last_used >= next_index:
raise LedgerError(...)
resolved = get_encoder(encoder) if isinstance(encoder, str) else encoder
_check_networks_agree(account, resolved)
_check_hardened_above(account, branch)
self._branch_key = account if branch is None else account.child(branch)Policy arithmetic comes first, because it is free. Then get_encoder resolves a string name, raising AddressError for one it does not know: the network check needs a real encoder with a real name, so resolution has to come before agreement. Then the two refusals that are about safety rather than bookkeeping, networks agree and the key sits below a hardened step. Only after all of that does account.child(branch) run, which is the first moment any key material is touched.
The effect is that an AddressLedger cannot exist in a state where it would mint addresses nobody scans. No caller has to remember a check, no code path can skip it, and the failure arrives at the line that built the object, in configuration-loading code, with a stack trace pointing at the configuration.
The snapshot is a second door into the same room#
A ledger that hands out addresses in order has state worth persisting: next_index, last_used, the gap limit, the branch, the account key, the format. So there is a restore path, and restore paths are where constructor invariants usually go to die. The tempting shape is to allocate the object and assign the fields back one at a time, which is fast, total, and bypasses every check above.
restore() instead requires account and format in the snapshot, raising LedgerError that lists whatever is missing, and then calls the constructor with the remaining values defaulted. Every refusal runs again on the way back in.
snapshot = {
"account": account_xpub,
"format": "bitcoin-p2wpkh",
"next_index": 41,
"last_used": 37,
}
book = AddressLedger.restore(snapshot)Snapshots get edited apart from the code that wrote them. Someone copies a staging row into production, someone fixes a format by hand during an incident, someone restores a backup taken before a key rotation. Each of those can produce a stored pair that was never possible through the constructor, and a restore that trusts its input accepts all of them. With the check on the load path, a snapshot that disagrees with itself fails at startup. Without it, the first symptom is a customer deposit at an address no scanner reaches, found days later with nothing left to do about it.
All of this lives in chain-addresses, in gap.py alongside the gap-limit bookkeeping.
What I would do differently#
In earlier custodial work the network was a deployment variable. One environment variable selected the key, another selected the address format, and nothing compared them, because on any single environment they were correct anyway: staging configured for testnet, production for mainnet, and the mismatch was a state no running process ever occupied. That is the shape of bug no test on one environment finds. Both halves are right, and only the combination is wrong.
What I would do earlier is make the network a property derived from the artifacts rather than declared next to them. The tpub already knows what it is. The encoder name already knows what it is. Once those two get read off the objects and compared, the mismatched pair stops being a configuration that happens to be correct today and becomes a configuration that cannot load.
The narrowing probably matters as much as the check. Comparing networks and not script types keeps the refusal aimed at the one disagreement that silently loses money, and leaves the formatting choices with the caller who knows which addresses the integration on the other end can read.