diff --git a/AGENTS.md b/AGENTS.md index 97fbafb..b557460 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 8 blockchains (Bitcoin, 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 9 blockchains (Bitcoin, Litecoin, Ethereum, Base, Solana, Aptos, Cardano, SUI, TRON). Built entirely on the @noble/@scure audited crypto ecosystem. ## STRUCTURE @@ -30,19 +30,20 @@ keys/ ## WHERE TO LOOK -| Task | Location | Notes | -| ------------------ | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| Add new blockchain | `src/blockchains/` + `src/_blockchains.ts` | Extend the appropriate base class, register in lazy loader | -| Add address format | `src/utils/address.ts` | Shared across chains (legacy, segwit, hex, base58) | -| Add EVM chain | `src/utils/evm.ts` → `AbstractEVMBlockchain` | Minimal subclass with `name` and `bip44` | -| Fix signing | `src/utils/signing.ts` (generic) or `evm.ts`/`ed25519-chains.ts` (chain-specific) | EVM uses preamble hash, ed25519 signs raw | -| Change public API | `src/index.ts` | Re-exports only, never add logic here | -| Change agent tools | `src/tool-operations.ts`, `src/mcp.ts`, `packages/pi/extensions/keys.ts` | Executors are shared; schemas stay aligned | -| Add BIP/derivation | `src/utils/bip32/`, `bip39/`, `bip44/`, `slip10/` | Subdirs with index.ts | -| Mnemonic to wallet | `src/blockchain.ts` → `deriveHDWallet` + `src/utils/hd.ts` | Bitcoin and Sui override it, Cardano throws (CIP-1852) | -| Write tests | `test/` mirroring `src/` path | Use fixtures from `test/fixtures.ts` | -| Integration test | `test-integration/` | Separate pnpm package, manual execution | -| Run demos | `playground/*.ts` | Execute via `pnpm playground ` | +| Task | Location | Notes | +| ------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Add new blockchain | `src/blockchains/` + `src/_blockchains.ts` | Extend the appropriate base class, register in lazy loader | +| Add address format | `src/utils/address.ts` | Shared across chains (legacy, segwit, hex, base58) | +| Add Bitcoin family | `src/utils/bitcoin.ts` → `AbstractBitcoinBlockchain` | Reuse transparent address and HD behavior; keep chain signing rules explicit | +| Add EVM chain | `src/utils/evm.ts` → `AbstractEVMBlockchain` | Minimal subclass with `name` and `bip44` | +| Fix signing | `src/utils/signing.ts` (generic) or `evm.ts`/`ed25519-chains.ts` (chain-specific) | EVM uses preamble hash, ed25519 signs raw | +| Change public API | `src/index.ts` | Re-exports only, never add logic here | +| Change agent tools | `src/tool-operations.ts`, `src/mcp.ts`, `packages/pi/extensions/keys.ts` | Executors are shared; schemas stay aligned | +| Add BIP/derivation | `src/utils/bip32/`, `bip39/`, `bip44/`, `slip10/` | Subdirs with index.ts | +| Mnemonic to wallet | `src/blockchain.ts` → `deriveHDWallet` + `src/utils/hd.ts` | Bitcoin/Litecoin infer the address type; Sui overrides it, Cardano throws (CIP-1852) | +| Write tests | `test/` mirroring `src/` path | Use fixtures from `test/fixtures.ts` | +| Integration test | `test-integration/` | Separate pnpm package, manual execution | +| Run demos | `playground/*.ts` | Execute via `pnpm playground ` | ## CONVENTIONS diff --git a/README.md b/README.md index da10d11..70aefdd 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 eight blockchains and two curves. +Typed key generation, address derivation, and message signing across nine 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. @@ -68,6 +68,22 @@ const testnet = useBlockchain(await blockchains.bitcoin({ network: "testnet" })( testnet.getAddress(publicKey, "segwit"); // tb1q... ``` +### Litecoin + +```ts +import { blockchains } from "@agntn/keys"; + +const mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const ltc = await blockchains.litecoin()(); +const wallet = ltc.deriveHDWallet(mnemonic, "m/84'/2'/0'/0/0"); +const testnet = await blockchains.litecoin({ network: "testnet" })(); +``` + +Litecoin uses the same five address types as Bitcoin, with `L`/`M`/`ltc1` on mainnet and `m` or `n`/`Q`/`tltc1` on testnet. Old P2SH prefixes (`3` and `2`) are accepted, not generated. Those old addresses overlap with Bitcoin, so validation alone cannot identify the chain. MWEB and regtest are outside this implementation. + +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`. + ### Sign and verify messages ```ts @@ -124,7 +140,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 reads 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. Cardano throws, because CIP-1852 starts from the entropy rather than the BIP39 seed. ### Recover one missing BIP39 word @@ -173,6 +189,7 @@ The server handles private keys, mnemonics, entropy, messages, and signatures as | Chain | Curve | Address Formats | Testnet | | ------------ | ------------------ | ------------------------------------ | ------- | | **Bitcoin** | secp256k1 | legacy, p2sh, segwit, p2wsh, taproot | ✅ | +| **Litecoin** | secp256k1 | legacy, p2sh, segwit, p2wsh, taproot | ✅ | | **Ethereum** | secp256k1 | EIP-55 checksum | - | | **Base** | secp256k1 | EVM-compatible | - | | **Solana** | ed25519 | base58 | - | diff --git a/build.config.ts b/build.config.ts index 9e96596..7e73d80 100644 --- a/build.config.ts +++ b/build.config.ts @@ -15,6 +15,7 @@ export default defineBuildConfig({ "./src/blockchains/aptos.ts", "./src/blockchains/base.ts", "./src/blockchains/bitcoin.ts", + "./src/blockchains/litecoin.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 e9d912b..574eee3 100644 --- a/packages/pi/extensions/keys.ts +++ b/packages/pi/extensions/keys.ts @@ -51,9 +51,9 @@ 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, ethereum, base, solana, aptos, tron, sui, cardano)", + "Provide a chain name (bitcoin, litecoin, ethereum, base, solana, aptos, tron, sui, cardano)", "Optionally specify network (mainnet/testnet) and address type", - "Bitcoin address types: legacy, p2sh, segwit, p2wsh, taproot", + "Bitcoin and Litecoin address types: legacy, p2sh, segwit, p2wsh, taproot", "Cardano address types: payment, stake, enterprise", "Returns hex private key, hex public key, and address", ], @@ -84,7 +84,7 @@ export default function keysExtension(pi: ExtensionAPI) { promptGuidelines: [ "Provide a chain name and private key as hex", "Optionally specify network and address type", - "Bitcoin address types: legacy, p2sh, segwit, p2wsh, taproot", + "Bitcoin and Litecoin address types: legacy, p2sh, segwit, p2wsh, taproot", "For Sui, use ed25519 or secp256k1 as the address type", ], parameters: Type.Object({ @@ -115,7 +115,7 @@ export default function keysExtension(pi: ExtensionAPI) { promptGuidelines: [ "Provide a chain, an English BIP39 mnemonic, and a full derivation path", "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 picks the address type from the path purpose unless addressType is set", + "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", "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", @@ -374,7 +374,7 @@ export default function keysExtension(pi: ExtensionAPI) { promptGuidelines: [ "Provide chain, message text, and private key (hex)", "Returns the signature as hex string", - "Bitcoin uses its own message preamble format", + "Bitcoin and Litecoin each use their own message preamble", "Ethereum/Base use EIP-191 prefix", ], parameters: Type.Object({ diff --git a/src/_blockchains.ts b/src/_blockchains.ts index c643eb6..8dd6903 100644 --- a/src/_blockchains.ts +++ b/src/_blockchains.ts @@ -22,6 +22,7 @@ function lazy(loader: () => Promise import("./blockchains/bitcoin.ts")), + litecoin: lazy(() => import("./blockchains/litecoin.ts")), solana: lazy(() => import("./blockchains/solana.ts")), aptos: lazy(() => import("./blockchains/aptos.ts")), tron: lazy(() => import("./blockchains/tron.ts")), diff --git a/src/blockchains/AGENTS.md b/src/blockchains/AGENTS.md index 4f7b038..85ad225 100644 --- a/src/blockchains/AGENTS.md +++ b/src/blockchains/AGENTS.md @@ -9,7 +9,8 @@ Lazy-loaded class modules. Each file exports a named concrete class and the same | Family | Chains | Signing | Key Derivation | | -------------------- | ---------------------- | ------------------------------------------------- | ----------------------------------- | | **EVM** | ethereum, base | `evmSignMessage` (preamble + keccak256) | secp256k1 via `utils/secp256k1` | -| **secp256k1 custom** | bitcoin, tron | `evmSignMessage` (same signing, custom addresses) | secp256k1 via `utils/secp256k1` | +| **Bitcoin family** | bitcoin, litecoin | chain-specific message preamble | secp256k1 via `utils/secp256k1` | +| **secp256k1 custom** | tron | `evmSignMessage` (same signing, custom addresses) | secp256k1 via `utils/secp256k1` | | **ed25519** | solana, aptos, cardano | `ed25519SignMessage` (raw, no prehash) | ed25519 via `utils/ed25519` | | **dual-curve** | sui | both (selected via `options.scheme`) | ed25519 default, secp256k1 optional | @@ -24,11 +25,12 @@ Lazy-loaded class modules. Each file exports a named concrete class and the same ## PATTERNS +- **Bitcoin base class** - `AbstractBitcoinBlockchain` in `utils/bitcoin.ts` shares address generation and HD purpose inference. Litecoin signs its Core digest with noble prehash disabled and accepts both P2SH prefix generations. - **EVM base class** - `Ethereum` and `Base` extend `AbstractEVMBlockchain`, which owns their shared key, address, validation, and signing behavior -- **Network params** - Bitcoin and Cardano keep separate address parameters for each network in `NETWORK_PARAMS`; TRON uses `0x41` and `T` on mainnet, Shasta, and Nile +- **Network params** - Bitcoin, Litecoin, and Cardano keep separate address parameters for each network in `NETWORK_PARAMS`; TRON uses `0x41` and `T` on mainnet, Shasta, and Nile - **BIP44 coin type** - every chain sets `bip44` from `BIP44` enum or SLIP-0044 number - **SUI dual-curve** - `getKeyPublic` and `signMessage` check `options.scheme` to pick ed25519 or secp256k1 -- **HD wallets** - `deriveHDWallet` on the base class walks BIP32 or SLIP-10 by curve; Bitcoin infers the address type from the path purpose, Sui takes the curve from the scheme, Cardano throws because CIP-1852 derives differently +- **HD wallets** - `deriveHDWallet` on the base class walks BIP32 or SLIP-10 by curve; Bitcoin and Litecoin infer the address type from the path purpose, Sui takes the curve from the scheme, Cardano throws because CIP-1852 derives differently ## COMPLEXITY diff --git a/src/blockchains/bitcoin.ts b/src/blockchains/bitcoin.ts index 99edae3..f0a262e 100644 --- a/src/blockchains/bitcoin.ts +++ b/src/blockchains/bitcoin.ts @@ -1,34 +1,4 @@ -import { sha256 } from "@noble/hashes/sha2.js"; -import { AbstractBlockchain } from "../blockchain.ts"; -import { - generateAddressLegacy, - generateAddressP2SH, - generateAddressSegWit, - validateAddressLegacy, - validateAddressP2SH, - validateAddressSegWit, -} from "../utils/address.ts"; -import { generateKeyPublic } from "../utils/secp256k1.ts"; -import { - signMessage as genericSignMessage, - verifyMessage as genericVerifyMessage, -} from "../utils/signing.ts"; -import type { - AddressType, - BitcoinAddressType, - Curve, - HDWalletOptions, - KeyOptions, - Wallet, -} from "../types.ts"; - -/** BIP43 purpose levels and the address format each one stands for. */ -const PURPOSE_ADDRESS_TYPES: Readonly> = { - "44": "legacy", - "49": "p2sh", - "84": "segwit", - "86": "taproot", -}; +import { AbstractBitcoinBlockchain } from "../utils/bitcoin.ts"; const NETWORK_PARAMS = { mainnet: { @@ -45,190 +15,15 @@ const NETWORK_PARAMS = { }, } as const; -type NetworkParams = (typeof NETWORK_PARAMS)[keyof typeof NETWORK_PARAMS]; - -/** - * Validates a bech32 address as taproot (v1) or SegWit v0, dispatched on its prefix. - * @param address - The bech32 address to validate - * @param params - Network parameters providing the human-readable part and v1 prefix - * @returns {boolean} Whether the address is a valid SegWit v0 or v1 address - */ -function validateAddressBech32(address: string, params: NetworkParams): boolean { - if (address.toLowerCase().startsWith(params.prefixSegWitV1)) { - return validateAddressSegWit(address, { - hrp: params.hrpSegWit, - witnessVersion: 1, - }); - } - return validateAddressSegWit(address, { - hrp: params.hrpSegWit, - witnessVersion: 0, - }); -} - -/** - * Validates a base58 mainnet address as P2SH ("3") or legacy P2PKH ("1"). - * @param address - The base58 address to validate - * @param params - Network parameters providing the version bytes - * @returns {boolean} Whether the address is a valid mainnet P2SH or legacy address - */ -function validateAddressBase58Mainnet(address: string, params: NetworkParams): boolean { - if (address.startsWith("3")) { - return validateAddressP2SH(address, { bytesVersion: params.bytesVersionP2SH }); - } - if (address.startsWith("1")) { - return validateAddressLegacy(address, { bytesVersion: params.bytesVersionP2PKH }); - } - return false; -} - -/** - * Validates a base58 testnet address as P2SH ("2") or legacy P2PKH ("m"/"n"). - * @param address - The base58 address to validate - * @param params - Network parameters providing the version bytes - * @returns {boolean} Whether the address is a valid testnet P2SH or legacy address - */ -function validateAddressBase58Testnet(address: string, params: NetworkParams): boolean { - if (address.startsWith("2")) { - return validateAddressP2SH(address, { bytesVersion: params.bytesVersionP2SH }); - } - if (address.startsWith("m") || address.startsWith("n")) { - return validateAddressLegacy(address, { bytesVersion: params.bytesVersionP2PKH }); - } - return false; -} - -function encodeCompactSize(value: number): Uint8Array { - if (value < 0xfd) return new Uint8Array([value]); - if (value <= 0xffff) { - const buffer = new Uint8Array(3); - buffer[0] = 0xfd; - buffer[1] = value & 0xff; - buffer[2] = (value >> 8) & 0xff; - return buffer; - } - - const buffer = new Uint8Array(5); - buffer[0] = 0xfe; - buffer[1] = value & 0xff; - buffer[2] = (value >> 8) & 0xff; - buffer[3] = (value >> 16) & 0xff; - buffer[4] = (value >> 24) & 0xff; - return buffer; -} - /** Bitcoin blockchain implementation. */ -export class Bitcoin extends AbstractBlockchain { +export class Bitcoin extends AbstractBitcoinBlockchain { override readonly name = "bitcoin"; - override readonly curve: Curve = "secp256k1"; override readonly bip44 = 0; + protected override readonly messagePreamble = "\u0018Bitcoin Signed Message:\n"; - private get params() { + protected override get params() { return this.network === "testnet" ? NETWORK_PARAMS.testnet : NETWORK_PARAMS.mainnet; } - - override getKeyPublic(keyPrivate: string, options?: KeyOptions): string { - return generateKeyPublic(keyPrivate, options); - } - - override getAddress(keyPublic: string, type = "legacy"): string { - if (["segwit", "p2wsh", "taproot"].includes(type)) { - const segwitOptions = { - hrp: this.params.hrpSegWit, - witnessVersion: type === "taproot" ? 1 : 0, - }; - const segwitType = type === "p2wsh" ? "p2wsh" : "p2wpkh"; - return generateAddressSegWit(keyPublic, segwitOptions, segwitType); - } - - if (type === "p2sh") { - return generateAddressP2SH(keyPublic, { - bytesVersion: this.params.bytesVersionP2SH, - }); - } - - return generateAddressLegacy(keyPublic, { - bytesVersion: this.params.bytesVersionP2PKH, - }); - } - - /** - * Without an explicit type the path purpose picks the format, so `m/84'/...` lands on bc1q, not on a legacy `1`. - * @param mnemonic - English BIP39 mnemonic - * @param path - Derivation path such as `m/84'/0'/0'/0/0` - * @param options - Key options plus an optional BIP39 passphrase - * @param addressType - Explicit address type that wins over the purpose - * @returns {Wallet} The wallet at the path - */ - override deriveHDWallet( - mnemonic: string, - path: string, - options?: HDWalletOptions, - addressType?: AddressType, - ): Wallet { - const purpose = /^[mM]'?\/(\d+)'/u.exec(path)?.[1]; - const inferredType = purpose === undefined ? undefined : PURPOSE_ADDRESS_TYPES[Number(purpose)]; - return super.deriveHDWallet(mnemonic, path, options, addressType ?? inferredType); - } - - override validateAddress(address: string): boolean { - const segwitPrefix = this.params.hrpSegWit + "1"; - if (address.toLowerCase().startsWith(segwitPrefix)) { - return validateAddressBech32(address, this.params); - } - - if (this.network === "mainnet") { - return validateAddressBase58Mainnet(address, this.params); - } - if (this.network === "testnet") { - return validateAddressBase58Testnet(address, this.params); - } - return false; - } - - private hashWithBitcoinPreamble(message: string | Uint8Array): Uint8Array { - const preambleBytes = new TextEncoder().encode("\u0018Bitcoin Signed Message:\n"); - const messageBytes = typeof message === "string" ? new TextEncoder().encode(message) : message; - const messageLength = encodeCompactSize(messageBytes.length); - const fullMessage = new Uint8Array( - preambleBytes.length + messageLength.length + messageBytes.length, - ); - fullMessage.set(preambleBytes); - fullMessage.set(messageLength, preambleBytes.length); - fullMessage.set(messageBytes, preambleBytes.length + messageLength.length); - return sha256(sha256(fullMessage)); - } - - override signMessage( - message: string | Uint8Array, - keyPrivate: string, - options?: KeyOptions, - ): string { - const hash = this.hashWithBitcoinPreamble(message); - return genericSignMessage(hash, keyPrivate, { - ...options, - curve: "secp256k1", - hash: false, - }); - } - - override verifyMessage( - message: string | Uint8Array, - signature: string, - keyPublic: string, - options?: KeyOptions, - ): boolean { - const hash = this.hashWithBitcoinPreamble(message); - try { - return genericVerifyMessage(hash, signature, keyPublic, { - ...options, - curve: "secp256k1", - hash: false, - }); - } catch { - return false; - } - } } export default Bitcoin; diff --git a/src/blockchains/litecoin.ts b/src/blockchains/litecoin.ts new file mode 100644 index 0000000..6b801dc --- /dev/null +++ b/src/blockchains/litecoin.ts @@ -0,0 +1,87 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { AbstractBitcoinBlockchain } from "../utils/bitcoin.ts"; +import { validateAddressLegacy, validateAddressP2SH } from "../utils/address.ts"; +import { BIP44 } from "../utils/bip44/index.ts"; +import type { Options } from "../types.ts"; + +/** Litecoin Core v0.21.4 src/chainparams.cpp, including the newer P2SH prefixes. */ +const NETWORK_PARAMS = { + mainnet: { + hrpSegWit: "ltc", + prefixSegWitV1: "ltc1p", + bytesVersionP2PKH: 0x30, + bytesVersionP2SH: 0x32, + }, + testnet: { + hrpSegWit: "tltc", + prefixSegWitV1: "tltc1p", + bytesVersionP2PKH: 0x6f, + bytesVersionP2SH: 0x3a, + }, +} as const; + +/** Litecoin transparent addresses and message signatures. MWEB is not supported. */ +export class Litecoin extends AbstractBitcoinBlockchain { + override readonly name = "litecoin"; + override readonly bip44 = BIP44.LITECOIN; + protected override readonly messagePreamble = "\u0019Litecoin Signed Message:\n"; + + constructor(options?: Options) { + super(options); + if (this.network !== "mainnet" && this.network !== "testnet") { + throw new RangeError("Litecoin supports mainnet and testnet only"); + } + } + + protected override get params() { + return this.network === "testnet" ? NETWORK_PARAMS.testnet : NETWORK_PARAMS.mainnet; + } + + override getAddress(keyPublic: string, type = "legacy"): string { + secp256k1.Point.fromHex(keyPublic); + return super.getAddress(keyPublic, type); + } + + override signMessage(message: string | Uint8Array, keyPrivate: string): string { + return bytesToHex( + secp256k1.sign(this.hashWithMessagePreamble(message), hexToBytes(keyPrivate), { + prehash: false, + }), + ); + } + + override verifyMessage( + message: string | Uint8Array, + signature: string, + keyPublic: string, + ): boolean { + try { + return secp256k1.verify( + hexToBytes(signature), + this.hashWithMessagePreamble(message), + hexToBytes(keyPublic), + { + prehash: false, + }, + ); + } catch { + return false; + } + } + + override validateAddress(address: string): boolean { + if (address.toLowerCase().startsWith(this.params.hrpSegWit + "1")) { + return super.validateAddress(address); + } + return ( + validateAddressLegacy(address, { bytesVersion: this.params.bytesVersionP2PKH }) || + validateAddressP2SH(address, { bytesVersion: this.params.bytesVersionP2SH }) || + validateAddressP2SH(address, { + bytesVersion: this.network === "testnet" ? 0xc4 : 0x05, + }) + ); + } +} + +export default Litecoin; diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 4eaaf48..da0f1fc 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -242,6 +242,10 @@ const BLOCKCHAIN_LOADERS: ReadonlyArray<{ name: "bitcoin", load: async (network) => useBlockchain(await blockchains.bitcoin({ network })()), }, + { + name: "litecoin", + load: async (network) => useBlockchain(await blockchains.litecoin({ network })()), + }, { name: "ethereum", load: async (network) => useBlockchain(await blockchains.ethereum({ network })()), diff --git a/src/tool-parameters.ts b/src/tool-parameters.ts index 793c4bf..20ac65a 100644 --- a/src/tool-parameters.ts +++ b/src/tool-parameters.ts @@ -16,6 +16,7 @@ export const BIP44_PATH_MODE_SCHEMA = { /** Every blockchain exposed by the tool surfaces. */ export const TOOL_CHAINS = [ "bitcoin", + "litecoin", "ethereum", "base", "solana", @@ -48,6 +49,7 @@ export const TOOL_ADDRESS_TYPES = [ /** Address types accepted for each tool chain. */ export const TOOL_ADDRESS_TYPES_BY_CHAIN: Readonly> = { bitcoin: BITCOIN_ADDRESS_TYPES, + litecoin: BITCOIN_ADDRESS_TYPES, ethereum: [], base: [], solana: [], diff --git a/src/utils/AGENTS.md b/src/utils/AGENTS.md index dfad245..236cba9 100644 --- a/src/utils/AGENTS.md +++ b/src/utils/AGENTS.md @@ -8,17 +8,18 @@ Shared cryptographic primitives and encoding utilities. BIP32, BIP39, and SLIP-1 **Plain files** (imported directly by blockchains): -| File | Lines | Used By | Purpose | -| ------------------- | ----- | -------------------------------- | ---------------------------------------------------------------------------------- | -| `address.ts` | 312 | bitcoin, sui, aptos, tron | hash160, legacy/P2SH/SegWit address gen + validation, hex address validation | -| `evm.ts` | ~220 | EVM classes and secp256k1 chains | EVM address generation, EIP-55 checksum, preamble signing, `AbstractEVMBlockchain` | -| `signing.ts` | ~100 | evm.ts, ed25519-chains.ts | Generic sign/verify dispatching by curve type | -| `ed25519-chains.ts` | ~50 | solana, aptos, cardano, sui | Shared raw Ed25519 signing and verification | -| `secp256k1.ts` | ~100 | bitcoin, tron, sui, evm.ts | Public key generation (compressed/uncompressed) | -| `ed25519.ts` | ~50 | solana, aptos, cardano, sui | Ed25519 public key generation | -| `encoding.ts` | ~60 | address.ts, tron | Base58Check encode/decode/validate | -| `crypto-hash.ts` | ~70 | (internal) | Hash function wrappers | -| `hd.ts` | ~45 | blockchain.ts | Mnemonic to private key at a path: BIP32 for secp256k1, SLIP-10 for ed25519 | +| File | Lines | Used By | Purpose | +| ------------------- | ----- | -------------------------------- | -------------------------------------------------------------------------------------- | +| `address.ts` | 312 | bitcoin, sui, aptos, tron | hash160, legacy/P2SH/SegWit address gen + validation, hex address validation | +| `bitcoin.ts` | | bitcoin, litecoin | Shared transparent address generation, HD purpose inference, and message serialization | +| `evm.ts` | ~220 | EVM classes and secp256k1 chains | EVM address generation, EIP-55 checksum, preamble signing, `AbstractEVMBlockchain` | +| `signing.ts` | ~100 | evm.ts, ed25519-chains.ts | Generic sign/verify dispatching by curve type | +| `ed25519-chains.ts` | ~50 | solana, aptos, cardano, sui | Shared raw Ed25519 signing and verification | +| `secp256k1.ts` | ~100 | bitcoin, tron, sui, evm.ts | Public key generation (compressed/uncompressed) | +| `ed25519.ts` | ~50 | solana, aptos, cardano, sui | Ed25519 public key generation | +| `encoding.ts` | ~60 | address.ts, tron | Base58Check encode/decode/validate | +| `crypto-hash.ts` | ~70 | (internal) | Hash function wrappers | +| `hd.ts` | ~45 | blockchain.ts | Mnemonic to private key at a path: BIP32 for secp256k1, SLIP-10 for ed25519 | **Subdirectories** (each has `index.ts`): diff --git a/src/utils/bip44/index.ts b/src/utils/bip44/index.ts index b5d5ece..88d2739 100644 --- a/src/utils/bip44/index.ts +++ b/src/utils/bip44/index.ts @@ -50,6 +50,7 @@ export enum BIP44Change { export const BIP44 = { BITCOIN: 0, TESTNET: 1, + LITECOIN: 2, ETHEREUM: 60, SOLANA: 501, CARDANO: 1815, diff --git a/src/utils/bitcoin.ts b/src/utils/bitcoin.ts new file mode 100644 index 0000000..8106f52 --- /dev/null +++ b/src/utils/bitcoin.ts @@ -0,0 +1,218 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { AbstractBlockchain } from "../blockchain.ts"; +import { + generateAddressLegacy, + generateAddressP2SH, + generateAddressSegWit, + validateAddressLegacy, + validateAddressP2SH, + validateAddressSegWit, +} from "./address.ts"; +import { generateKeyPublic } from "./secp256k1.ts"; +import { + signMessage as genericSignMessage, + verifyMessage as genericVerifyMessage, +} from "./signing.ts"; +import type { + AddressType, + BitcoinAddressType, + Curve, + HDWalletOptions, + KeyOptions, + Wallet, +} from "../types.ts"; + +/** BIP43 purpose levels and the address format each one stands for. */ +const PURPOSE_ADDRESS_TYPES: Readonly> = { + "44": "legacy", + "49": "p2sh", + "84": "segwit", + "86": "taproot", +}; + +interface NetworkParams { + readonly hrpSegWit: string; + readonly prefixSegWitV1: string; + readonly bytesVersionP2PKH: number; + readonly bytesVersionP2SH: number; +} + +/** + * Validates a bech32 address as taproot (v1) or SegWit v0, dispatched on its prefix. + * @param address - The bech32 address to validate + * @param params - Network parameters providing the human readable part and v1 prefix + * @returns {boolean} Whether the address is a valid SegWit v0 or v1 address + */ +function validateAddressBech32(address: string, params: NetworkParams): boolean { + if (address.toLowerCase().startsWith(params.prefixSegWitV1)) { + return validateAddressSegWit(address, { + hrp: params.hrpSegWit, + witnessVersion: 1, + }); + } + return validateAddressSegWit(address, { + hrp: params.hrpSegWit, + witnessVersion: 0, + }); +} + +/** + * Validates a base58 mainnet address as P2SH ("3") or legacy P2PKH ("1"). + * @param address - The base58 address to validate + * @param params - Network parameters providing the version bytes + * @returns {boolean} Whether the address is a valid mainnet P2SH or legacy address + */ +function validateAddressBase58Mainnet(address: string, params: NetworkParams): boolean { + if (address.startsWith("3")) { + return validateAddressP2SH(address, { bytesVersion: params.bytesVersionP2SH }); + } + if (address.startsWith("1")) { + return validateAddressLegacy(address, { bytesVersion: params.bytesVersionP2PKH }); + } + return false; +} + +/** + * Validates a base58 testnet address as P2SH ("2") or legacy P2PKH ("m"/"n"). + * @param address - The base58 address to validate + * @param params - Network parameters providing the version bytes + * @returns {boolean} Whether the address is a valid testnet P2SH or legacy address + */ +function validateAddressBase58Testnet(address: string, params: NetworkParams): boolean { + if (address.startsWith("2")) { + return validateAddressP2SH(address, { bytesVersion: params.bytesVersionP2SH }); + } + if (address.startsWith("m") || address.startsWith("n")) { + return validateAddressLegacy(address, { bytesVersion: params.bytesVersionP2PKH }); + } + return false; +} + +function encodeCompactSize(value: number): Uint8Array { + if (value < 0xfd) return new Uint8Array([value]); + if (value <= 0xffff) { + const buffer = new Uint8Array(3); + buffer[0] = 0xfd; + buffer[1] = value & 0xff; + buffer[2] = (value >> 8) & 0xff; + return buffer; + } + + const buffer = new Uint8Array(5); + buffer[0] = 0xfe; + buffer[1] = value & 0xff; + buffer[2] = (value >> 8) & 0xff; + buffer[3] = (value >> 16) & 0xff; + buffer[4] = (value >> 24) & 0xff; + return buffer; +} + +/** Shared transparent address and HD behavior for Bitcoin and Litecoin. */ +export abstract class AbstractBitcoinBlockchain extends AbstractBlockchain { + override readonly curve: Curve = "secp256k1"; + protected abstract readonly messagePreamble: string; + protected abstract get params(): NetworkParams; + + override getKeyPublic(keyPrivate: string, options?: KeyOptions): string { + return generateKeyPublic(keyPrivate, options); + } + + override getAddress(keyPublic: string, type = "legacy"): string { + if (["segwit", "p2wsh", "taproot"].includes(type)) { + const segwitOptions = { + hrp: this.params.hrpSegWit, + witnessVersion: type === "taproot" ? 1 : 0, + }; + const segwitType = type === "p2wsh" ? "p2wsh" : "p2wpkh"; + return generateAddressSegWit(keyPublic, segwitOptions, segwitType); + } + + if (type === "p2sh") { + return generateAddressP2SH(keyPublic, { + bytesVersion: this.params.bytesVersionP2SH, + }); + } + + return generateAddressLegacy(keyPublic, { + bytesVersion: this.params.bytesVersionP2PKH, + }); + } + + /** + * Path purpose selects the address format unless a type is provided. + * @param mnemonic - English BIP39 mnemonic + * @param path - Derivation path such as `m/84'/0'/0'/0/0` + * @param options - Key options plus an optional BIP39 passphrase + * @param addressType - Explicit address type that wins over the purpose + * @returns {Wallet} The wallet at the path + */ + override deriveHDWallet( + mnemonic: string, + path: string, + options?: HDWalletOptions, + addressType?: AddressType, + ): Wallet { + const purpose = /^[mM]'?\/(\d+)'/u.exec(path)?.[1]; + const inferredType = purpose === undefined ? undefined : PURPOSE_ADDRESS_TYPES[Number(purpose)]; + return super.deriveHDWallet(mnemonic, path, options, addressType ?? inferredType); + } + + override validateAddress(address: string): boolean { + const segwitPrefix = this.params.hrpSegWit + "1"; + if (address.toLowerCase().startsWith(segwitPrefix)) { + return validateAddressBech32(address, this.params); + } + + if (this.network === "mainnet") { + return validateAddressBase58Mainnet(address, this.params); + } + if (this.network === "testnet") { + return validateAddressBase58Testnet(address, this.params); + } + return false; + } + + protected hashWithMessagePreamble(message: string | Uint8Array): Uint8Array { + const preambleBytes = new TextEncoder().encode(this.messagePreamble); + const messageBytes = typeof message === "string" ? new TextEncoder().encode(message) : message; + const messageLength = encodeCompactSize(messageBytes.length); + const fullMessage = new Uint8Array( + preambleBytes.length + messageLength.length + messageBytes.length, + ); + fullMessage.set(preambleBytes); + fullMessage.set(messageLength, preambleBytes.length); + fullMessage.set(messageBytes, preambleBytes.length + messageLength.length); + return sha256(sha256(fullMessage)); + } + + override signMessage( + message: string | Uint8Array, + keyPrivate: string, + options?: KeyOptions, + ): string { + const hash = this.hashWithMessagePreamble(message); + return genericSignMessage(hash, keyPrivate, { + ...options, + curve: "secp256k1", + hash: false, + }); + } + + override verifyMessage( + message: string | Uint8Array, + signature: string, + keyPublic: string, + options?: KeyOptions, + ): boolean { + const hash = this.hashWithMessagePreamble(message); + try { + return genericVerifyMessage(hash, signature, keyPublic, { + ...options, + curve: "secp256k1", + hash: false, + }); + } catch { + return false; + } + } +} diff --git a/test/blockchains/litecoin.test.ts b/test/blockchains/litecoin.test.ts new file mode 100644 index 0000000..776bda5 --- /dev/null +++ b/test/blockchains/litecoin.test.ts @@ -0,0 +1,140 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { base58check, bech32, bech32m } from "@scure/base"; +import { HDNodeWallet } from "ethers"; +import { describe, expect, it } from "vitest"; +import { blockchains, getBlockchainPath } from "../../src/index.ts"; +import Bitcoin from "../../src/blockchains/bitcoin.ts"; +import Litecoin, { Litecoin as NamedLitecoin } from "../../src/blockchains/litecoin.ts"; +import { + bip39TestVectors, + bitcoinTestVectors, + litecoinTestVectors as vector, +} from "../fixtures.ts"; + +const base58 = base58check(sha256); +const formats = ["legacy", "p2sh", "segwit", "p2wsh", "taproot"] as const; + +describe("Litecoin", () => { + it("loads through the public registry with SLIP-0044 coin type 2", async () => { + const chain = await blockchains.litecoin()(); + expect(chain).toBeInstanceOf(NamedLitecoin); + expect(chain.name).toBe("litecoin"); + expect(chain.curve).toBe("secp256k1"); + expect(getBlockchainPath(chain, 0, 1, 3)).toBe("m/44'/2'/0'/1/3"); + expect(chain.getKeyPublic(vector.privateKey)).toBe(vector.publicKey); + expect(chain.deriveWallet(vector.privateKey).address).toBe(vector.address); + expect(() => new Litecoin({ network: "regtest" })).toThrow("mainnet and testnet only"); + }); + + for (const [network, p2pkh, p2sh, hrp] of [ + ["mainnet", 48, 50, "ltc"], + ["testnet", 111, 58, "tltc"], + ] as const) { + const chain = new Litecoin({ network }); + const otherNetwork = new Litecoin({ network: network === "mainnet" ? "testnet" : "mainnet" }); + + it(`${network} encodes the Core network prefixes and witness programs`, () => { + const legacy = base58.decode(chain.getAddress(vector.publicKey)); + expect(legacy[0]).toBe(p2pkh); + expect(bytesToHex(legacy.slice(1))).toBe(vector.publicKeyHash); + const nested = base58.decode(chain.getAddress(vector.publicKey, "p2sh")); + expect(nested[0]).toBe(p2sh); + expect(nested.length).toBe(21); + for (const type of ["segwit", "p2wsh", "taproot"] as const) { + const codec = type === "taproot" ? bech32m : bech32; + const decoded = codec.decode(chain.getAddress(vector.publicKey, type)); + expect(decoded.prefix).toBe(hrp); + expect(decoded.words[0]).toBe(type === "taproot" ? 1 : 0); + expect(codec.fromWords(decoded.words.slice(1)).length).toBe(type === "segwit" ? 20 : 32); + if (type === "segwit") { + expect(bytesToHex(codec.fromWords(decoded.words.slice(1)))).toBe(vector.publicKeyHash); + } + } + }); + + it.each(formats)( + `${network} validates %s and rejects corruption and the other network`, + (type) => { + const address = chain.getAddress(vector.publicKey, type); + expect(chain.validateAddress(address)).toBe(true); + expect(otherNetwork.validateAddress(address)).toBe(false); + expect( + chain.validateAddress(address.slice(0, -1) + (address.endsWith("q") ? "p" : "q")), + ).toBe(false); + if (address.startsWith(hrp + "1")) { + expect(chain.validateAddress(address.toUpperCase())).toBe(true); + expect(chain.validateAddress(address[0]!.toUpperCase() + address.slice(1))).toBe(false); + } + }, + ); + + it(`${network} accepts the old P2SH prefix without generating it`, () => { + expect(chain.validateAddress(bitcoinTestVectors.addresses.p2sh[network])).toBe(true); + expect(chain.getAddress(vector.publicKey, "p2sh")).not.toMatch(/^[32]/); + }); + } + + it("rejects Bitcoin mainnet and witness addresses, malformed keys, and MWEB", () => { + const chain = new Litecoin(); + const bitcoin = new Bitcoin(); + for (const type of ["legacy", "segwit", "taproot"]) { + expect(chain.validateAddress(bitcoin.getAddress(vector.publicKey, type))).toBe(false); + } + for (const address of ["", "L".repeat(34), "ltcmweb1invalid"]) { + expect(chain.validateAddress(address)).toBe(false); + } + expect(() => chain.getAddress("02")).toThrow(); + expect(() => chain.deriveWallet("00".repeat(32))).toThrow(); + }); + + it.each([ + [44, "legacy"], + [49, "p2sh"], + [84, "segwit"], + [86, "taproot"], + ] as const)( + "derives purpose %s through BIP32 and preserves an explicit address type", + (purpose, format) => { + const chain = new Litecoin(); + const path = `m/${purpose}'/2'/0'/1/2`; + const wallet = chain.deriveHDWallet(bip39TestVectors.mnemonic, path, { + passphrase: "TREZOR", + }); + const independent = HDNodeWallet.fromPhrase(bip39TestVectors.mnemonic, "TREZOR", path); + expect(wallet.keys.private).toBe(independent.privateKey.slice(2)); + expect(wallet.address).toBe(chain.getAddress(independent.publicKey.slice(2), format)); + expect( + chain.deriveHDWallet(bip39TestVectors.mnemonic, path, { passphrase: "TREZOR" }, "legacy") + .address, + ).toBe(chain.getAddress(independent.publicKey.slice(2), "legacy")); + }, + ); + + it.each(vector.messageHashes)( + "signs Core message vector %# without another hash", + (message, digest) => { + const chain = new Litecoin(); + 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, + new Bitcoin().signMessage(message, vector.privateKey), + vector.publicKey, + ), + ).toBe(false); + }, + ); +}); diff --git a/test/fixtures.ts b/test/fixtures.ts index 7055586..f0d777a 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -56,3 +56,15 @@ export const testMessages = { medium: "This is a longer test message for cryptographic signing operations", long: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit.", }; + +/** Disposable key 1 and Litecoin Core message hashes, independently serialized and SHA256d hashed. */ +export const litecoinTestVectors = { + privateKey: "0000000000000000000000000000000000000000000000000000000000000001", + publicKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + publicKeyHash: "751e76e8199196d454941c45d1b3a323f1433bd6", + address: "LVuDpNCSSj6pQ7t9Pv6d6sUkLKoqDEVUnJ", + messageHashes: [ + ["hello", "51bd869e89676860cf1d778b8735f5e6768da32023d3dcd951711bd21c669d4c"], + ["é".repeat(127), "08bebd99b9d1fbd73231de22e544e9b0b75c0c54ab6e3128f53665cdf944477f"], + ], +} as const; diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 9236a90..87d2dd9 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,6 +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 { createMcpServer } from "../src/mcp.ts"; const TOOL_NAMES = [ @@ -86,6 +87,17 @@ describe("keys MCP server", () => { expect(text(response.content)).not.toContain(mnemonic); }); + it("derives Litecoin through the MCP schema and executor", async () => { + const client = await connectTestClient(); + const response = await client.callTool({ + name: "keys_derive_wallet", + arguments: { chain: "litecoin", privateKey: litecoinTestVectors.privateKey }, + }); + expect(response.isError).not.toBe(true); + expect(text(response.content)).toContain(litecoinTestVectors.address); + expect(text(response.content)).not.toContain(litecoinTestVectors.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 18e6e0f..87d72a9 100644 --- a/test/pi-extension.test.ts +++ b/test/pi-extension.test.ts @@ -2,6 +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 keysExtension from "../packages/pi/extensions/keys.ts"; interface RegisteredTool { @@ -32,6 +33,20 @@ function registerTools(): ReadonlyMap { } describe("keys Pi extension", () => { + 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"); + const args = { chain: "litecoin", privateKey: litecoinTestVectors.privateKey }; + expect(Value.Check(tool.parameters, args)).toBe(true); + const result = await tool.execute("litecoin", args); + expect(result.content).toEqual([ + { + type: "text", + text: `Public key: ${litecoinTestVectors.publicKey}\nAddress: ${litecoinTestVectors.address}`, + }, + ]); + }); + it("rejects unsupported networks and address types on every relevant tool", async () => { const tools = registerTools(); const networkCases = [