diff --git a/AGENTS.md b/AGENTS.md index c585bc9..f260382 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ pnpm build # obuild via build.config.ts pnpm lint # oxlint + oxfmt check pnpm lint:fix # oxlint + oxfmt fixes pnpm playground # run any TS file via tsx -pnpm test:mcp # build and exercise all 13 MCP tools over stdio +pnpm test:mcp # build and exercise all 15 MCP tools over stdio ``` ## NOTES diff --git a/README.md b/README.md index 483bac4..eb91487 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Typed key generation, address derivation, and message signing across ten blockch - 🛤️ **BIP44 paths** - derivation path utilities for all supported chains - 🧩 **BIP39 puzzles** - validate phrases, narrow one missing word, and map words or indices across all 10 official lists - 🔌 **Lazy loading** - blockchain implementations load on demand for smaller bundles -- 🤖 **MCP server** - the same 13 key, mnemonic, address, and signing tools over stdio +- 🤖 **MCP server** - the same 15 key, mnemonic, address, and signing tools over stdio - 📐 **Fully typed** - TypeScript definitions for every interface ## Install @@ -68,6 +68,26 @@ const testnet = useBlockchain(await blockchains.bitcoin({ network: "testnet" })( testnet.getAddress(publicKey, "segwit"); // tb1q... ``` +### Import and export WIF + +```ts +import { encodeWIF, decodeWIF, blockchains } from "@agntn/keys"; + +const privateKey = "00".repeat(31) + "01"; +const wif = encodeWIF(privateKey, { chain: "bitcoin" }); +const decoded = decodeWIF(wif, { chain: "bitcoin" }); +const btc = await blockchains.bitcoin()(); +const wallet = btc.deriveWallet(decoded.privateKey, { compressed: decoded.compressed }); +``` + +Choose `chain: "bitcoin"`, `"litecoin"` or `"decred"`, the three chains in this package with native WIF support. Both functions default to `network: "mainnet"`; pass `network: "testnet"` for testnet (testnet3 on Decred). `encodeWIF` takes exactly 64 hex characters without `0x` and defaults to `compressed: true`. Bitcoin and Litecoin also accept `compressed: false`. Decred uses its native BLAKE-256 checksum and ECDSA scheme, and rejects uncompressed exports or other signature schemes. + +`decodeWIF` checks the selected chain/network and returns `{ privateKey, chain, network, compressed }`. Both functions reject invalid secp256k1 scalars; decoding also checks the checksum, payload length and compression marker. Bitcoin and Litecoin share a testnet prefix, so the returned chain is the requested context, not proof of ownership. Other chains, networks and BIP38 encrypted keys are not supported. + +Agents can use `keys_encode_wif` and `keys_decode_wif` through MCP or Pi with the same chain/network choices. Encoding accepts `privateKey` and optional `compressed`; decoding accepts `wif`. Both return the converted secret and effective wallet options. + +Preserve `compressed` when deriving a wallet: the same private key can produce a different address without it. WIF is not encryption. Use disposable test keys only. + ### Litecoin ```ts @@ -181,7 +201,7 @@ Language keys cover the 10 official BIP39 lists. Word lookup is case-insensitive ## MCP server -The package includes a stdio MCP server with the same 13 operations used by the Pi extension. After installing the package, configure an MCP client to run `keys mcp`. A checkout can run the built entry directly: +The package includes a stdio MCP server with the same 15 operations used by the Pi extension. After installing the package, configure an MCP client to run `keys mcp`. A checkout can run the built entry directly: ```json { diff --git a/packages/pi/AGENTS.md b/packages/pi/AGENTS.md index 505942b..f142b75 100644 --- a/packages/pi/AGENTS.md +++ b/packages/pi/AGENTS.md @@ -2,11 +2,11 @@ ## Scope -Pi coding agent extension only. Wraps the `@agntn/keys` library as 13 agent tools. **Do not** add blockchain logic, crypto, or chain implementations here. Those live in `../../src/`. This package is a thin tool surface over shared executors. +Pi coding agent extension only. Wraps the `@agntn/keys` library as 15 agent tools. **Do not** add blockchain logic, crypto, or chain implementations here. Those live in `../../src/`. This package is a thin tool surface over shared executors. ## Layout -- `extensions/keys.ts`: the extension. One `export default function(pi: ExtensionAPI)` registering 13 tools via `pi.registerTool`. +- `extensions/keys.ts`: the extension. One `export default function(pi: ExtensionAPI)` registering 15 tools via `pi.registerTool`. ## Key facts diff --git a/packages/pi/README.md b/packages/pi/README.md index 8bf356a..08d7b92 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -1,6 +1,6 @@ # @agntn/keys: Pi extension -Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library as 13 agent tools for key generation, BIP39 entropy encoding, inspection and recovery, address derivation, validation, signing, and BIP44 paths across 8 blockchains (Bitcoin, Ethereum, Base, Solana, Aptos, TRON, SUI, Cardano). +Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library as 15 agent tools for key generation, WIF conversion, BIP39 entropy encoding, inspection and recovery, address derivation, validation, signing, and BIP44 paths across 10 blockchains (Bitcoin, Litecoin, Decred, Ethereum, Base, Solana, Aptos, TRON, SUI, Cardano). > [!WARNING] > **This extension is experimental.** The package name, public API, provider model, CLI flags, and tool surfaces may change before the first stable release. Pin exact versions if you build on it now. @@ -9,6 +9,8 @@ Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library | Tool | Purpose | | ---------------------------- | -------------------------------------------------------------- | +| `keys_encode_wif` | Export a disposable private key as native BTC, LTC or DCR WIF | +| `keys_decode_wif` | Read native WIF into a hex key, network and compression flag | | `keys_generate_wallet` | Generate private key + public key + address for a chain | | `keys_derive_wallet` | Derive public key + address from an existing private key | | `keys_derive_hd_wallet` | Derive public key + address from a mnemonic and path | @@ -34,9 +36,11 @@ The extension loads the shared executors from `dist/tool-operations.mjs`; in a c - A built library (`pnpm build`) for production resolution of the `@agntn/keys` import. - Dev deps `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `typebox`. +Both WIF tools require a chain and default to mainnet. Encoding defaults to compressed keys; decoding preserves the encoded flag. Bitcoin and Litecoin testnet WIFs overlap, so decoding checks the requested context rather than identifying ownership. Decred supports compressed ECDSA keys only. + ## Security note -`keys_generate_wallet` returns a plaintext private key, while `keys_derive_wallet` and `keys_sign_message` accept one. The BIP39 tools accept words or complete and partial phrases, and may return equivalent entropy, indices, or words allowed by the checksum. Tool arguments and output land in the agent transcript. +`keys_generate_wallet` returns a plaintext private key, while `keys_derive_wallet` and `keys_sign_message` accept one. The BIP39 tools accept words or complete and partial phrases, and may return equivalent entropy, indices, or words allowed by the checksum. WIF tools convert between two equivalent secret representations, neither encrypted. Tool arguments and output land in the agent transcript. > [!CAUTION] > **Never use this with real funds or with any wallet that has ever been used.** Treat every key it touches as burned the moment it appears in tool output. Generate fresh throwaway keys for testing only; assume anything passing through this extension is compromised and discard it. Keys that control real funds belong on a hardware wallet, never in an agent transcript. diff --git a/packages/pi/extensions/keys.ts b/packages/pi/extensions/keys.ts index d6c7a64..2c71327 100644 --- a/packages/pi/extensions/keys.ts +++ b/packages/pi/extensions/keys.ts @@ -7,6 +7,7 @@ import { Type } from "typebox"; import type * as KeysTools from "../../../dist/tool-operations.d.mts"; import { TOOL_ADDRESS_TYPES, TOOL_CHAINS, TOOL_NETWORKS } from "../../../src/tool-parameters.ts"; import { BIP39_LANGUAGES } from "../../../src/utils/bip39/languages.ts"; +import { WIF_ENCODE_PARAMETERS, WIF_DECODE_PARAMETERS } from "../../../src/tool-schemas.ts"; const sourceModuleUrl = new URL("../../../src/tool-operations.ts", import.meta.url); const distributionModuleUrl = new URL("../../../dist/tool-operations.mjs", import.meta.url); @@ -42,6 +43,37 @@ const ADDRESS_TYPE_PARAMETER = Type.Optional( ); export default function keysExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "keys_encode_wif", + label: "Encode WIF", + description: + "Encode a disposable private key as Bitcoin, Litecoin or Decred ECDSA WIF. WIF is not encryption; inputs and results enter the transcript. Never use keys controlling real funds.", + parameters: WIF_ENCODE_PARAMETERS, + renderCall() { + return new Text("🔐 Encode WIF", 0, 0); + }, + async execute(_toolCallId, params) { + return (await loadToolOperations()).encodeWif( + params.chain, + params.privateKey, + params.network, + params.compressed, + ); + }, + }); + pi.registerTool({ + name: "keys_decode_wif", + label: "Decode WIF", + description: + "Decode public or disposable Bitcoin, Litecoin or Decred ECDSA WIF into a hex private key and wallet options. Specify the expected chain and network; Bitcoin and Litecoin testnet WIFs overlap. Both forms enter the transcript.", + parameters: WIF_DECODE_PARAMETERS, + renderCall() { + return new Text("🔐 Decode WIF", 0, 0); + }, + async execute(_toolCallId, params) { + return (await loadToolOperations()).decodeWif(params.chain, params.wif, params.network); + }, + }); // ─── generate_wallet ──────────────────────────────────────────────────── pi.registerTool({ name: "keys_generate_wallet", diff --git a/src/index.ts b/src/index.ts index a9cb6dc..ae9397e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export { AbstractBlockchain, useBlockchain } from "./blockchain.ts"; export { AbstractEVMBlockchain } from "./utils/evm.ts"; +export { encodeWIF, decodeWIF } from "./utils/wif.ts"; +export type { WIFChain, WIFNetworkOptions, WIFOptions, DecodedWIF } from "./utils/wif.ts"; // Export lazy-loaded blockchain implementations export { blockchains } from "./_blockchains.ts"; diff --git a/src/mcp.ts b/src/mcp.ts index 8ae6ec0..18095da 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -22,6 +22,8 @@ import { deriveHdWallet, deriveWallet, encodeBip39Entropy, + encodeWif, + decodeWif, generateWallet, getAddress, inspectMnemonic, @@ -36,6 +38,7 @@ import { verifyMessage, } from "./tool-operations.ts"; import { version } from "./version.ts"; +import { WIF_ENCODE_PARAMETERS, WIF_DECODE_PARAMETERS } from "./tool-schemas.ts"; type ReadonlyObjectSchema = Readonly & { readonly type: "object"; @@ -96,6 +99,25 @@ const addressTypeArgument = Type.Optional( ); const tools: readonly ToolDefinition[] = [ + { + name: "keys_encode_wif", + title: "Encode WIF", + description: + "Encode a disposable private key as Bitcoin, Litecoin or Decred ECDSA WIF. WIF is not encryption; inputs and results enter the transcript. Never use keys controlling real funds.", + inputSchema: WIF_ENCODE_PARAMETERS, + annotations: LOCAL_READ, + execute: (args) => + encodeWif(args["chain"], args["privateKey"], args["network"], args["compressed"]), + }, + { + name: "keys_decode_wif", + title: "Decode WIF", + description: + "Decode public or disposable Bitcoin, Litecoin or Decred ECDSA WIF into a hex private key and wallet options. Specify the expected chain and network; Bitcoin and Litecoin testnet WIFs overlap. Both forms enter the transcript.", + inputSchema: WIF_DECODE_PARAMETERS, + annotations: LOCAL_READ, + execute: (args) => decodeWif(args["chain"], args["wif"], args["network"]), + }, { name: "keys_generate_wallet", title: "Generate Wallet", diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 58c1a53..c444a64 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -2,15 +2,17 @@ * Tool executors shared by the MCP server and the Pi extension. * * Inputs may bypass a host schema, so every executor checks its own boundary. - * Secret inputs are never copied into result details or error messages. + * Errors never echo secret inputs; conversion results contain the equivalent secret. */ +import { encodeWIF, decodeWIF, type DecodedWIF, type WIFNetworkOptions } from "./utils/wif.ts"; import type { AbstractBlockchain } from "./blockchain.ts"; import { blockchains, getBlockchainPath, parseBIP44Path, useBlockchain } from "./index.ts"; import { TOOL_ADDRESS_TYPES_BY_CHAIN, TOOL_CHAINS, TOOL_NETWORKS, + TOOL_WIF_CHAINS, type ToolChain, type ToolNetwork, } from "./tool-parameters.ts"; @@ -739,3 +741,60 @@ export async function bip44Path( }, }; } + +/** Exported WIF and the effective wallet options, without the input hex key. */ +export interface EncodedWIFDetails { + wif: string; + chain: DecodedWIF["chain"]; + network: DecodedWIF["network"]; + compressed: boolean; +} + +function parseWIFContext(chainValue: unknown, networkValue: unknown): WIFNetworkOptions { + const chain = TOOL_WIF_CHAINS.find((candidate) => candidate === chainValue); + if (chain === undefined) + throw new Error("Unsupported WIF chain. Use bitcoin, litecoin or decred"); + const network = networkValue === undefined ? "mainnet" : networkValue; + if (network !== "mainnet" && network !== "testnet") + throw new Error("Unsupported WIF network. Use mainnet or testnet"); + return { chain, network }; +} + +/** + * Export a disposable key as native WIF without echoing the supplied hex. + * @param chainValue - Native WIF chain. + * @param privateKeyValue - Disposable private key as hex. + * @param networkValue - Optional network. + * @param compressedValue - Optional compression flag. + * @returns {ToolResult} WIF and effective wallet options. + */ +export function encodeWif( + chainValue: unknown, + privateKeyValue: unknown, + networkValue?: unknown, + compressedValue?: unknown, +): ToolResult { + const options = parseWIFContext(chainValue, networkValue); + const compressed = compressedValue === undefined ? true : compressedValue; + if (typeof compressed !== "boolean") throw new TypeError("WIF compressed must be a boolean"); + const wif = encodeWIF(requiredString(privateKeyValue, "Private key"), { ...options, compressed }); + const details = { wif, chain: options.chain, network: options.network ?? "mainnet", compressed }; + return { content: content(JSON.stringify(details)), details }; +} + +/** + * Read native WIF into hex and wallet options; both representations are secrets. + * @param chainValue - Expected native WIF chain. + * @param wifValue - Public or disposable WIF. + * @param networkValue - Optional expected network. + * @returns {ToolResult} Private key and effective wallet options. + */ +export function decodeWif( + chainValue: unknown, + wifValue: unknown, + networkValue?: unknown, +): ToolResult { + const options = parseWIFContext(chainValue, networkValue); + const details = decodeWIF(requiredString(wifValue, "WIF"), options); + return { content: content(JSON.stringify(details)), details }; +} diff --git a/src/tool-parameters.ts b/src/tool-parameters.ts index 2a58734..f8dee90 100644 --- a/src/tool-parameters.ts +++ b/src/tool-parameters.ts @@ -1,3 +1,5 @@ +import type { WIFChain } from "./utils/wif.ts"; + /** Require either parse mode by itself or generation mode with its optional indices. */ export const BIP44_PATH_MODE_SCHEMA = { oneOf: [ @@ -60,3 +62,10 @@ export const TOOL_ADDRESS_TYPES_BY_CHAIN: Readonly 54) { + throw new Error("Invalid WIF length"); + } + try { + return chain === "decred" ? decodeDecred(wif) : decodeBase58Check(wif); + } catch { + throw new Error("Invalid WIF encoding or checksum"); + } +} + +/** + * Read the compression marker or require Decred's supported ECDSA scheme. + * @param payload - Verified payload + * @param chain - Expected chain + * @returns {boolean} Whether the WIF represents a compressed public key + */ +function readCompression(payload: Uint8Array, chain: WIFChain): boolean { + if (chain === "decred") { + if (payload[2] !== 0) { + throw new Error("Decred WIF supports ECDSA secp256k1 only"); + } + return true; + } + if (payload.length !== 33 && payload.length !== 34) { + throw new Error("Invalid WIF payload length"); + } + const compressed = payload.length === 34; + if (compressed && payload[33] !== 1) { + throw new Error("Invalid WIF compression flag"); + } + return compressed; +} + +/** + * Encode a private key for the selected chain, defaulting to mainnet and compressed keys. + * @param privateKey - Exactly 32 bytes of hex without a prefix + * @param options - Chain, network and compression flag + * @returns {string} Native WIF, not encrypted + */ +export function encodeWIF(privateKey: string, options: WIFOptions): string { + const { chain, prefix } = resolveNetwork(options); + const { compressed = true } = options; + if (typeof compressed !== "boolean") { + throw new TypeError("WIF compressed must be a boolean"); + } + const key = parsePrivateKey(privateKey); + if (chain === "decred") { + if (!compressed) { + throw new Error("Decred WIF requires a compressed public key"); + } + const payload = concatBytes(prefix, new Uint8Array([0]), key); + return base58.encode(concatBytes(payload, blake256(payload).slice(0, 4))); + } + const payload = concatBytes(prefix, key, compressed ? new Uint8Array([1]) : new Uint8Array()); + return encodeBase58Check(payload); +} + +/** + * Decode WIF against an explicit chain and network instead of guessing from its prefix. + * @param wif - Native WIF string + * @param options - Expected chain and network, defaulting to mainnet + * @returns {DecodedWIF} Hex private key and effective wallet options + */ +export function decodeWIF(wif: string, options: WIFNetworkOptions): DecodedWIF { + const { chain, network, prefix } = resolveNetwork(options); + const payload = readPayload(wif, chain); + const compressed = readCompression(payload, chain); + if (!equalBytes(payload.subarray(0, prefix.length), prefix)) { + throw new Error("WIF does not match the expected chain/network"); + } + const offset = chain === "decred" ? 3 : 1; + const key = payload.slice(offset, offset + 32); + if (!secp256k1.utils.isValidSecretKey(key)) { + throw new Error("Invalid WIF private key scalar"); + } + return { privateKey: bytesToHex(key), chain, network, compressed }; +} diff --git a/test/eval-mcp.mjs b/test/eval-mcp.mjs index 5a197b4..08e6477 100644 --- a/test/eval-mcp.mjs +++ b/test/eval-mcp.mjs @@ -62,7 +62,7 @@ await client.connect(transport); try { const listed = await client.listTools(); - if (listed.tools.length !== 13) throw new Error(`Expected 13 tools, got ${listed.tools.length}`); + if (listed.tools.length !== 15) throw new Error(`Expected 15 tools, got ${listed.tools.length}`); const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; const mnemonic = @@ -70,6 +70,15 @@ try { const missing = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ?"; + for (const chain of ["bitcoin", "litecoin", "decred"]) { + for (const network of ["mainnet", "testnet"]) { + const encoded = await call("keys_encode_wif", { chain, network, privateKey }, /"wif":/); + const wif = /"wif":"([1-9A-HJ-NP-Za-km-z]+)"/.exec(encoded)?.[1]; + if (!wif) throw new Error("keys_encode_wif returned no WIF"); + await call("keys_decode_wif", { chain, network, wif }, new RegExp(privateKey)); + } + } + await call("keys_generate_wallet", { chain: "bitcoin" }, /Private key: [0-9a-f]{64}/); const derived = await call( "keys_derive_wallet", diff --git a/test/fixtures.ts b/test/fixtures.ts index 380dabc..597c365 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -93,3 +93,110 @@ export const decredTestVectors = { ["a".repeat(65536), "5097489a2d963b9deec6554a0edc4528bef4fc2fe530279c486967efdab79bd7"], ], } as const; + +/** + * Public WIF interoperability vectors. + * @see https://github.com/bitcoinjs/wif/blob/master/test/fixtures.json + * @see https://github.com/litecoin-project/litecoin/blob/master/src/test/data/key_io_valid.json + * @see https://github.com/decred/dcrd/blob/master/dcrutil/wif_test.go + */ +export const wifTestVectors = [ + { + wif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", + privateKey: "0000000000000000000000000000000000000000000000000000000000000001", + chain: "bitcoin", + network: "mainnet", + compressed: true, + }, + { + wif: "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf", + privateKey: "0000000000000000000000000000000000000000000000000000000000000001", + chain: "bitcoin", + network: "mainnet", + compressed: false, + }, + { + wif: "KxhEDBQyyEFymvfJD96q8stMbJMbZUb6D1PmXqBWZDU2WvbvVs9o", + privateKey: "2bfe58ab6d9fd575bdc3a624e4825dd2b375d64ac033fbc46ea79dbab4f69a3e", + chain: "bitcoin", + network: "mainnet", + compressed: true, + }, + { + wif: "KzrA86mCVMGWnLGBQu9yzQa32qbxb5dvSK4XhyjjGAWSBKYX4rHx", + privateKey: "6c4313b03f2e7324d75e642f0ab81b734b724e13fec930f309e222470236d66b", + chain: "bitcoin", + network: "mainnet", + compressed: true, + }, + { + wif: "5JdxzLtFPHNe7CAL8EBC6krdFv9pwPoRo4e3syMZEQT9srmK8hh", + privateKey: "6c4313b03f2e7324d75e642f0ab81b734b724e13fec930f309e222470236d66b", + chain: "bitcoin", + network: "mainnet", + compressed: false, + }, + { + wif: "cRD9b1m3vQxmwmjSoJy7Mj56f4uNFXjcWMCzpQCEmHASS4edEwXv", + privateKey: "6c4313b03f2e7324d75e642f0ab81b734b724e13fec930f309e222470236d66b", + chain: "bitcoin", + network: "testnet", + compressed: true, + }, + { + wif: "92Qba5hnyWSn5Ffcka56yMQauaWY6ZLd91Vzxbi4a9CCetaHtYj", + privateKey: "6c4313b03f2e7324d75e642f0ab81b734b724e13fec930f309e222470236d66b", + chain: "bitcoin", + network: "testnet", + compressed: false, + }, + { + wif: "L5oLkpV3aqBjhki6LmvChTCV6odsp4SXM6FfU2Gppt5kFLaHLuZ9", + privateKey: "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140", + chain: "bitcoin", + network: "mainnet", + compressed: true, + }, + { + wif: "6vqpCruyRS9bEY6ZVrL8S9EL5h2MnQZffzzqrpNP9i8YozvXTKs", + privateKey: "e2993b1b4a8b1e00c024715e106d7c79ada82528b80b938a566cfc71f9ffcf42", + chain: "litecoin", + network: "mainnet", + compressed: false, + }, + { + wif: "T5MZ5z9WqJxzVxYyVPecTJUSDkzDWrUYe1JuSX2AqJ9jKmLJrvTE", + privateKey: "44b78d45adc801a65949661d5df1c4a44f532cd422be413a505d776784ddbe25", + chain: "litecoin", + network: "mainnet", + compressed: true, + }, + { + wif: "927w9fGHSbrUEWHdfBd5AU4mDFmhEnBkxLyfsRn4oNwPTLQw7qS", + privateKey: "466d8cbefaa702b2f597ade1c4f9fa4b0e709527e214443990c13e3fdcb53deb", + chain: "litecoin", + network: "testnet", + compressed: false, + }, + { + wif: "cQaeKQwuakynYD9iebyxsKiBKF8RT3G6zoqRNUDybMsAimANRypo", + privateKey: "597b8f070b98ee1f997fa3cb976466fa0e931256246b8c7177d2b067eed06ad7", + chain: "litecoin", + network: "testnet", + compressed: true, + }, + { + wif: "PmQdMn8xafwaQouk8ngs1CccRCB1ZmsqQxBaxNR4vhQi5a5QB5716", + privateKey: "0c28fca386c7a227600b2fe50b7cae11ec86d3bf1fbe471be89827e19d72aa1d", + chain: "decred", + network: "mainnet", + compressed: true, + }, + { + wif: "PtWVDUidYaiiNT5e2Sfb1Ah4evbaSopZJkkpFBuzkJYcYteugvdFg", + privateKey: "dda35a1488fb97b6eb3fe6e9ef2a25814e396fb5dc295fe994b96789b21a0398", + chain: "decred", + network: "testnet", + compressed: true, + }, +] as const; diff --git a/test/mcp.test.ts b/test/mcp.test.ts index ea6d4a6..01dbb3f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,10 +1,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it } from "vitest"; -import { litecoinTestVectors, decredTestVectors } from "./fixtures.ts"; +import { litecoinTestVectors, decredTestVectors, wifTestVectors } from "./fixtures.ts"; import { createMcpServer } from "../src/mcp.ts"; const TOOL_NAMES = [ + "keys_encode_wif", + "keys_decode_wif", "keys_generate_wallet", "keys_derive_wallet", "keys_derive_hd_wallet", @@ -42,6 +44,46 @@ afterEach(async () => { }); describe("keys MCP server", () => { + it.each(wifTestVectors)("converts $chain $network WIF through MCP", async (vector) => { + const client = await connectTestClient(); + const { chain, network, compressed, privateKey, wif } = vector; + const encoded = await client.callTool({ + name: "keys_encode_wif", + arguments: { chain, network, compressed, privateKey }, + }); + expect(encoded.isError).not.toBe(true); + expect(JSON.parse(text(encoded.content))).toEqual({ chain, network, compressed, wif }); + const decoded = await client.callTool({ + name: "keys_decode_wif", + arguments: { chain, network, wif }, + }); + expect(decoded.isError).not.toBe(true); + expect(JSON.parse(text(decoded.content))).toEqual({ chain, network, compressed, privateKey }); + }); + + it("rejects malformed WIF tool inputs without echoing secrets", async () => { + const client = await connectTestClient(); + const secret = "burner-secret-not-valid-WIF"; + for (const args of [ + { chain: "ethereum", wif: secret }, + { chain: "bitcoin", wif: secret }, + { chain: "bitcoin", wif: "1".repeat(55) }, + { chain: "bitcoin", wif: "111", network: "unknown" }, + { chain: "bitcoin", wif: "111", extra: secret }, + ]) { + const result = await client.callTool({ name: "keys_decode_wif", arguments: args }); + expect(result.isError).toBe(true); + expect(text(result.content)).not.toContain(secret); + } + const vector = wifTestVectors[0]; + const wrongChain = await client.callTool({ + name: "keys_decode_wif", + arguments: { chain: "litecoin", wif: vector.wif }, + }); + expect(wrongChain.isError).toBe(true); + expect(text(wrongChain.content)).not.toContain(vector.wif); + }); + it("advertises every keys tool with explicit safety annotations", async () => { const client = await connectTestClient(); diff --git a/test/pi-extension.test.ts b/test/pi-extension.test.ts index 870a778..125eaed 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, decredTestVectors } from "./fixtures.ts"; +import { litecoinTestVectors, decredTestVectors, wifTestVectors } from "./fixtures.ts"; import keysExtension from "../packages/pi/extensions/keys.ts"; interface RegisteredTool { @@ -33,6 +33,49 @@ function registerTools(): ReadonlyMap { } describe("keys Pi extension", () => { + it.each(wifTestVectors)("converts $chain $network WIF through Pi", async (vector) => { + const tools = registerTools(); + const encode = tools.get("keys_encode_wif"); + const decode = tools.get("keys_decode_wif"); + if (!encode || !decode) throw new Error("WIF tools not registered"); + const { chain, network, compressed, privateKey, wif } = vector; + const encodeArgs = { chain, network, compressed, privateKey }; + const decodeArgs = { chain, network, wif }; + expect(Value.Check(encode.parameters, encodeArgs)).toBe(true); + expect(Value.Check(decode.parameters, decodeArgs)).toBe(true); + expect(await encode.execute("encode-wif", encodeArgs)).toEqual({ + content: [{ type: "text", text: JSON.stringify({ wif, chain, network, compressed }) }], + details: { wif, chain, network, compressed }, + }); + expect(await decode.execute("decode-wif", decodeArgs)).toEqual({ + content: [{ type: "text", text: JSON.stringify({ privateKey, chain, network, compressed }) }], + details: { privateKey, chain, network, compressed }, + }); + }); + + it("validates WIF inputs even when Pi skips schema validation", async () => { + const tools = registerTools(); + const encode = tools.get("keys_encode_wif"); + const decode = tools.get("keys_decode_wif"); + if (!encode || !decode) throw new Error("WIF tools not registered"); + const privateKey = wifTestVectors[0].privateKey; + for (const args of [ + { chain: "ethereum", privateKey }, + { chain: "bitcoin", privateKey: "private-secret" }, + { chain: "bitcoin", privateKey, compressed: "false" }, + { chain: "bitcoin", privateKey, network: "unknown" }, + ]) { + expect(Value.Check(encode.parameters, args)).toBe(false); + await expect(encode.execute("invalid", args)).rejects.toThrow(); + } + await expect( + encode.execute("invalid", { chain: "decred", privateKey, compressed: false }), + ).rejects.toThrow("Decred WIF requires"); + await expect( + decode.execute("invalid", { chain: "bitcoin", wif: "private-secret" }), + ).rejects.toThrow("Invalid WIF encoding or checksum"); + }); + it("derives Litecoin through the registered Pi tool", async () => { const tool = registerTools().get("keys_derive_wallet"); if (!tool) throw new Error("keys_derive_wallet was not registered"); diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts index 00dfa5d..e1545ab 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import type { DecodedWIF, WIFOptions } from "@agntn/keys"; +import { wifTestVectors } from "./fixtures.ts"; const EXPORTS = [ ["@agntn/keys/bip32", "/dist/utils/bip32/index.mjs"], @@ -6,6 +8,20 @@ const EXPORTS = [ ["@agntn/keys/slip10", "/dist/utils/slip10/index.mjs"], ] as const; +describe("Public WIF exports", () => { + it("imports the built API and preserves compression when deriving a wallet", async () => { + const { encodeWIF, decodeWIF, blockchains } = await import("@agntn/keys"); + const vector = wifTestVectors[1]; + const options: WIFOptions = { chain: vector.chain, compressed: vector.compressed }; + expect(encodeWIF(vector.privateKey, options)).toBe(vector.wif); + const decoded: DecodedWIF = decodeWIF(vector.wif, options); + const btc = await blockchains.bitcoin()(); + const wallet = btc.deriveWallet(decoded.privateKey, { compressed: decoded.compressed }); + expect(wallet).toEqual(btc.deriveWallet(vector.privateKey, { compressed: false })); + expect(wallet.address).not.toBe(btc.deriveWallet(decoded.privateKey).address); + }); +}); + describe("Public derivation exports", () => { it.each(EXPORTS)("resolves %s", (specifier, path) => { expect(import.meta.resolve(specifier).endsWith(path)).toBe(true); diff --git a/test/utils/wif.test.ts b/test/utils/wif.test.ts new file mode 100644 index 0000000..f0edc7a --- /dev/null +++ b/test/utils/wif.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { blake256 } from "@noble/hashes/blake1.js"; +import { concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import { base58 } from "@scure/base"; +import { decodeWIF, encodeWIF } from "../../src/index.ts"; +import { encodeBase58Check } from "../../src/utils/encoding.ts"; +import { wifTestVectors } from "../fixtures.ts"; + +const keyOne = "00".repeat(31) + "01"; +const order = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; +const bitcoin = { chain: "bitcoin" } as const; +const decred = { chain: "decred" } as const; + +/* Build malformed Decred payloads with a valid native checksum. */ +function decredWIF(hex: string): string { + const payload = hexToBytes(hex); + return base58.encode(concatBytes(payload, blake256(payload).slice(0, 4))); +} + +describe("WIF", () => { + it.each(wifTestVectors)("matches $chain $network vector $wif", (vector) => { + expect(encodeWIF(vector.privateKey, vector)).toBe(vector.wif); + expect(decodeWIF(vector.wif, vector)).toEqual({ + privateKey: vector.privateKey, + chain: vector.chain, + network: vector.network, + compressed: vector.compressed, + }); + }); + + it.each(["bitcoin", "litecoin", "decred"] as const)( + "defaults to compressed mainnet for %s and preserves leading zeros", + (chain) => { + expect(decodeWIF(encodeWIF(keyOne, { chain }), { chain })).toEqual({ + privateKey: keyOne, + chain, + network: "mainnet", + compressed: true, + }); + expect( + decodeWIF(encodeWIF(keyOne, { chain, network: "testnet" }), { chain, network: "testnet" }), + ).toEqual({ + privateKey: keyOne, + chain, + network: "testnet", + compressed: true, + }); + }, + ); + + it("enforces chain/network while acknowledging shared testnet prefixes", () => { + const wif = encodeWIF(keyOne, { chain: "litecoin" }); + expect(() => decodeWIF(wif, bitcoin)).toThrow("expected chain/network"); + for (const chain of ["bitcoin", "litecoin", "decred"] as const) { + const testnet = encodeWIF(keyOne, { chain, network: "testnet" }); + expect(() => decodeWIF(testnet, { chain })).toThrow("expected chain/network"); + } + const shared = encodeWIF(keyOne, { chain: "bitcoin", network: "testnet" }); + expect(shared).toBe(encodeWIF(keyOne, { chain: "litecoin", network: "testnet" })); + expect(decodeWIF(shared, { chain: "litecoin", network: "testnet" }).chain).toBe("litecoin"); + expect(() => decodeWIF(wif, decred)).toThrow(); + expect(() => decodeWIF(encodeWIF(keyOne, decred), bitcoin)).toThrow(); + }); + + it.each(["00".repeat(32), order, "ff".repeat(32)])("rejects invalid scalar %s", (key) => { + for (const chain of ["bitcoin", "litecoin", "decred"] as const) { + expect(() => encodeWIF(key, { chain })).toThrow("Invalid WIF private key scalar"); + } + for (const suffix of ["", "01"]) { + expect(() => decodeWIF(encodeBase58Check(hexToBytes("80" + key + suffix)), bitcoin)).toThrow( + "Invalid WIF private key scalar", + ); + expect(() => + decodeWIF(encodeBase58Check(hexToBytes("b0" + key + suffix)), { chain: "litecoin" }), + ).toThrow("Invalid WIF private key scalar"); + } + expect(() => decodeWIF(decredWIF("22de00" + key), decred)).toThrow( + "Invalid WIF private key scalar", + ); + }); + + it.each([ + "", + "01", + "00".repeat(31), + "00".repeat(33), + "0x" + keyOne, + "g".repeat(64), + keyOne + "\n", + ])("rejects malformed hex without echoing it", (key) => { + expect(() => encodeWIF(key, bitcoin)).toThrow( + "WIF private key must be 32 bytes of hex without a prefix", + ); + }); + + it("rejects malformed Bitcoin payloads with valid checksums", () => { + for (const length of [31, 35]) { + const payload = new Uint8Array(length); + payload[0] = 128; + expect(() => decodeWIF(encodeBase58Check(payload), bitcoin)).toThrow( + "Invalid WIF payload length", + ); + } + for (const flag of ["00", "02", "ff"]) { + expect(() => decodeWIF(encodeBase58Check(hexToBytes("80" + keyOne + flag)), bitcoin)).toThrow( + "Invalid WIF compression flag", + ); + } + }); + + it("rejects unsupported Decred schemes and uncompressed exports", () => { + for (const scheme of ["01", "02", "ff"]) { + expect(() => decodeWIF(decredWIF("22de" + scheme + keyOne), decred)).toThrow( + "Decred WIF supports ECDSA secp256k1 only", + ); + } + expect(() => encodeWIF(keyOne, { chain: "decred", compressed: false })).toThrow( + "Decred WIF requires a compressed public key", + ); + expect(() => decodeWIF(decredWIF("22de00" + keyOne + "01"), decred)).toThrow(); + expect(() => decodeWIF(encodeBase58Check(hexToBytes("22de00" + keyOne)), decred)).toThrow( + "Invalid WIF encoding or checksum", + ); + }); + + it.each(["bitcoin", "litecoin", "decred"] as const)("rejects corrupted %s WIF", (chain) => { + const wif = encodeWIF(keyOne, { chain }); + const last = wif.endsWith("1") ? "2" : "1"; + for (const input of [wif.slice(0, -1) + last, " " + wif, wif + "\n", "0".repeat(51)]) { + expect(() => decodeWIF(input, { chain })).toThrow("Invalid WIF encoding or checksum"); + } + expect(() => decodeWIF("1".repeat(100_000), { chain })).toThrow("Invalid WIF length"); + expect(() => decodeWIF("", { chain })).toThrow("Invalid WIF length"); + }); + + it("rejects unsupported chains, networks and JavaScript inputs without coercion", () => { + for (const chain of [ + "ethereum", + "base", + "solana", + "aptos", + "sui", + "cardano", + "tron", + "toString", + "__proto__", + ]) { + /* @ts-expect-error Deliberately unsupported chain. */ + expect(() => encodeWIF(keyOne, { chain })).toThrow("Unsupported WIF chain"); + /* @ts-expect-error Deliberately unsupported chain. */ + expect(() => decodeWIF("invalid", { chain })).toThrow("Unsupported WIF chain"); + } + /* @ts-expect-error Deliberately invalid JavaScript input. */ + expect(() => encodeWIF(keyOne, { chain: "bitcoin", compressed: "false" })).toThrow( + "WIF compressed", + ); + /* @ts-expect-error Deliberately unsupported network. */ + expect(() => decodeWIF("invalid", { chain: "bitcoin", network: "regtest" })).toThrow( + "Unsupported WIF network", + ); + /* @ts-expect-error Deliberately invalid JavaScript input. */ + expect(() => encodeWIF(123, bitcoin)).toThrow("WIF private key"); + /* @ts-expect-error Deliberately invalid JavaScript input. */ + expect(() => decodeWIF(123, bitcoin)).toThrow("Invalid WIF length"); + }); +}); diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json index fb1a663..e0fbb43 100644 --- a/tsconfig.type-tests.json +++ b/tsconfig.type-tests.json @@ -7,6 +7,7 @@ "include": [ "test/public-exports.test.ts", "test/utils/slip10.test.ts", - "test/utils/signing.test.ts" + "test/utils/signing.test.ts", + "test/utils/wif.test.ts" ] }