Build1 publisher3 min readPublished
Checking the encoding before the version byte keeps a wallet from naming the wrong chain
A wallet engineer's Python package splits address rejection into three typed reasons and checks encoding before network, so a mangled testnet string comes back as a bad checksum.
The Engineer · Build desk
What happened
- A dev.to post published under the handle polycratia argues that address validation should return a result carrying a typed reason, with the UI and the support tooling branching on that reason.
- It names three situations that a single "invalid address" message hides: a string truncated by a copy that clipped at a line break, a good address for another network, and an encoding the build does not know yet.
- The author says the shape survived custodial BTC and ETH wallets, 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 current version of the design is published as chain-addresses, a Python package the author keeps on GitHub under the same handle.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Retry advice belongs in only one of the three branches, and every other rejection has to say something the user can act on somewhere other than the clipboard.
- exposure A validator that reads a network out of bytes that failed their checksum hands the user a confident wrong claim. The boolean version does not face the choice, so it surfaces in a support queue instead.
- capability New address types can be added underneath without editing the branches at any call site, because those branches key off the reason set and not the format.
- cost Every surface that touches an address now writes three messages instead of one, and the support tooling has to branch the same way to keep its answers consistent.
Any decoder that checks integrity and then reads fields out of the same bytes has to pick an order. The post picks integrity first. A testnet address with one character mangled fails its checksum while its version byte still says testnet, and both statements are true [11]. Naming the network from a payload that failed its own check means reporting a field you have no reason to trust, so a corrupted testnet address comes back as a bad checksum [12]. "Confidently wrong output is worse than a vague rejection, and precedence is the only place you get to prevent it," the author wrote [13]. The other ordering ends in the ticket the post imagines: "it told me this was a Bitcoin address, it is not." [14]
The guard is what makes this cheap to adopt. The result is falsy when the address is not acceptable, so `if not result` reads exactly like the boolean check it replaces, and the reason, format and network are attached when a caller wants them [10]. The published example tests `Reason.BAD_CHECKSUM`, then `Reason.WRONG_NETWORK`, then falls through to a message saying the format is not supported here [9]. Retry advice goes only to the first branch, the one failure where copying again has a good chance of working [5]. The wrong-network branch names the network the address belongs to and stops, since re-copying sends the user back to the same clipboard for the same string [6].
Without a reason code, every call site invents its own message out of the absence of information, and the post's description of the outcome is three screens telling the same user three different half-truths [8].
The split that keeps callers stable is between an open set and a closed one. Address formats are open: `bitcoin-p2pkh`, `bitcoin-p2sh`, `bitcoin-p2wpkh`, `bitcoin-p2tr`, their testnet variants, and EIP-55 hex for EVM chains [16]. Failure reasons are closed at three: the encoding did not verify, the address belongs to another network, the version is not one you serve [17]. Counted from the post's own lists, nine format names sit against three reasons [20]. New chains keep arriving in the first set while the second has barely changed [18]. Callers branch on the closed set, so adding a chain does not ripple into them [19].
For the three-way split to pay for itself, your rejections have to land in all three buckets. The post reports no error rates and no ticket counts for the three classes [21]. If nearly all your failures are truncated pastes, one message and a retry prompt recovers most of the value, and the other two branches are insurance you carry for correctness. The third reason has a second job anyway: an address type you do not serve yet is often not the user's fault, and the post counts it separately from typos as a roadmap signal [7].
What to watch
- Whether the reason set stays closed at three as chain-addresses adds formats, or a fourth appears for empty and length failures.
- Whether the package documents the encoding-before-network precedence where callers can find it, so a rejected address is guaranteed never to name a network from unverified bytes.
- Anyone publishing the real distribution of rejection causes on a live withdrawal form, which would show whether three branches earn their messages.