Guide

Creating Custom Blockchains

Extend AbstractBlockchain, implement five methods, register it, test it.

Two bases

  • AbstractBlockchain gives you private key generation, key pairs, deriveWallet, generateWallet, deriveHDWallet, and the network option. You bring the chain rules.
  • AbstractEVMBlockchain is the first one with all the chain rules filled in for the EVM. See EVM chains if that is what you are adding.

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:

// src/blockchains/mychain.ts
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hexToBytes } from "@noble/hashes/utils.js";
import { AbstractBlockchain } from "../blockchain.ts";
import { encodeBase58Check, validateBase58Check } from "../utils/encoding.ts";
import { generateKeyPublic } from "../utils/secp256k1.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 = hexToBytes(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 does not have one, that is a conversation to have upstream before shipping a driver.

Using it

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:

mychain: lazy(() => 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/:

// test/blockchains/mychain.test.ts
import { describe, expect, it } from "vitest";
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);
  });
});
pnpm exec vitest 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 are testing that your encoder agrees with your decoder, which proves nothing.

Rules of the house

  1. Reuse the @noble and @scure 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.

@agntn/keys·MIT license· Keys never leave the browser.