Guide

Address validation

assertAddress on every chain. What each family decodes and which checksums are verified and which are left alone. A format check and nothing more.

The call

import { getChain, InvalidAddressError } from "@agntn/chains";

const bitcoin = getChain("btc");

bitcoin.validatesAddress; // true
bitcoin.assertAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); // returns the address

try {
  bitcoin.assertAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5");
} catch (error) {
  error instanceof InvalidAddressError; // true, the checksum caught the last digit
  (error as InvalidAddressError).chain; // "bitcoin"
}

chain.assertAddress(address) returns the address when it fits the chain's format and throws InvalidAddressError when it doesn't. Chains without a validator throw AddressValidationUnsupportedError instead of quietly saying yes, and chain.validatesAddress tells you which ones those are before you ask. Right now every built-in chain validates. A false green light costs more than a false alarm when the caller is about to send funds.

The library doesn't trim. assertAddress(" bc1q…") fails on the space. The CLI's validate passes the argument through as typed too. The agent tools do trim, because an address lifted out of a log line keeps its trailing newline and one invisible byte should not turn a good address into a bad one.

Why decode

Character counts aren't enough. A 34-character window takes a Bitcoin legacy address and a TRON address, both 25 bytes of Base58Check under different version bytes. Solana's System Program is 32 characters and 32 bytes, a Bitcoin address is 34 characters and 25 bytes. So the base58 validators decode and look at the bytes, and the Base58Check ones hash the last four against the rest. The Bech32 validators run the polymod. And Octra is the one base58 chain where decoding would be wrong: a contract address there is cut out of a base58 string rather than encoded from a payload, so the width is the whole format.

What each family checks

ChainAccepted formsVerifiedLeft alone
EVM, fourteen chains0x and 40 hex digits, mixed case under EIP-55the EIP-55 checksum wherever the case carries onean address in one case throughout, which has no checksum to fail
Bitcoinlegacy 1… 3…, Bech32 bc1q…, Bech32m bc1p…25 Base58Check bytes under 0x00 or 0x05 with the checksum; the full BIP-173/350 encoding, witness version, program length, padding, checksumnothing; one character off fails on either branch
Litecoinlegacy L… M…, ltc1…, MWEB ltcmweb1…same as Bitcoin under versions 0x30 and 0x32 and the ltc prefix; MWEB's Bech32 checksum past 90 characters, version digit 0, two 33-byte keysthe deprecated 3… script version is out; curve points
PepecoinP…, 9…, A…25 Base58Check bytes under 0x38 or 0x16 with the checksumno Bech32 at all
DogecoinD…, 9…, A…25 Base58Check bytes under 0x1e or 0x16 with the checksumno Bech32 at all; 9… and A… pass as Pepecoin too
Bitcoin CashCashAddr with or without bitcoincash:, q… p… z… r…the CashAddr checksum, types 0 to 3 with the CashTokens pair, a 20-byte hash or a 32-byte script hash, zero paddinglegacy 1… 3…, the bytes are Bitcoin's; a bare eCash address fails on the checksum
Bitcoin Goldlegacy G… A…, btg1…same as Bitcoin under versions 0x26 and 0x17 and the btg prefixBitcoin's 1… and 3…, the fork moved both version bytes
DashX…, 7…25 Base58Check bytes under 0x4c or 0x10 with the checksumtestnet y… and 8…; no Bech32 at all
Zcashtransparent t1… t3…, Sapling zs1…, TEX tex1…, Unified u1…26 Base58Check bytes under 0x1cb8 or 0x1cbd; Bech32 over 43 bytes; Bech32m over 20 bytes; ZIP-316 Revision 0: Bech32m past 90 characters, F4Jumble undone, padding, item order, receiver lengths, at least one shielded itemSprout zc…, which nothing can pay since Canopy; testnet; the curve points in shielded receivers
Bitcoin SV1…25 Base58Check bytes under 0x00 with the checksum3…, which no transaction may pay since Genesis; no Bech32, no CashAddr; 1… passes as Bitcoin too
eCashCashAddr with or without ecash:the CashAddr checksum, version 0x00 or 0x08, 20-byte hash, zero paddingnothing; a bare Bitcoin Cash address fails on the checksum
CardanoShelley addr1…, stake1…, Byron Ae2… DdzFF…Bech32 checksum past BIP-173's 90 characters, CIP-19 header, mainnet tag, payload length, pointer coordinates; Byron's CRC-32, root, attributes and type as the ledger decodes themnothing; a Byron address with a network magic is a testnet address
DecredDs De DS Dc, Dk26-byte hash forms, 39-byte public key form with its selector, the BLAKE-256 checksumcurve points
Solanabase58exactly 32 decoded bytesnothing else exists to check
StellarSEP-23 Strkey G… M… C…canonical base32, version byte, CRC16-XModemaccount existence
XRP Ledgerclassic r…, X-address X…the ledger's own base58 alphabet, the Base58Check checksum, version 0x00, X-address prefix, flag and reserved bytesnothing else
Aptos, Sui64 hex digits or the single digit AIP-40 short formshapenothing else
TONTEP-2 friendly, either base64 alphabet36 bytes, tag 0x11 or 0x51, workchain 0x00 or 0xff, the CRC16-XModemthe raw workchain:hex form isn't accepted
TRONT…25 Base58Check bytes under 0x41 with the checksumnothing else
Octraoct and 44 base58 charactersthe width and the alphabeteverything else, on purpose
Arweave43 base64url characterszero unused bits in the last digitthe optional :checksum suffix is rejected
Monerostandard 4…, subaddress 8…, integrated 4…base58 block bounds, mainnet type byte, 95 or 106 characters, the Keccak-256 checksumcurve points; testnet and stagenet are rejected

Mixed case fails on every Bech32 and CashAddr chain, all upper or all lower passes, as the specs say. On an EVM chain mixed case is the checksum, so it has to match, and one case throughout is the unchecksummed form every wallet accepts. Testnet forms are rejected everywhere the format can tell: Bitcoin's version bytes, TON's testnet flag, Monero's network byte, XRP's testnet X-address prefix, the network magic a Byron address carries only on a test network.

Move addresses and EVM addresses

Aptos and Sui want all 32 bytes of hex written out, or the single digit short form AIP-40 defines for the special addresses, which is how the framework address 0x1 is actually written. Anything in between stays rejected. Accepting dropped leading zeros would make every EVM address a valid Move address too, and identify would report a family nobody asked about.

Transaction ids

import { getChain } from "@agntn/chains";

getChain("btc").assertTxid("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"); // the genesis coinbase, returned
getChain("sol").assertTxid("4AwYqQ8RbD6yhTbE9h38YiYufC7UHJfxzhP3BZsEoJRp4cY6TzphNFEsUWMjkrKNjE5dFMbBoxVwvr9m8vJukavE"); // a signature, 64 bytes of base58
getChain("xlm").validatesTxid; // true, on every chain that ships

assertTxid is the same contract for a transaction id: the string back when it fits, InvalidTxidError with .chain and .txid when it doesn't. It's a shape check and nothing else, there's no transaction to hash. Each rule is what the chain's own node writes and reads, so the case rules differ where the readers do.

ChainA txid is
EVM, fourteen chains0x and 64 hex digits, either case. 0X is out, same as for an address
UTXO: Bitcoin, Litecoin, Pepecoin, Dogecoin, Bitcoin Cash, Bitcoin SV, Bitcoin Gold, Dash, Zcash, eCash, Cardano, Decred64 hex digits, either case, no prefix. Double SHA-256 on the Bitcoin forks, BLAKE on Cardano and Decred, BLAKE2b on Zcash since v5 transactions, same width either way
Monero64 hex digits, a Keccak-256
Arweave43 base64url characters with the last digit's two spare bits zero, the address rule. A transaction id there is the SHA-256 of the signature
SolanaThe first signature of the transaction, 64 Ed25519 bytes in base58, so up to 88 characters that decode to exactly 64 bytes, the way Signature::from_str reads them
SuiA 32-byte digest in base58, decoded and counted the way TransactionDigest::from_str does. Base58, not hex, so the two Move chains don't share a rule
Aptos0x and 64 hex digits, either case, as the node writes them. The node also reads bare hex, but nothing writes it, so it stays out like it does for an address
TONThe 32-byte hash in either of the two spellings TON's own tools write: 64 hex digits, either case, as hash().toString("hex") and tonapi print it, or 44 characters of base64 with the =, in toncenter's alphabet or the URL-safe one tonscan links with, never a mix of the two. Unpadded base64 is read by toncenter and written by nobody, so it's out
TRON64 hex digits, either case, the SHA-256 of raw_data. No 0x: the node strips one on the way in, but nothing on TRON writes one
Stellar64 hex digits, lowercase only. Horizon's isTransactionHash refuses uppercase, so a hash that passes here passes there
XRP Ledger64 hex digits, uppercase as rippled writes them, either case as its parseHex reads them. A CTID (XLS-37) is a different parameter, not a hash
Octra64 hex digits, lowercase only, which is the node's whole sanitize_hash check

A custom chain that never overrides assertTxid throws TxidValidationUnsupportedError, and validatesTxid says false there before you ask. chains_validate_txid and chains validate --txid are the same check for a model and for the terminal.

Where this stops

None of this touches a network. Not existence, not balances, not whether a contract sits at the address. That belongs in @agntn/nodes, and the Playground says the same thing under every green result.

@agntn/chains·MIT license· A format check, not proof an address exists. Nothing on this site talks to a chain.