Guide

Custom chains

Extend the abstract Chain with a key and a validator and register the class. From then on the registry treats it like a built-in.

One class, one file

Every built-in is a concrete class extending the exported abstract Chain, or EVM and Move when the address format is theirs. Yours is the same shape:

nano.ts
import { Chain, InvalidAddressError, register } from "@agntn/chains";
import type { ChainKey, ChainType } from "@agntn/chains";

// The shape of a Nano account. Its checksum stays unchecked here.
const ADDRESS = /^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/;

class Nano extends Chain {
  static readonly key = "nano" as ChainKey;
  readonly type = "nano" as ChainType;
  readonly name = "Nano";
  readonly symbol = "XNO";
  override readonly decimals = 30;
  readonly explorer = "https://nanexplorer.com/nano";
  readonly bip44 = 165;

  override assertAddress(address: string): string {
    if (!ADDRESS.test(address)) throw new InvalidAddressError(this.key, address);
    return address;
  }
}

register(Nano);
import "./nano";
import { create, getChain, identify } from "@agntn/chains";

getChain("nano").name; // "Nano"
identify("nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3").matches[0]?.key; // "nano"

Two casts, and they are the honest part of this page. ChainKey and ChainType are closed unions of the built-in values, so a new key doesn't type check without as ChainKey, and create("nano") wants the same cast. getChain("nano") takes a plain string and needs none. The unions exist so a typo in a key is a compile error inside the package; the price is paid once, in your class.

The contract

  • static readonly key is what register files the class under and what chain.key returns. Lowercase, the chain's name rather than its ticker. Registering the same key again replaces the class.
  • name, symbol, type and explorer are required. decimals, bip44, chainId, caip2 and rpcDefault are optional and mean unknown when left out, never a default.
  • assertAddress returns the address or throws InvalidAddressError(this.key, address). Throw that class and nothing else for a bad address, because identify counts only that as a miss and lets any other throw come back out as a bug.
  • Skip assertAddress and validatesAddress says false, the base method throws AddressValidationUnsupportedError, and identify lists your chain as unchecked rather than pretending it said no.
  • An EVM chain is class Mine extends EVM with chainId, caip2 and the rest. The format check comes with the family.

Decode when you can

A regex is fine when the width is the whole format, as it is for Octra. When the format is Base58Check with a known byte length, decode and look at the bytes: a character window takes neighbouring chains and half the typos. The built-ins are the reference, src/chains/tron.ts is the short one and src/chains/cardano.ts the long one.

Where it shows up

Once the module is imported, the chain is in chains(), has() says true, getChain finds it by key and by display name, identify asks it like any other. The CLI and the MCP server only load the built-ins. To use a custom chain there you build your own binary around create and createMcpServer. There's no plugin path, and I'd rather say so than invent one that half works.

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