Creating Custom Blockchains
Three bases
AbstractBlockchaingives you private key generation, key pairs,deriveWallet,generateWallet,deriveHDWallet,getDerivationPath, and thenetworkoption. You bring the chain rules.getDerivationPathis BIP44 from yourbip44; override it when the chain's wallets walk another shape, the way Solana, Stellar, Aptos, Sui and Cardano do.AbstractEVMBlockchainhas all the chain rules filled in for the EVM. See EVM chains if that's what you're adding.AbstractBitcoinBlockchaininsrc/utils/bitcoin.tsis what Bitcoin and Litecoin share: the five address formats, validation, purpose inference on HD paths and Core style message hashing. A subclass brings aname, abip44, amessagePreambleand aparamsgetter with the version bytes and the bech32 prefix. It isn't exported from the package yet, so it's for drivers inside this repo.
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 doesn't have one, that's 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're 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.