polycratia

An address validator should return a reason, not a boolean

· 8 min read

A user pastes an address into a withdrawal form and gets back "invalid address". Sometimes the string was truncated by a copy that clipped at a line break. Sometimes it is a perfectly good address for a network you are not sending on. Sometimes it is an encoding your build does not know yet. Three different situations, three different fixes, and a validator that returns a boolean has thrown away the distinction before any caller could act on it.

I have built custodial wallets for BTC and ETH, an on-chain payment system, and a fiat-to-crypto onramp where addresses arrive from forms, from support tickets, and from other systems' APIs. The shape that survived all of it is small: validation returns a result carrying a typed reason, the UI and the support tooling branch on that reason, and the list of supported chains grows underneath without any of those branches changing. chain-addresses (https://github.com/polycratia/chain-addresses) is where I keep the current version of that shape.

The rejections are not interchangeable#

Start from what the caller has to do next, not from what the validator knows.

A checksum that does not match means the bytes on screen are not the bytes anyone intended. The fix is mechanical: ask the user to copy it again, and there is a good chance the second attempt works. This is the one failure where "try again" is honest advice.

An address that decodes cleanly but belongs to another network is the opposite. Nothing is mistyped. Asking the user to re-copy sends them back to the same clipboard for the same string, and they will paste it again, more annoyed. The useful message names the network the address is actually for and stops there, because the real fix lives somewhere else entirely: a different withdrawal flow, a different asset, or a support conversation.

An unexpected version byte inside a well-formed encoding is a third thing, and often not the user's fault at all. Someone is handing you an address type you do not serve yet. That is a roadmap signal rather than a validation error, and it deserves to be counted separately from typos.

One boolean cannot carry any of that, so every call site invents its own message out of the absence of information. That is how you end up with three screens telling the same user three different half-truths.

python
from chain_addresses import Reason, validate_address

result = validate_address(destination, network="testnet")
if not result:
    if result.reason is Reason.BAD_CHECKSUM:
        return ask_again("that address did not check out - copy it again")
    if result.reason is Reason.WRONG_NETWORK:
        return stop(f"that address belongs to {result.network}")
    return stop("that address format is not supported here")

The result is falsy when the address is not acceptable, so the guard reads like a boolean check and the detail is sitting there when you need it. A valid result carries what it recognised: result.format is one of the format names the package exposes, and result.network is the network the address belongs to.

Precedence is the design, not a detail#

This part only shows up once you stop returning booleans. A single bad string is usually wrong in more than one way at once, and you have to decide which truth to report.

Take a testnet address with one character mangled. It fails its checksum, and its version byte says testnet. Both statements are true. If you check the network first, you tell a mainnet user "this is a testnet address", advice derived from bytes you have no reason to trust, since a failed checksum means you may be reading a corruption rather than a version.

So encoding is checked first: a corrupted testnet address is a bad checksum, not a wrong network. You do not name a network from bytes that did not survive their own integrity check. A validator returning a boolean never has to make this call, which is exactly why the question goes unasked until support is reading a ticket that says "it told me this was a Bitcoin address, it is not." Confidently wrong output is worse than a vague rejection, and precedence is the only place you get to prevent it.

The set that grows must not be the set you branch on#

This is the structural argument, and it is why adding a chain does not ripple into callers.

There are two sets in play. Address formats are an open set: bitcoin-p2pkh, bitcoin-p2sh, bitcoin-p2wpkh, bitcoin-p2tr, their testnet variants, EIP-55 hex for EVM chains, and whatever comes next. Failure reasons are a closed set: the encoding did not verify, the address is for another network, the version is not one you serve. New chains land in the first set continuously. The second set has barely moved for me in years.

Callers must branch on the closed set. The moment a call site matches on format names or parses an error string, every new chain becomes an edit in the UI, in the support panel, in the API serializer, and in whatever internal script someone wrote last quarter. That is the real cost a boolean hides: it does not just lose information, it pushes the open set into the branch structure of code that has no business knowing about it.

The same separation holds on the generation side. Every encoder takes a 33-byte compressed public key and returns a string, behind one interface:

python
@runtime_checkable
class AddressEncoder(Protocol):
    """Turns a compressed public key into an address for one chain and format."""

    name: str

    def encode(self, public_key: bytes) -> str:
        ...

Because the interface is that narrow, code holding an encoder never learns which chain it is serving, and a new chain is a new entry in the ENCODERS registry instead of a change in every caller. get_encoder raises AddressError on a name it does not know, so a typo in a configuration value fails at lookup instead of producing something plausible further down.

When a flow only serves one chain, you narrow at the call rather than branching afterwards:

python
result = validate_address(destination, network="mainnet", formats=["evm"])

EVM addresses are worth a note here, because they show the limit of what a validator can honestly say. EIP-55 hex carries no network marker at all: the checksum is over the hex digits, not over a chain identifier. In this package such an address belongs to Network.ANY and passes any network check, which is not a shortcut but the truth, since the same address is valid on every EVM chain. "Wrong network" is not expressible for it, and a validator that pretended otherwise would be guessing. The chain selector owns that decision; validation does not.

The addresses you can pay to outnumber the ones you can generate#

The tempting implementation of validation is a round trip: decode the string, re-encode it with each encoder, accept it if something matches. It is compact, it reuses code you already trust, and it is wrong.

Those two sets are not the same size. A version 0, 32-byte witness program (P2WSH) is a perfectly reasonable withdrawal destination, and it validates as bitcoin-p2wsh even though no encoder in the package produces one. That is deliberate: deriving deposit addresses is something you do for yourself and can keep narrow, while accepting a destination is something you do for the rest of the world, which is wider than your own wallet. A round-trip validator quietly makes the two identical and rejects a customer's perfectly good address because your key derivation happens not to produce that shape.

A boolean cannot express that asymmetry either. A result that names a format can: the format it recognised may be one you never generate, and that is fine.

What I would do differently#

I would introduce the reason type before the second chain, not after the third. Retrofitting reasons into a boolean API is easy enough. Replacing the messages that every call site has already invented is the slow part, because by then support has memorised them and someone has written a runbook against the wording.

I would also log the reason rather than the address from the start. Reasons aggregate; addresses do not, and storing them is a liability you do not need. A count per reason is a genuinely useful operational signal: a rise in bad checksums probably points at something clipping strings in a UI, and a rise in wrong-network rejections after a release usually means a field got relabelled, not that users suddenly got careless. Neither is visible when the only thing you recorded was that something was invalid.

The general form of this is not about addresses at all. Any validator at a boundary where a human is still available should return what it learned, not whether it approved. The boolean is a summary you can always compute later; the reason is the thing you can never get back once you have thrown it away.

react

$ new-project --brief

or email hey@polycratia.com