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 oncoinType, which is yourbip44until a driver overrides it, the way the UTXO ones answer1on testnet. Override the path itself 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, Litecoin and Bitcoin Gold 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. Its keys and Core style message signing live one level down inAbstractBitcoinMessageBlockchain, which Zcash extends because it keeps those and writes addresses its own way. Bitcoin SV, Dash and Dogecoin go throughAbstractBitcoinP2PKHBlockchainbeside it, where a subclass passes a label and a table of one byte versions tosuper()and gets base58 P2PKH. Bitcoin Cash and eCash go throughAbstractCashAddrBlockchain, 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
| 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, 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
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("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/:
// 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);
});
});
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
- Reuse the
@noble,@scure,@agntn/hashesand@agntn/encodingshelpers 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.