Guide

Creating Custom Blockchains

Extend AbstractBlockchain. Implement five methods. Register it and test it.

Three bases

  • AbstractBlockchain gives you private key generation, key pairs, deriveWallet, generateWallet, deriveHDWallet, getDerivationPath, and the network option. You bring the chain rules. getDerivationPath is BIP44 on coinType, which is your bip44 until a driver overrides it, the way the UTXO ones answer 1 on testnet. Override the path itself when the chain's wallets walk another shape, the way Solana, Stellar, Aptos, Sui and Cardano do.
  • AbstractEVMBlockchain has all the chain rules filled in for the EVM. See EVM chains if that's what you're adding.
  • AbstractBitcoinBlockchain in src/utils/bitcoin.ts is what Bitcoin, Litecoin and Bitcoin Gold share: the five address formats, validation, purpose inference on HD paths and Core style message hashing. A subclass brings a name, a bip44, a messagePreamble and a params getter with the version bytes and the bech32 prefix. Its keys and Core style message signing live one level down in AbstractBitcoinMessageBlockchain, which Zcash extends because it keeps those and writes addresses its own way. Bitcoin SV, Dash and Dogecoin go through AbstractBitcoinP2PKHBlockchain beside it, where a subclass passes a label and a table of one byte versions to super() and gets base58 P2PKH. Bitcoin Cash and eCash go through AbstractCashAddrBlockchain, which takes a label, a prefix for each network and the hash lengths each address type pays to, and writes CashAddr. None of them is exported from the package yet, so they're for drivers inside this repo.

What a direct subclass has to provide

MemberWhat it is
namelowercase identifier, also the key in the lazy loader
curve"secp256k1", "ed25519", or a readonly array if the chain takes both
bip44SLIP-0044 coin type
getKeyPublicpublic key from a private key
getAddressaddress from a public key, plus an optional type
validateAddressformat and checksum check
signMessage, verifyMessagemessage signatures

A full example

A base58check chain with a made up version byte. Every helper here already exists in the package, so the class is mostly glue:

ts
// src/blockchains/mychain.ts
import { ripemd160, sha256 } from "@agntn/hashes";
import { AbstractBlockchain } from "../blockchain.ts";
import { encodeBase58Check, validateBase58Check } from "../utils/encoding.ts";
import { generateKeyPublic } from "../utils/secp256k1/keys.ts";
import {
  signMessage as genericSignMessage,
  verifyMessage as genericVerifyMessage,
} from "../utils/signing.ts";
import type { Curve, KeyOptions } from "../types.ts";

const ADDRESS_VERSION = 0x34;

export class MyChain extends AbstractBlockchain {
  override readonly name = "mychain";
  override readonly curve: Curve = "secp256k1";
  override readonly bip44 = 1234;

  override getKeyPublic(keyPrivate: string, options?: KeyOptions): string {
    return generateKeyPublic(keyPrivate, options);
  }

  override getAddress(keyPublic: string): string {
    const publicKey = Uint8Array.fromHex(keyPublic);
    const hash = ripemd160(sha256(publicKey));
    const payload = new Uint8Array(hash.length + 1);
    payload[0] = ADDRESS_VERSION;
    payload.set(hash, 1);
    return encodeBase58Check(payload);
  }

  override validateAddress(address: string): boolean {
    return validateBase58Check(address, ADDRESS_VERSION);
  }

  override signMessage(
    message: string | Uint8Array,
    keyPrivate: string,
    options?: KeyOptions,
  ): string {
    return genericSignMessage(message, keyPrivate, {
      curve: "secp256k1",
      ...options,
    });
  }

  override verifyMessage(
    message: string | Uint8Array,
    signature: string,
    keyPublic: string,
    options?: KeyOptions,
  ): boolean {
    return genericVerifyMessage(message, signature, keyPublic, {
      curve: "secp256k1",
      ...options,
    });
  }
}

export default MyChain;

1234 is a placeholder. A real chain uses its registered SLIP-0044 number, and if it doesn't have one, that's a conversation to have upstream before shipping a driver.

Using it

ts
import { useBlockchain } from "@agntn/keys";
import MyChain from "@agntn/keys/blockchains/mychain";

const chain = useBlockchain(new MyChain({ network: "testnet" }));
const wallet = chain.generateWallet();

useBlockchain returns the same instance with its concrete type intact. It exists so the lazy loader and a direct import produce the same thing.

Registering it

Add one line inside the blockchains object in src/_blockchains.ts:

ts
mychain: lazy("mychain", () => import("./blockchains/mychain.ts")),

From then on await blockchains.mychain({ network: "testnet" })() loads and constructs it like every other chain.

Testing it

Mirror the source path under test/:

ts
// test/blockchains/mychain.test.ts
import { describe, expect, it } from "vite-plus/test";
import { useBlockchain } from "../../src";
import MyChain from "../../src/blockchains/mychain";

describe("MyChain", () => {
  const blockchain = useBlockchain(new MyChain());

  it("exposes its chain metadata", () => {
    expect(blockchain.name).toBe("mychain");
    expect(blockchain.curve).toBe("secp256k1");
    expect(blockchain.network).toBe("mainnet");
  });

  it("generates self validating wallets", () => {
    const wallet = blockchain.generateWallet();
    expect(blockchain.validateAddress(wallet.address)).toBe(true);
  });
});
shell
pnpm exec vp test run test/blockchains/mychain.test.ts

Self validating wallets are the minimum. Before the driver is worth merging it also needs a published test vector: a known key with the address the chain's own tooling produces for it. Without that you're testing that your encoder agrees with your decoder, which proves nothing.

Rules of the house

  1. Reuse the @noble, @scure, @agntn/hashes and @agntn/encodings helpers already in the package. A second crypto implementation is a review rejection, not a style preference.
  2. Keep network parameters in one record and read this.network. Conditionals spread across methods drift.
  3. Export the class by name and as default.
  4. Register it in the lazy loader and mirror the path in tests.