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 @@ -76,7 +76,7 @@ pnpm build # obuild via build.config.ts
pnpm lint # oxlint + oxfmt check
pnpm lint:fix # oxlint + oxfmt fixes
pnpm playground <f> # 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
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down
4 changes: 2 additions & 2 deletions packages/pi/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions packages/pi/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 |
Expand All @@ -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.
32 changes: 32 additions & 0 deletions packages/pi/extensions/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
22 changes: 22 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
deriveHdWallet,
deriveWallet,
encodeBip39Entropy,
encodeWif,
decodeWif,
generateWallet,
getAddress,
inspectMnemonic,
Expand All @@ -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<TSchema> & {
readonly type: "object";
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 60 additions & 1 deletion src/tool-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<EncodedWIFDetails>} WIF and effective wallet options.
*/
export function encodeWif(
chainValue: unknown,
privateKeyValue: unknown,
networkValue?: unknown,
compressedValue?: unknown,
): ToolResult<EncodedWIFDetails> {
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<DecodedWIF>} Private key and effective wallet options.
*/
export function decodeWif(
chainValue: unknown,
wifValue: unknown,
networkValue?: unknown,
): ToolResult<DecodedWIF> {
const options = parseWIFContext(chainValue, networkValue);
const details = decodeWIF(requiredString(wifValue, "WIF"), options);
return { content: content(JSON.stringify(details)), details };
}
9 changes: 9 additions & 0 deletions src/tool-parameters.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -60,3 +62,10 @@ export const TOOL_ADDRESS_TYPES_BY_CHAIN: Readonly<Record<ToolChain, readonly st
sui: SUI_ADDRESS_TYPES,
cardano: CARDANO_ADDRESS_TYPES,
};

/** Native WIF chains exposed by both agent transports. */
export const TOOL_WIF_CHAINS = [
"bitcoin",
"litecoin",
"decred",
] as const satisfies readonly WIFChain[];
46 changes: 46 additions & 0 deletions src/tool-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Type } from "typebox";
import { TOOL_WIF_CHAINS, TOOL_NETWORKS } from "./tool-parameters.ts";

const wifContext = {
chain: Type.String({
enum: TOOL_WIF_CHAINS,
description: "Native WIF chain: bitcoin, litecoin or decred",
}),
network: Type.Optional(
Type.String({
enum: TOOL_NETWORKS,
description: "Expected network. Default: mainnet; Decred testnet means testnet3",
}),
),
};

/** Shared MCP and Pi schema for exporting a disposable private key. */
export const WIF_ENCODE_PARAMETERS = Type.Object(
{
...wifContext,
privateKey: Type.String({
minLength: 64,
maxLength: 64,
pattern: "^[0-9A-Fa-f]{64}$",
description: "Disposable private key as 64 hex characters without 0x",
}),
compressed: Type.Optional(
Type.Boolean({ description: "Compressed public key. Default: true; Decred requires true" }),
),
},
{ additionalProperties: false },
);

/** Shared MCP and Pi schema for reading a disposable WIF. */
export const WIF_DECODE_PARAMETERS = Type.Object(
{
...wifContext,
wif: Type.String({
minLength: 1,
maxLength: 54,
pattern: "^[1-9A-HJ-NP-Za-km-z]+$",
description: "Public or disposable WIF string",
}),
},
{ additionalProperties: false },
);
Loading
Loading