Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## OVERVIEW

TypeScript library providing a unified interface for key generation, address derivation, wallet creation, and message signing across 9 blockchains (Bitcoin, Litecoin, Ethereum, Base, Solana, Aptos, Cardano, SUI, TRON). Built entirely on the @noble/@scure audited crypto ecosystem.
TypeScript library providing a unified interface for key generation, address derivation, wallet creation, and message signing across 10 blockchains (Bitcoin, Litecoin, Decred, Ethereum, Base, Solana, Aptos, Cardano, SUI, TRON). Built entirely on the @noble/@scure audited crypto ecosystem.

## STRUCTURE

Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![license](https://img.shields.io/github/license/agntn/keys?style=flat&colorA=130f40&colorB=474787)](https://github.com/agntn/keys/blob/main/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/agntn/keys)

Typed key generation, address derivation, and message signing across nine blockchains and two curves.
Typed key generation, address derivation, and message signing across ten blockchains and two curves.

> [!WARNING]
> **@agntn/keys is experimental.** The package name, public API, provider model, and tool surfaces may change before the first stable release. Pin exact versions if you build on it now.
Expand Down Expand Up @@ -84,6 +84,20 @@ Litecoin uses the same five address types as Bitcoin, with `L`/`M`/`ltc1` on mai

Message signing uses the Litecoin Core message digest and returns a compact signature of 64 bytes as hex, not Core's recoverable base64 format. Verify against the public key with `verifyMessage`.

### Decred

```ts
import { blockchains } from "@agntn/keys";

const dcr = await blockchains.decred()();
const wallet = dcr.generateWallet();
const testnet = await blockchains.decred({ network: "testnet" })();
```

Decred supports ECDSA P2PKH addresses (`legacy`), with `Ds` on mainnet and `Ts` on testnet3. Both compressed and uncompressed public keys work. Other address formats and signature schemes are outside this implementation.

Message signing uses the Decred message digest and returns 64 bytes of compact r/s as hex, not the recoverable base64 format used by dcrd. Use `verifyMessage` with the public key. `deriveHDWallet` throws: Decred's HD derivation strips leading zeros, so ordinary BIP32 is not a safe substitute. BIP44 path generation uses coin type 42, not the historical type 20.

### Sign and verify messages

```ts
Expand Down Expand Up @@ -140,7 +154,7 @@ const sol = useBlockchain(await blockchains.solana()());
sol.deriveHDWallet(mnemonic, "m/44'/501'/0'/0'", { passphrase: "TREZOR" }).address;
```

secp256k1 chains walk BIP32 and ed25519 chains walk SLIP-10, which accepts hardened segments only. Bitcoin and Litecoin read the address type off the purpose level (44, 49, 84, 86) unless one is passed. Cardano throws, because CIP-1852 starts from the entropy rather than the BIP39 seed.
secp256k1 chains walk BIP32 and ed25519 chains walk SLIP-10, which accepts hardened segments only. Bitcoin and Litecoin read the address type off the purpose level (44, 49, 84, 86) unless one is passed. Decred throws because its HD derivation differs from standard BIP32. Cardano throws, because CIP-1852 starts from the entropy rather than the BIP39 seed.

### Recover one missing BIP39 word

Expand Down Expand Up @@ -190,6 +204,7 @@ The server handles private keys, mnemonics, entropy, messages, and signatures as
| ------------ | ------------------ | ------------------------------------ | ------- |
| **Bitcoin** | secp256k1 | legacy, p2sh, segwit, p2wsh, taproot | ✅ |
| **Litecoin** | secp256k1 | legacy, p2sh, segwit, p2wsh, taproot | ✅ |
| **Decred** | secp256k1 | legacy ECDSA P2PKH | ✅ |
| **Ethereum** | secp256k1 | EIP-55 checksum | - |
| **Base** | secp256k1 | EVM-compatible | - |
| **Solana** | ed25519 | base58 | - |
Expand All @@ -205,7 +220,7 @@ All chains support key generation, address derivation, address validation, and m
Built on audited cryptographic packages from [@paulmillr](https://github.com/paulmillr):

- [@noble/curves](https://github.com/paulmillr/noble-curves) - elliptic curve implementations (secp256k1, ed25519)
- [@noble/hashes](https://github.com/paulmillr/noble-hashes) - SHA-256, Keccak, BLAKE2b, SHA3
- [@noble/hashes](https://github.com/paulmillr/noble-hashes) - SHA-256, Keccak, BLAKE-256, BLAKE2b, SHA3
- [@scure/base](https://github.com/paulmillr/scure-base) - base58, bech32, hex encoding
- [@scure/bip32](https://github.com/paulmillr/scure-bip32) - HD wallet key derivation
- [micro-key-producer](https://github.com/paulmillr/micro-key-producer) - SLIP-0010 for ed25519
Expand Down
1 change: 1 addition & 0 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default defineBuildConfig({
"./src/blockchains/base.ts",
"./src/blockchains/bitcoin.ts",
"./src/blockchains/litecoin.ts",
"./src/blockchains/decred.ts",
"./src/blockchains/cardano.ts",
"./src/blockchains/ethereum.ts",
"./src/blockchains/solana.ts",
Expand Down
4 changes: 3 additions & 1 deletion packages/pi/extensions/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ export default function keysExtension(pi: ExtensionAPI) {
promptSnippet:
"Use to create a new wallet with keys and address for Bitcoin, Ethereum, Solana, etc.",
promptGuidelines: [
"Provide a chain name (bitcoin, litecoin, ethereum, base, solana, aptos, tron, sui, cardano)",
"Provide a chain name (bitcoin, litecoin, decred, ethereum, base, solana, aptos, tron, sui, cardano)",
"Optionally specify network (mainnet/testnet) and address type",
"Bitcoin and Litecoin address types: legacy, p2sh, segwit, p2wsh, taproot",
"Decred supports legacy ECDSA P2PKH addresses only",
"Cardano address types: payment, stake, enterprise",
"Returns hex private key, hex public key, and address",
],
Expand Down Expand Up @@ -117,6 +118,7 @@ export default function keysExtension(pi: ExtensionAPI) {
"Common paths: Bitcoin m/44'/0'/0'/0/0 (legacy), m/49'/0'/0'/0/0 (p2sh), m/84'/0'/0'/0/0 (segwit), m/86'/0'/0'/0/0 (taproot); Ethereum m/44'/60'/0'/0/0; Solana m/44'/501'/0'/0'; Aptos m/44'/637'/0'/0'/0'; Sui m/44'/784'/0'/0'/0'",
"Bitcoin and Litecoin pick the address type from the path purpose unless addressType is set",
"Optionally pass a BIP39 passphrase, a network, or an address type",
"Decred HD derivation is not supported because it differs from standard BIP32",
"Cardano is not supported because CIP-1852 derives from entropy, not from the BIP39 seed",
"Use only public or disposable mnemonics because tool arguments are saved in the transcript",
"Returns the path, public key, and address, never the mnemonic or private key",
Expand Down
1 change: 1 addition & 0 deletions src/_blockchains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function lazy<T extends AbstractBlockchain>(loader: () => Promise<BlockchainModu
*/
export const blockchains = {
bitcoin: lazy(() => import("./blockchains/bitcoin.ts")),
decred: lazy(() => import("./blockchains/decred.ts")),
litecoin: lazy(() => import("./blockchains/litecoin.ts")),
solana: lazy(() => import("./blockchains/solana.ts")),
aptos: lazy(() => import("./blockchains/aptos.ts")),
Expand Down
2 changes: 2 additions & 0 deletions src/blockchains/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Lazy-loaded class modules. Each file exports a named concrete class and the same
| **ed25519** | solana, aptos, cardano | `ed25519SignMessage` (raw, no prehash) | ed25519 via `utils/ed25519` |
| **dual-curve** | sui | both (selected via `options.scheme`) | ed25519 default, secp256k1 optional |

Decred uses `AbstractBlockchain` directly: ECDSA P2PKH with BLAKE-256, not Bitcoin address hashing or EVM signing. Its HD method throws because standard BIP32 does not preserve Decred's legacy derivation.

## ADDING A NEW CHAIN

1. Create `src/blockchains/<name>.ts` with `class Name extends AbstractBlockchain`
Expand Down
98 changes: 98 additions & 0 deletions src/blockchains/decred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { blake256 } from "@noble/hashes/blake1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js";
import { base58check } from "@scure/base";
import { AbstractBlockchain } from "../blockchain.ts";
import { BIP44 } from "../utils/bip44/index.ts";
import { encodeCompactSize } from "../utils/bitcoin.ts";
import { generateKeyPublic } from "../utils/secp256k1.ts";
import type { Curve, KeyOptions, Options, Wallet } from "../types.ts";

const codec = base58check(blake256);
const messagePreamble = new TextEncoder().encode("Decred Signed Message:\n");

/** ECDSA P2PKH prefixes from dcrd chaincfg, mainnet and testnet3. */
const NETWORK_PREFIXES = {
mainnet: new Uint8Array([0x07, 0x3f]),
testnet: new Uint8Array([0x0f, 0x21]),
};

function hashMessage(message: string | Uint8Array): Uint8Array {
const bytes = typeof message === "string" ? new TextEncoder().encode(message) : message;
return blake256(
concatBytes(
encodeCompactSize(messagePreamble.length),
messagePreamble,
encodeCompactSize(bytes.length),
bytes,
),
);
}

/** Decred ECDSA P2PKH wallets. Other address and signature schemes are not supported. */
export class Decred extends AbstractBlockchain {
override readonly name = "decred";
override readonly curve: Curve = "secp256k1";
override readonly bip44 = BIP44.DECRED;

constructor(options?: Options) {
super(options);
if (this.network !== "mainnet" && this.network !== "testnet") {
throw new RangeError("Decred supports mainnet and testnet only");
}
}

private get prefix(): Uint8Array {
return this.network === "testnet" ? NETWORK_PREFIXES.testnet : NETWORK_PREFIXES.mainnet;
}

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

/** Decred strips leading zeros during HD derivation, unlike standard BIP32. */
override deriveHDWallet(): Wallet {
throw new Error("Decred HD derivation is not supported");
}

override getAddress(keyPublic: string, type = "legacy"): string {
if (type !== "legacy") throw new RangeError("Decred supports legacy ECDSA P2PKH only");
secp256k1.Point.fromHex(keyPublic);
return codec.encode(concatBytes(this.prefix, ripemd160(blake256(hexToBytes(keyPublic)))));
}

override validateAddress(address: string): boolean {
if (address.length > 54) return false;
try {
const payload = codec.decode(address);
return (
payload.length === 22 && payload[0] === this.prefix[0] && payload[1] === this.prefix[1]
);
} catch {
return false;
}
}

override signMessage(message: string | Uint8Array, keyPrivate: string): string {
return bytesToHex(
secp256k1.sign(hashMessage(message), hexToBytes(keyPrivate), { prehash: false }),
);
}

override verifyMessage(
message: string | Uint8Array,
signature: string,
keyPublic: string,
): boolean {
try {
return secp256k1.verify(hexToBytes(signature), hashMessage(message), hexToBytes(keyPublic), {
prehash: false,
});
} catch {
return false;
}
}
}

export default Decred;
4 changes: 4 additions & 0 deletions src/tool-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ const BLOCKCHAIN_LOADERS: ReadonlyArray<{
name: "litecoin",
load: async (network) => useBlockchain(await blockchains.litecoin({ network })()),
},
{
name: "decred",
load: async (network) => useBlockchain(await blockchains.decred({ network })()),
},
{
name: "ethereum",
load: async (network) => useBlockchain(await blockchains.ethereum({ network })()),
Expand Down
2 changes: 2 additions & 0 deletions src/tool-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const BIP44_PATH_MODE_SCHEMA = {
export const TOOL_CHAINS = [
"bitcoin",
"litecoin",
"decred",
"ethereum",
"base",
"solana",
Expand Down Expand Up @@ -50,6 +51,7 @@ export const TOOL_ADDRESS_TYPES = [
export const TOOL_ADDRESS_TYPES_BY_CHAIN: Readonly<Record<ToolChain, readonly string[]>> = {
bitcoin: BITCOIN_ADDRESS_TYPES,
litecoin: BITCOIN_ADDRESS_TYPES,
decred: ["legacy"],
ethereum: [],
base: [],
solana: [],
Expand Down
1 change: 1 addition & 0 deletions src/utils/bip44/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const BIP44 = {
BITCOIN: 0,
TESTNET: 1,
LITECOIN: 2,
DECRED: 42,
ETHEREUM: 60,
SOLANA: 501,
CARDANO: 1815,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function validateAddressBase58Testnet(address: string, params: NetworkParams): b
return false;
}

function encodeCompactSize(value: number): Uint8Array {
export function encodeCompactSize(value: number): Uint8Array {
if (value < 0xfd) return new Uint8Array([value]);
if (value <= 0xffff) {
const buffer = new Uint8Array(3);
Expand Down
103 changes: 103 additions & 0 deletions test/blockchains/decred.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { blake256 } from "@noble/hashes/blake1.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hexToBytes } from "@noble/hashes/utils.js";
import { base58check } from "@scure/base";
import { describe, expect, it } from "vitest";
import Decred, { Decred as NamedDecred } from "../../src/blockchains/decred.ts";
import { blockchains, getBlockchainPath } from "../../src/index.ts";
import { deriveWallet, deriveHdWallet } from "../../src/tool-operations.ts";
import { bip39TestVectors, decredTestVectors as vector } from "../fixtures.ts";

const codec = base58check(blake256);

describe("Decred", () => {
it("loads ECDSA wallets through the registry and shared tools", async () => {
const chain = await blockchains.decred()();
expect(chain).toBeInstanceOf(Decred);
expect(Decred).toBe(NamedDecred);
expect(chain.name).toBe("decred");
expect(chain.curve).toBe("secp256k1");
expect(getBlockchainPath(chain, 0, 1, 3)).toBe("m/44'/42'/0'/1/3");
expect(chain.deriveWallet(vector.privateKey).address).toBe(vector.addresses.mainnet);
expect(chain.getKeyPublic(vector.privateKey)).toBe(vector.publicKey);
const result = await deriveWallet("decred", vector.privateKey);
expect(result.details.address).toBe(vector.addresses.mainnet);
expect(JSON.stringify(result)).not.toContain(vector.privateKey);
const generated = chain.generateWallet();
expect(chain.validateAddress(generated.address)).toBe(true);
expect(chain.getKeyPublic(generated.keys.private)).toBe(generated.keys.public);
});

for (const network of ["mainnet", "testnet"] as const) {
it(`${network} matches dcrd for compressed and uncompressed keys`, async () => {
const chain = await blockchains.decred({ network })();
expect(chain.getAddress(vector.publicKey)).toBe(vector.addresses[network]);
const uncompressed = chain.getKeyPublic(vector.privateKey, { compressed: false });
expect(chain.getAddress(uncompressed)).toBe(vector.uncompressedAddresses[network]);
expect(chain.validateAddress(vector.addresses[network])).toBe(true);
expect(chain.validateAddress(vector.uncompressedAddresses[network])).toBe(true);
expect(
chain.validateAddress(vector.addresses[network === "mainnet" ? "testnet" : "mainnet"]),
).toBe(false);
});
}

it("rejects bad checksums, wrong payload sizes, prefixes and unsupported formats", async () => {
const chain = await blockchains.decred()();
const payload = codec.decode(vector.addresses.mainnet);
for (const address of [
"",
"1".repeat(55),
vector.addresses.mainnet.slice(0, -1) + "1",
base58check(sha256).encode(payload),
codec.encode(payload.slice(0, -1)),
codec.encode(new Uint8Array([...payload, 0])),
codec.encode(new Uint8Array([6, ...payload.slice(1)])),
codec.encode(new Uint8Array([payload[0]!, 0x3e, ...payload.slice(2)])),
"DeeUhrRoTp4DftsqddVW96yMGMW4sgQFYUE",
"DcuQKx8BES9wU7C6Q5VmLBjw436r27hayjS",
"DkM3ZigNyiwHrsXRjkDQ8t8tW6uKGW9g61qEkG3bMqQPQWYEf5X3J",
])
expect(chain.validateAddress(address)).toBe(false);
for (const key of ["02", "02" + "ff".repeat(32), "04" + "00".repeat(64)]) {
expect(() => chain.getAddress(key)).toThrow();
}
for (const type of ["p2sh", "segwit", "taproot", "unknown"]) {
expect(() => chain.getAddress(vector.publicKey, type)).toThrow("legacy");
}
await expect(blockchains.decred({ network: "simnet" })()).rejects.toThrow(
"mainnet and testnet only",
);
await expect(deriveWallet("decred", vector.privateKey, "segwit")).rejects.toThrow(
"not supported",
);
});

it("refuses standard BIP32 rather than silently substituting it for Decred HD", async () => {
await expect(
deriveHdWallet("decred", bip39TestVectors.mnemonic, "m/44'/42'/0'/0/0"),
).rejects.toThrow("Decred HD derivation is not supported");
});

it.each(vector.messageHashes)("matches the dcrd message digest %#", async (message, digest) => {
const chain = await blockchains.decred()();
const signature = chain.signMessage(message, vector.privateKey);
expect(
secp256k1.verify(hexToBytes(signature), hexToBytes(digest), hexToBytes(vector.publicKey), {
prehash: false,
}),
).toBe(true);
expect(chain.verifyMessage(message, signature, vector.publicKey)).toBe(true);
expect(chain.signMessage(new TextEncoder().encode(message), vector.privateKey)).toBe(signature);
expect(chain.verifyMessage(message + "!", signature, vector.publicKey)).toBe(false);
expect(chain.verifyMessage(message, "invalid", vector.publicKey)).toBe(false);
expect(chain.verifyMessage(message, signature, "02")).toBe(false);
});

it("verifies the compact r/s bytes from dcrd SignCompact", async () => {
const chain = await blockchains.decred()();
expect(chain.verifyMessage("hello", vector.signature, vector.publicKey)).toBe(true);
expect(chain.signMessage("hello", vector.privateKey)).toBe(vector.signature);
});
});
25 changes: 25 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,28 @@ export const litecoinTestVectors = {
["é".repeat(127), "08bebd99b9d1fbd73231de22e544e9b0b75c0c54ab6e3128f53665cdf944477f"],
],
} as const;

/** Disposable key 1, dcrd stdaddr v4.1.2 and chainhash v1.0.5 with wire v1.7.5. */
export const decredTestVectors = {
privateKey: "00".repeat(31) + "01",
publicKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
addresses: {
mainnet: "DsmcYVbP1Nmag2H4AS17UTvmWXmGeA7nLDx",
testnet: "TsmfmUitQApgnNxQypdGd2x36djCCpDpERU",
},
uncompressedAddresses: {
mainnet: "DsbnCMAYV13buumdjHuwiJeJWZWvgjZRTbE",
testnet: "TsbqRLJ3so6i2GSzYgY6rsfa6fUrFMfSDJD",
},
signature:
"4e590293bb394c5d2a5d21fc2c166fb372c706068dc120e3fe71aaccad831006586d0eb88c6c92b6eb04432fec4550d6ccf9351e02112efa3833f5c0b00e9b36",
messageHashes: [
["", "edec5d11d20ee5ea952da86dba18b453f520d778d40b1004908ed93ea22f93ce"],
["hello", "776fea952d41c5269b91e9710afcd91103ad41a06e814f8ecba72f49044fdfe6"],
["żółw 🐢", "6e3c8c1752e8b6fd010c84c21bfd51496ef47ff4fcc2c81142f80c208e42093b"],
["a".repeat(252), "2c729fc2b4296dfb76b3413208d655beda09aa4fd7b64715c26fe7f85e690e3a"],
["a".repeat(253), "f7fafe4c0f9f82d637608c35852c1a0056c9ef22d52ca43735b321d64c88cde6"],
["a".repeat(65535), "2f3422e725451a9834251b25b1f7ade328740cf70a7e14c4119b32d469175481"],
["a".repeat(65536), "5097489a2d963b9deec6554a0edc4528bef4fc2fe530279c486967efdab79bd7"],
],
} as const;
Loading
Loading