From 0d0d29b229e8d6dac378aa4be431d8824b38f247 Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:27:28 +0200 Subject: [PATCH] feat: implement Decred key and message operations --- AGENTS.md | 2 +- README.md | 21 ++++++- build.config.ts | 1 + packages/pi/extensions/keys.ts | 4 +- src/_blockchains.ts | 1 + src/blockchains/AGENTS.md | 2 + src/blockchains/decred.ts | 98 ++++++++++++++++++++++++++++++ src/tool-operations.ts | 4 ++ src/tool-parameters.ts | 2 + src/utils/bip44/index.ts | 1 + src/utils/bitcoin.ts | 2 +- test/blockchains/decred.test.ts | 103 ++++++++++++++++++++++++++++++++ test/fixtures.ts | 25 ++++++++ test/mcp.test.ts | 13 +++- test/pi-extension.test.ts | 16 ++++- 15 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 src/blockchains/decred.ts create mode 100644 test/blockchains/decred.test.ts diff --git a/AGENTS.md b/AGENTS.md index b557460..c585bc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 70aefdd..483bac4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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 | - | @@ -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 diff --git a/build.config.ts b/build.config.ts index 7e73d80..702318a 100644 --- a/build.config.ts +++ b/build.config.ts @@ -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", diff --git a/packages/pi/extensions/keys.ts b/packages/pi/extensions/keys.ts index 574eee3..d6c7a64 100644 --- a/packages/pi/extensions/keys.ts +++ b/packages/pi/extensions/keys.ts @@ -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", ], @@ -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", diff --git a/src/_blockchains.ts b/src/_blockchains.ts index 8dd6903..4890b88 100644 --- a/src/_blockchains.ts +++ b/src/_blockchains.ts @@ -22,6 +22,7 @@ function lazy(loader: () => Promise 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")), diff --git a/src/blockchains/AGENTS.md b/src/blockchains/AGENTS.md index 85ad225..8b4977f 100644 --- a/src/blockchains/AGENTS.md +++ b/src/blockchains/AGENTS.md @@ -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/.ts` with `class Name extends AbstractBlockchain` diff --git a/src/blockchains/decred.ts b/src/blockchains/decred.ts new file mode 100644 index 0000000..c60d976 --- /dev/null +++ b/src/blockchains/decred.ts @@ -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; diff --git a/src/tool-operations.ts b/src/tool-operations.ts index da0f1fc..58c1a53 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -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 })()), diff --git a/src/tool-parameters.ts b/src/tool-parameters.ts index 20ac65a..2a58734 100644 --- a/src/tool-parameters.ts +++ b/src/tool-parameters.ts @@ -17,6 +17,7 @@ export const BIP44_PATH_MODE_SCHEMA = { export const TOOL_CHAINS = [ "bitcoin", "litecoin", + "decred", "ethereum", "base", "solana", @@ -50,6 +51,7 @@ export const TOOL_ADDRESS_TYPES = [ export const TOOL_ADDRESS_TYPES_BY_CHAIN: Readonly> = { bitcoin: BITCOIN_ADDRESS_TYPES, litecoin: BITCOIN_ADDRESS_TYPES, + decred: ["legacy"], ethereum: [], base: [], solana: [], diff --git a/src/utils/bip44/index.ts b/src/utils/bip44/index.ts index 88d2739..0659abd 100644 --- a/src/utils/bip44/index.ts +++ b/src/utils/bip44/index.ts @@ -51,6 +51,7 @@ export const BIP44 = { BITCOIN: 0, TESTNET: 1, LITECOIN: 2, + DECRED: 42, ETHEREUM: 60, SOLANA: 501, CARDANO: 1815, diff --git a/src/utils/bitcoin.ts b/src/utils/bitcoin.ts index 8106f52..5dfe86b 100644 --- a/src/utils/bitcoin.ts +++ b/src/utils/bitcoin.ts @@ -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); diff --git a/test/blockchains/decred.test.ts b/test/blockchains/decred.test.ts new file mode 100644 index 0000000..3444bae --- /dev/null +++ b/test/blockchains/decred.test.ts @@ -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); + }); +}); diff --git a/test/fixtures.ts b/test/fixtures.ts index f0d777a..380dabc 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -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; diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 87d2dd9..ea6d4a6 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it } from "vitest"; -import { litecoinTestVectors } from "./fixtures.ts"; +import { litecoinTestVectors, decredTestVectors } from "./fixtures.ts"; import { createMcpServer } from "../src/mcp.ts"; const TOOL_NAMES = [ @@ -98,6 +98,17 @@ describe("keys MCP server", () => { expect(text(response.content)).not.toContain(litecoinTestVectors.privateKey); }); + it("derives Decred through the MCP schema and executor", async () => { + const client = await connectTestClient(); + const response = await client.callTool({ + name: "keys_derive_wallet", + arguments: { chain: "decred", privateKey: decredTestVectors.privateKey }, + }); + expect(response.isError).not.toBe(true); + expect(text(response.content)).toContain(decredTestVectors.addresses.mainnet); + expect(text(response.content)).not.toContain(decredTestVectors.privateKey); + }); + it("validates a known Bitcoin address", async () => { const client = await connectTestClient(); diff --git a/test/pi-extension.test.ts b/test/pi-extension.test.ts index 87d72a9..870a778 100644 --- a/test/pi-extension.test.ts +++ b/test/pi-extension.test.ts @@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "typebox"; import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; -import { litecoinTestVectors } from "./fixtures.ts"; +import { litecoinTestVectors, decredTestVectors } from "./fixtures.ts"; import keysExtension from "../packages/pi/extensions/keys.ts"; interface RegisteredTool { @@ -47,6 +47,20 @@ describe("keys Pi extension", () => { ]); }); + it("derives Decred through the registered Pi tool", async () => { + const tool = registerTools().get("keys_derive_wallet"); + if (!tool) throw new Error("keys_derive_wallet was not registered"); + const args = { chain: "decred", privateKey: decredTestVectors.privateKey }; + expect(Value.Check(tool.parameters, args)).toBe(true); + const result = await tool.execute("decred", args); + expect(result.content).toEqual([ + { + type: "text", + text: `Public key: ${decredTestVectors.publicKey}\nAddress: ${decredTestVectors.addresses.mainnet}`, + }, + ]); + }); + it("rejects unsupported networks and address types on every relevant tool", async () => { const tools = registerTools(); const networkCases = [