Canonical blockchain classes, aliases, and address validation.
Every web3 library I write needs the same handful of facts: Polygon's chain ID, Bitcoin's coin type, which explorer to link, whether an address even looks right. Re-declared in every one of them. So they live here once, as classes.
TypeScript, ESM-only. The core imports nothing at runtime. The CLI adds citty and consola, the MCP server adds @modelcontextprotocol/sdk, and the agent extensions need typebox and @earendil-works/pi-coding-agent. The MCP server and the extensions describe their parameters with the same typebox schemas.
pnpm add @agntn/chainsimport { Ethereum, EVM, create, getChain } from "@agntn/chains";
const ethereum = create("eth");
ethereum instanceof Ethereum; // true
ethereum instanceof EVM; // true
ethereum.name; // "Ethereum"
ethereum.symbol; // "ETH"
ethereum.chainId; // "0x1"
ethereum.caip2; // "eip155:1"
// Aliases resolve to the same concrete classes.
const polygon = getChain("matic");
polygon.key; // "polygon"
// Validation lives on the class that knows the format.
ethereum.assertAddress("0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984");Chain (abstract)
├── EVM (abstract)
│ ├── Ethereum
│ ├── Base
│ ├── Arbitrum
│ └── ...
├── Move (abstract)
│ ├── Aptos
│ └── Sui
├── Bitcoin
├── Solana
├── Ton
├── Tron
└── Octra
Each chain is its own class holding its own metadata. EVM and Move own the family type and the address format, and everything else is declared per class, down to the coin type all thirteen EVM chains repeat. Importing the package registers all of them.
Registration runs on side-effect imports. Set sideEffects: false and the bundler eats the register() calls, so create("eth") throws on a key that is right there in the source. Bundler docs and package linters both suggest that flag, and nothing complains until a production build hands you an empty registry.
Chainis the abstract contract for metadata and address validationEVMandMovehold what their concrete classes shareChainConstructoris what the registry accepts
register(ChainClass)registers a concrete class under its statickeycreate(key)builds a fresh instance of a registered classchains()returns the registered keys in registration orderhas(key)checks whether a class is registeredgetChain(input?)takes a key, symbol, or alias and gives you an instance, defaulting to Ethereumidentify(address)partitions the registry by an address: chains whose validator accepts it, and chains with no validator at all
getChain matches keys, symbols, and the aliases people actually type, so matic, btc and arb all work. Display names work too, read straight off the registered classes, so whatever chain.name prints resolves back to the same chain — Arbitrum One, BNB Chain, zkSync Era. That round trip matters for agents, which get a name out of one call and put it into the next. Symbols stay out of the automatic index: six chains report ETH, so matching on them would depend on registration order.
getChain() with no argument still means Ethereum. getChain("") or a blank string does not — that is a caller mistake, and it throws rather than quietly answering about the wrong chain.
chain.assertAddress(address) returns the address when it fits the chain's format and throws when it doesn't. It's a format check, not a checksum, and not proof the address exists on chain. Chains without a validator throw instead of quietly saying yes — chain.validatesAddress tells you which ones those are before you ask. A false green light costs more than a false alarm when the caller is about to send funds.
The base58 validators decode, because a shape is not enough. Solana requires exactly 32 decoded bytes: character length cannot separate an account from a Bitcoin or TRON address, since those are 34 characters and 25 bytes, while the System Program is 32 characters and 32 bytes. Bitcoin's legacy branch requires the 25 Base58Check bytes under a 0x00 or 0x05 version: the same System Program fit a character-length window, and decoding is what keeps that false match out. The checksum stays unchecked, this is a format check. Bitcoin's bech32 branch uses the BIP-173 charset, which has no 1, b, i or o, and treats all-lowercase and all-uppercase as valid while rejecting mixed case - uppercase is what QR encoders emit, so rejecting it would fail addresses that spend fine.
TRON is the same 25 Base58Check bytes under version 0x41, so decoding is also what keeps it and Bitcoin's legacy form apart. TON takes the TEP-2 friendly form in either base64 alphabet: 36 decoded bytes, a bounceable or non-bounceable tag and one of the two workchains that exist, with the testnet-only flag rejected the way Bitcoin's testnet versions are. Aptos and Sui want all 32 bytes of hex written out, or the one-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, because accepting dropped leading zeros would make every EVM address a valid move address too. With those in place every registered chain validates, so identify gets an answer out of the whole registry.
Everything thrown here descends from ChainsError, so you catch one type and read fields instead of parsing message strings.
UnknownChainErrorwhencreate()got a key with no registered class, carries.keyUnsupportedChainErrorwhengetChain()got input matching no alias or name, carries.input; the message quotes the value, so blank and control-character input stays visible in a logInvalidAddressErrorwhen an address failed its format check, carries.addressand.chainAddressValidationUnsupportedErrorwhen the chain has no validator, carries.chain
.chain holds the canonical key on both, the same value create() takes. It used to name whatever read well in the message - "EVM" for all thirteen EVM chains, a display name elsewhere - which made the field useless as an identifier.
chains list --type evm # every registered EVM chain
chains info matic # canonical metadata, add --json for a machine
chains resolve btc # bitcoin
chains validate eth 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984
chains identify 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984 # which chains accept this formatresolve, info and validate print a message and exit 1 when they fail. list warns and exits 0 when a --type filter matches nothing, so don't use it as a check in a script. identify exits 0 even when nothing matches, because that is an answer too.
chains mcpSpeaks MCP over stdio and exposes the same four tools as the agent extensions: chains_lookup, chains_validate_address, chains_identify_address and chains_list. Point a client at it:
{
"mcpServers": {
"chains": { "command": "npx", "args": ["-y", "@agntn/chains", "mcp"] }
}
}An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer: every metadata field on a hit, and the registered keys when resolution fails, so the next call has somewhere to go. chains_list is there for the same reason — without it the only way to learn what the registry holds is to send a value you expect to fail. Absent fields say so out loud (bip44: none) rather than vanishing, because a missing coin type reads as "not shown" and invites the caller to supply one from memory.
A rejected address is an answer, not a tool error. Only an unresolvable chain or a chain with no validator sets isError, because then nothing was checked.
chains_identify_address turns validation around: it runs an address of unknown origin through every validator at once and reports the chains that accept the format, grouped by family. A chain without a validator would be named as unchecked rather than skipped, though the list is empty right now because every registered chain validates. A match narrows the family and no more - one EVM address is valid on all thirteen EVM chains.
createMcpServer() is exported from @agntn/chains/mcp for hosts that bring their own transport.
Pi and OMP extensions live in packages/pi/extensions and packages/omp/extensions. They expose chains_lookup for resolving a chain into its metadata, chains_validate_address for checking an address, chains_identify_address for narrowing an address of unknown origin, and chains_list for the registry.
All three surfaces call the executors in src/tool-operations.ts, so the MCP server and the two extensions answer identically. The extensions add the details the harnesses render; MCP drops them and keeps the text.
The extensions prefer the built executors and fall back to source only when dist/ is missing, because the internal imports use .js specifiers that a plain TypeScript-stripping runtime can't resolve back to .ts. Without pnpm build the tools still register and the first call dies with a module-resolution error.
No RPC calls, no wallets, no transaction building. Those belong in rpcx, ubichain and webri.
| Field | Type | Description |
|---|---|---|
key |
ChainKey |
Canonical class key |
name |
string |
Human-readable name |
symbol |
string |
Native token symbol |
type |
ChainType |
Blockchain family |
bip44 |
number? |
BIP-44 / SLIP-0044 coin type |
chainId |
string? |
EVM chain ID in hexadecimal |
caip2 |
string? |
CAIP-2 identifier |
explorer |
string |
Block explorer base URL |
rpcDefault |
string? |
Default public RPC endpoint |
Optional fields stay empty when the chain has no registered value. Octra has no BIP-44 coin type and no CAIP-2 namespace, so both are undefined rather than invented.
eth, base, arbitrum, optimism, polygon, bsc, avalanche, fantom, gnosis, linea, zksync, scroll, bera, bitcoin, solana, aptos, sui, ton, tron, and oct.