Creating Custom Blockchains
Two bases
AbstractBlockchaingives you private key generation, key pairs,deriveWallet,generateWallet,deriveHDWallet, and thenetworkoption. You bring the chain rules.AbstractEVMBlockchainis 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
| Member | What it is |
|---|---|
name | lowercase identifier, also the key in the lazy loader |
curve | "secp256k1", "ed25519", or a readonly array if the chain takes both |
bip44 | SLIP-0044 coin type |
getKeyPublic | public key from a private key |
getAddress | address from a public key, plus an optional type |
validateAddress | format and checksum check |
signMessage, verifyMessage | message 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
- Reuse the
@nobleand@scurehelpers already in the package. A second crypto implementation is a review rejection, not a style preference. - Keep network parameters in one record and read
this.network. Conditionals spread across methods drift. - Export the class by name and as default.
- Register it in the lazy loader and mirror the path in tests.