diff --git a/packages/wasm-utxo/js/README.md b/packages/wasm-utxo/js/README.md index 79762dbf..326ea732 100644 --- a/packages/wasm-utxo/js/README.md +++ b/packages/wasm-utxo/js/README.md @@ -6,42 +6,61 @@ generated by the `wasm-pack` command (which uses `wasm-bindgen`). While the `wasm-bindgen` crate allows some customization of the emitted type signatures, it is a bit painful to use and has certain limitations that cannot be easily worked around. -## Architecture Pattern +## Architecture Patterns -This directory implements a **namespace wrapper pattern** that provides a cleaner, more -type-safe API over the raw WASM bindings. +This directory implements two complementary patterns to provide cleaner, more type-safe APIs over the raw WASM bindings: -### Pattern Overview +1. **Namespace Wrapper Pattern** - For static utility functions +2. **Class Wrapper Pattern** - For stateful objects with methods + +### Common Elements 1. **WASM Generation** (`wasm/wasm_utxo.d.ts`) - Generated by `wasm-bindgen` from Rust code - - Exports classes with static methods (e.g., `AddressNamespace`, `UtxolibCompatNamespace`) - - Uses `snake_case` naming (Rust convention) + - Uses `snake_case` naming (Rust convention) - **no `js_name` overrides in Rust** - Uses loose types (`any`, `string | null`) due to WASM-bindgen limitations + - TypeScript wrapper layer handles conversion to `camelCase` -2. **Namespace Wrapper Files** (e.g., `address.ts`, `utxolibCompat.ts`, `fixedScriptWallet.ts`) - - - Import the generated WASM namespace classes - - Define precise TypeScript types to replace `any` types - - Export individual functions that wrap the static WASM methods - - Convert `snake_case` WASM methods to `camelCase` (JavaScript convention) - - Re-export related types for convenience - -3. **Shared Type Files** (e.g., `coinName.ts`, `triple.ts`) +2. **Shared Type Files** (e.g., `coinName.ts`, `triple.ts`) - Define common types used across multiple modules - Single source of truth to avoid duplication - Imported by wrapper files as needed -4. **Main Entry Point** (`index.ts`) +3. **Main Entry Point** (`index.ts`) - Uses `export * as` to group related functionality into namespaces - - Re-exports shared types for top-level access + - Re-exports shared types and classes for top-level access - Augments WASM types with additional TypeScript declarations -### Example +### Pattern 1: Namespace Wrapper Pattern + +Used for static utility functions (e.g., `address.ts`, `utxolibCompat.ts`). + +**Characteristics:** + +- Import the generated WASM namespace classes +- Define precise TypeScript types to replace `any` types +- Export individual functions that wrap the static WASM methods +- Convert `snake_case` WASM methods to `camelCase` (JavaScript convention) +- Re-export related types for convenience + +### Pattern 2: Class Wrapper Pattern + +Used for stateful objects that maintain WASM instances (e.g., `BIP32`, `RootWalletKeys`, `BitGoPsbt`). + +**Characteristics:** -Given a WASM-generated class: +- Private `_wasm` property holds the underlying WASM instance +- Private constructor prevents direct instantiation +- Static factory methods (camelCase) for object creation +- Instance methods (camelCase) wrap WASM methods and return wrapped instances when appropriate +- Public `wasm` getter for internal access to WASM instance (marked `@internal`) +- Implements interfaces to ensure compatibility with existing code + +### Example 1: Namespace Wrapper Pattern + +Given a WASM-generated namespace class: ```typescript // wasm/wasm_utxo.d.ts (generated by wasm-bindgen) @@ -88,10 +107,110 @@ And expose it via the main entry point: export * as address from "./address"; ``` +### Example 2: Class Wrapper Pattern + +Given a WASM-generated class with instance methods: + +```typescript +// wasm/wasm_utxo.d.ts (generated by wasm-bindgen) +export class WasmBIP32 { + private constructor(); + // Note: snake_case naming from Rust (no js_name overrides) + static from_base58(base58_str: string): WasmBIP32; + derive(index: number): WasmBIP32; + derive_path(path: string): WasmBIP32; + to_base58(): string; + readonly public_key: Uint8Array; +} +``` + +We create a wrapper class that encapsulates the WASM instance: + +```typescript +// bip32.ts +import { WasmBIP32 } from "./wasm/wasm_utxo"; + +export class BIP32 { + // Private property with underscore prefix + private constructor(private _wasm: WasmBIP32) {} + + // Static factory method (camelCase) calls snake_case WASM method + static fromBase58(base58Str: string): BIP32 { + const wasm = WasmBIP32.from_base58(base58Str); + return new BIP32(wasm); + } + + // Property getter (camelCase) accesses snake_case WASM property + get publicKey(): Uint8Array { + return this._wasm.public_key; + } + + // Instance method (camelCase) returns wrapped instance + derive(index: number): BIP32 { + const wasm = this._wasm.derive(index); + return new BIP32(wasm); + } + + // Convert snake_case to camelCase + derivePath(path: string): BIP32 { + const wasm = this._wasm.derive_path(path); + return new BIP32(wasm); + } + + // Convert snake_case to camelCase + toBase58(): string { + return this._wasm.to_base58(); + } + + // Public getter for internal use (marked @internal) + /** + * @internal + */ + get wasm(): WasmBIP32 { + return this._wasm; + } +} +``` + +And expose it directly: + +```typescript +// index.ts +export { BIP32 } from "./bip32"; +``` + ### Benefits +**Common to Both Patterns:** + - **Type Safety**: Replace loose `any` and `string` types with precise union types -- **Idiomatic Naming**: Each layer uses its native convention (`snake_case` in Rust, `camelCase` in JavaScript) +- **Idiomatic Naming**: Each layer uses its native convention (`snake_case` in Rust/WASM, `camelCase` in TypeScript/JavaScript) + - Rust exports use `snake_case` (no `js_name` overrides) + - TypeScript wrappers provide `camelCase` API - **Better DX**: IDE autocomplete works better with concrete types and familiar naming - **Maintainability**: Centralized type definitions prevent duplication - **Clear Separation**: WASM bindings stay pure to Rust conventions, TypeScript handles JS conventions + +**Class Wrapper Pattern Specific:** + +- **Encapsulation**: Private `_wasm` property hides implementation details +- **Controlled Access**: Private constructor forces use of factory methods +- **Consistent Returns**: Methods that return new instances automatically wrap them +- **Internal Access**: Public `wasm` getter allows internal code to access WASM instance when needed +- **Type Compatibility**: Can implement interfaces to maintain backward compatibility + +### When to Use Which Pattern + +**Use Namespace Wrapper Pattern when:** + +- Functions are stateless utilities +- No need to maintain WASM instance state +- Simple input → output transformations +- Examples: address encoding/decoding, network conversions + +**Use Class Wrapper Pattern when:** + +- Object represents stateful data (keys, PSBTs, etc.) +- Methods need to return new instances of the same type +- Need to encapsulate underlying WASM instance +- Examples: BIP32 keys, RootWalletKeys, BitGoPsbt diff --git a/packages/wasm-utxo/js/bip32.ts b/packages/wasm-utxo/js/bip32.ts new file mode 100644 index 00000000..5950c0c3 --- /dev/null +++ b/packages/wasm-utxo/js/bip32.ts @@ -0,0 +1,226 @@ +import { WasmBIP32 } from "./wasm/wasm_utxo.js"; + +/** + * BIP32Arg represents the various forms that BIP32 keys can take + * before being converted to a WasmBIP32 instance + */ +export type BIP32Arg = + /** base58-encoded extended key string (xpub/xprv/tpub/tprv) */ + | string + /** BIP32 instance */ + | BIP32 + /** WasmBIP32 instance */ + | WasmBIP32 + /** BIP32Interface compatible object */ + | BIP32Interface; + +/** + * BIP32 interface for extended key operations + */ +export interface BIP32Interface { + chainCode: Uint8Array; + depth: number; + index: number; + parentFingerprint: number; + privateKey?: Uint8Array; + publicKey: Uint8Array; + identifier: Uint8Array; + fingerprint: Uint8Array; + isNeutered(): boolean; + neutered(): BIP32Interface; + toBase58(): string; + toWIF(): string; + derive(index: number): BIP32Interface; + deriveHardened(index: number): BIP32Interface; + derivePath(path: string): BIP32Interface; +} + +/** + * BIP32 wrapper class for extended key operations + */ +export class BIP32 implements BIP32Interface { + private constructor(private _wasm: WasmBIP32) {} + + /** + * Create a BIP32 instance from a WasmBIP32 instance (internal use) + * @internal + */ + static fromWasm(wasm: WasmBIP32): BIP32 { + return new BIP32(wasm); + } + + /** + * Convert BIP32Arg to BIP32 instance + * @param key - The BIP32 key in various formats + * @returns BIP32 instance + */ + static from(key: BIP32Arg): BIP32 { + // Short-circuit if already a BIP32 instance + if (key instanceof BIP32) { + return key; + } + // If it's a WasmBIP32 instance, wrap it + if (key instanceof WasmBIP32) { + return new BIP32(key); + } + // If it's a string, parse from base58 + if (typeof key === "string") { + const wasm = WasmBIP32.from_base58(key); + return new BIP32(wasm); + } + // If it's an object (BIP32Interface), use from_bip32_interface + if (typeof key === "object" && key !== null) { + const wasm = WasmBIP32.from_bip32_interface(key); + return new BIP32(wasm); + } + throw new Error("Invalid BIP32Arg type"); + } + + /** + * Create a BIP32 key from a base58 string (xpub/xprv/tpub/tprv) + * @param base58Str - The base58-encoded extended key string + * @returns A BIP32 instance + */ + static fromBase58(base58Str: string): BIP32 { + const wasm = WasmBIP32.from_base58(base58Str); + return new BIP32(wasm); + } + + /** + * Create a BIP32 master key from a seed + * @param seed - The seed bytes + * @param network - Optional network string + * @returns A BIP32 instance + */ + static fromSeed(seed: Uint8Array, network?: string | null): BIP32 { + const wasm = WasmBIP32.from_seed(seed, network); + return new BIP32(wasm); + } + + /** + * Get the chain code as a Uint8Array + */ + get chainCode(): Uint8Array { + return this._wasm.chain_code; + } + + /** + * Get the depth + */ + get depth(): number { + return this._wasm.depth; + } + + /** + * Get the child index + */ + get index(): number { + return this._wasm.index; + } + + /** + * Get the parent fingerprint + */ + get parentFingerprint(): number { + return this._wasm.parent_fingerprint; + } + + /** + * Get the private key as a Uint8Array (if available) + */ + get privateKey(): Uint8Array | undefined { + return this._wasm.private_key; + } + + /** + * Get the public key as a Uint8Array + */ + get publicKey(): Uint8Array { + return this._wasm.public_key; + } + + /** + * Get the identifier as a Uint8Array + */ + get identifier(): Uint8Array { + return this._wasm.identifier; + } + + /** + * Get the fingerprint as a Uint8Array + */ + get fingerprint(): Uint8Array { + return this._wasm.fingerprint; + } + + /** + * Check if this is a neutered (public) key + * @returns True if the key is public-only (neutered) + */ + isNeutered(): boolean { + return this._wasm.is_neutered(); + } + + /** + * Get the neutered (public) version of this key + * @returns A new BIP32 instance containing only the public key + */ + neutered(): BIP32 { + const wasm = this._wasm.neutered(); + return new BIP32(wasm); + } + + /** + * Serialize to base58 string + * @returns The base58-encoded extended key string + */ + toBase58(): string { + return this._wasm.to_base58(); + } + + /** + * Get the WIF encoding of the private key + * @returns The WIF-encoded private key + */ + toWIF(): string { + return this._wasm.to_wif(); + } + + /** + * Derive a normal (non-hardened) child key + * @param index - The child index + * @returns A new BIP32 instance for the derived key + */ + derive(index: number): BIP32 { + const wasm = this._wasm.derive(index); + return new BIP32(wasm); + } + + /** + * Derive a hardened child key (only works for private keys) + * @param index - The child index + * @returns A new BIP32 instance for the derived key + */ + deriveHardened(index: number): BIP32 { + const wasm = this._wasm.derive_hardened(index); + return new BIP32(wasm); + } + + /** + * Derive a key using a derivation path (e.g., "0/1/2" or "m/0/1/2") + * @param path - The derivation path string + * @returns A new BIP32 instance for the derived key + */ + derivePath(path: string): BIP32 { + const wasm = this._wasm.derive_path(path); + return new BIP32(wasm); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmBIP32 { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/ecpair.ts b/packages/wasm-utxo/js/ecpair.ts new file mode 100644 index 00000000..9fd3e5de --- /dev/null +++ b/packages/wasm-utxo/js/ecpair.ts @@ -0,0 +1,162 @@ +import { WasmECPair } from "./wasm/wasm_utxo.js"; + +/** + * ECPairArg represents the various forms that ECPair keys can take + * before being converted to a WasmECPair instance + */ +export type ECPairArg = + /** Private key (32 bytes) or compressed public key (33 bytes) as Buffer/Uint8Array */ + | Uint8Array + /** ECPair instance */ + | ECPair + /** WasmECPair instance */ + | WasmECPair; + +/** + * ECPair interface for elliptic curve key pair operations + */ +export interface ECPairInterface { + publicKey: Uint8Array; + privateKey?: Uint8Array; + toWIF(): string; +} + +/** + * ECPair wrapper class for elliptic curve key pair operations + */ +export class ECPair implements ECPairInterface { + private constructor(private _wasm: WasmECPair) {} + + /** + * Create an ECPair instance from a WasmECPair instance (internal use) + * @internal + */ + static fromWasm(wasm: WasmECPair): ECPair { + return new ECPair(wasm); + } + + /** + * Convert ECPairArg to ECPair instance + * @param key - The ECPair key in various formats + * @returns ECPair instance + */ + static from(key: ECPairArg): ECPair { + // Short-circuit if already an ECPair instance + if (key instanceof ECPair) { + return key; + } + // If it's a WasmECPair instance, wrap it + if (key instanceof WasmECPair) { + return new ECPair(key); + } + // Parse from Buffer/Uint8Array + // Check length to determine if it's a private key (32 bytes) or public key (33 bytes) + if (key.length === 32) { + const wasm = WasmECPair.from_private_key(key); + return new ECPair(wasm); + } else if (key.length === 33) { + const wasm = WasmECPair.from_public_key(key); + return new ECPair(wasm); + } else { + throw new Error( + `Invalid key length: ${key.length}. Expected 32 bytes (private key) or 33 bytes (compressed public key)`, + ); + } + } + + /** + * Create an ECPair from a private key (always uses compressed keys) + * @param buffer - The 32-byte private key + * @returns An ECPair instance + */ + static fromPrivateKey(buffer: Uint8Array): ECPair { + const wasm = WasmECPair.from_private_key(buffer); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a compressed public key + * @param buffer - The compressed public key bytes (33 bytes) + * @returns An ECPair instance + */ + static fromPublicKey(buffer: Uint8Array): ECPair { + const wasm = WasmECPair.from_public_key(buffer); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a WIF string (auto-detects network from WIF) + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIF(wifString: string): ECPair { + const wasm = WasmECPair.from_wif(wifString); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a mainnet WIF string + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIFMainnet(wifString: string): ECPair { + const wasm = WasmECPair.from_wif_mainnet(wifString); + return new ECPair(wasm); + } + + /** + * Create an ECPair from a testnet WIF string + * @param wifString - The WIF-encoded private key string + * @returns An ECPair instance + */ + static fromWIFTestnet(wifString: string): ECPair { + const wasm = WasmECPair.from_wif_testnet(wifString); + return new ECPair(wasm); + } + + /** + * Get the private key as a Uint8Array (if available) + */ + get privateKey(): Uint8Array | undefined { + return this._wasm.private_key; + } + + /** + * Get the public key as a Uint8Array + */ + get publicKey(): Uint8Array { + return this._wasm.public_key; + } + + /** + * Convert to WIF string (mainnet) + * @returns The WIF-encoded private key + */ + toWIF(): string { + return this._wasm.to_wif(); + } + + /** + * Convert to mainnet WIF string + * @returns The WIF-encoded private key + */ + toWIFMainnet(): string { + return this._wasm.to_wif_mainnet(); + } + + /** + * Convert to testnet WIF string + * @returns The WIF-encoded private key + */ + toWIFTestnet(): string { + return this._wasm.to_wif_testnet(); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmECPair { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts similarity index 58% rename from packages/wasm-utxo/js/fixedScriptWallet.ts rename to packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 62756fee..47103e55 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -1,59 +1,13 @@ -import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo.js"; -import type { UtxolibName, UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat.js"; -import type { CoinName } from "./coinName.js"; -import { Triple } from "./triple.js"; -import { AddressFormat } from "./address.js"; +import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; +import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; +import { type ReplayProtectionArg, ReplayProtection } from "./ReplayProtection.js"; +import { type BIP32Arg, BIP32 } from "../bip32.js"; +import { type ECPairArg, ECPair } from "../ecpair.js"; +import type { UtxolibName } from "../utxolibCompat.js"; +import type { CoinName } from "../coinName.js"; export type NetworkName = UtxolibName | CoinName; -export type WalletKeys = - /** Just an xpub triple, will assume default derivation prefixes */ - | Triple - /** Compatible with utxolib RootWalletKeys */ - | UtxolibRootWalletKeys; - -/** - * Create the output script for a given wallet keys and chain and index - */ -export function outputScript( - keys: WalletKeys, - chain: number, - index: number, - network: UtxolibNetwork, -): Uint8Array { - return FixedScriptWalletNamespace.output_script(keys, chain, index, network); -} - -/** - * Create the address for a given wallet keys and chain and index and network. - * Wrapper for outputScript that also encodes the script to an address. - * @param keys - The wallet keys to use. - * @param chain - The chain to use. - * @param index - The index to use. - * @param network - The network to use. - * @param addressFormat - The address format to use. - * Only relevant for Bitcoin Cash and eCash networks, where: - * - "default" means base58check, - * - "cashaddr" means cashaddr. - */ -export function address( - keys: WalletKeys, - chain: number, - index: number, - network: UtxolibNetwork, - addressFormat?: AddressFormat, -): string { - return FixedScriptWalletNamespace.address(keys, chain, index, network, addressFormat); -} - -type ReplayProtection = - | { - outputScripts: Uint8Array[]; - } - | { - addresses: string[]; - }; - export type ScriptId = { chain: number; index: number }; export type InputScriptType = @@ -88,8 +42,6 @@ export type ParsedTransaction = { virtualSize: number; }; -import { BitGoPsbt as WasmBitGoPsbt } from "./wasm/wasm_utxo.js"; - export class BitGoPsbt { private constructor(private wasm: WasmBitGoPsbt) {} @@ -119,13 +71,12 @@ export class BitGoPsbt { * @returns Parsed transaction information */ parseTransactionWithWalletKeys( - walletKeys: WalletKeys, - replayProtection: ReplayProtection, + walletKeys: WalletKeysArg, + replayProtection: ReplayProtectionArg, ): ParsedTransaction { - return this.wasm.parse_transaction_with_wallet_keys( - walletKeys, - replayProtection, - ) as ParsedTransaction; + const keys = RootWalletKeys.from(walletKeys); + const rp = ReplayProtection.from(replayProtection, this.wasm.network()); + return this.wasm.parse_transaction_with_wallet_keys(keys.wasm, rp.wasm) as ParsedTransaction; } /** @@ -139,29 +90,61 @@ export class BitGoPsbt { * @returns Array of parsed outputs * @note This method does NOT validate wallet inputs. It only parses outputs. */ - parseOutputsWithWalletKeys(walletKeys: WalletKeys): ParsedOutput[] { - return this.wasm.parse_outputs_with_wallet_keys(walletKeys) as ParsedOutput[]; + parseOutputsWithWalletKeys(walletKeys: WalletKeysArg): ParsedOutput[] { + const keys = RootWalletKeys.from(walletKeys); + return this.wasm.parse_outputs_with_wallet_keys(keys.wasm) as ParsedOutput[]; } /** - * Verify if a valid signature exists for a given extended public key at the specified input index. + * Verify if a valid signature exists for a given key at the specified input index. * - * This method derives the public key from the xpub using the derivation path found in the - * PSBT input, then verifies the signature. It supports: + * This method can verify signatures using either: + * - Extended public key (xpub): Derives the public key using the derivation path from PSBT + * - ECPair (private key): Extracts the public key and verifies directly + * + * When using xpub, it supports: * - ECDSA signatures (for legacy/SegWit inputs) * - Schnorr signatures (for Taproot script path inputs) * - MuSig2 partial signatures (for Taproot keypath MuSig2 inputs) * + * When using ECPair, it supports: + * - ECDSA signatures (for legacy/SegWit inputs) + * - Schnorr signatures (for Taproot script path inputs) + * Note: MuSig2 inputs require xpubs for derivation + * * @param inputIndex - The index of the input to check (0-based) - * @param xpub - The extended public key as a base58-encoded string + * @param key - Either an extended public key (base58 string, BIP32 instance, or WasmBIP32) or an ECPair (private key Buffer, ECPair instance, or WasmECPair) * @returns true if a valid signature exists, false if no signature exists - * @throws Error if input index is out of bounds, xpub is invalid, or verification fails + * @throws Error if input index is out of bounds, key is invalid, or verification fails + * + * @example + * ```typescript + * // Verify wallet input signature with xpub + * const hasUserSig = psbt.verifySignature(0, userXpub); + * + * // Verify signature with ECPair (private key) + * const ecpair = ECPair.fromPrivateKey(privateKeyBuffer); + * const hasReplaySig = psbt.verifySignature(1, ecpair); + * + * // Or pass private key directly + * const hasReplaySig2 = psbt.verifySignature(1, privateKeyBuffer); + * ``` */ - verifySignature(inputIndex: number, xpub: string): boolean { - return this.wasm.verify_signature(inputIndex, xpub); + verifySignature(inputIndex: number, key: BIP32Arg | ECPairArg): boolean { + // Try to parse as BIP32Arg first (string or BIP32 instance) + if (typeof key === "string" || ("derive" in key && typeof key.derive === "function")) { + const wasmKey = BIP32.from(key as BIP32Arg).wasm; + return this.wasm.verify_signature_with_xpub(inputIndex, wasmKey); + } + + // Otherwise it's an ECPairArg (Uint8Array, ECPair, or WasmECPair) + const wasmECPair = ECPair.from(key as ECPairArg).wasm; + return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair); } /** + * @deprecated - use verifySignature with the replay protection key instead + * * Verify if a replay protection input has a valid signature. * * This method checks if a given input is a replay protection input (like P2shP2pk) and verifies @@ -179,8 +162,12 @@ export class BitGoPsbt { * @returns true if the input is a replay protection input and has a valid signature, false if no valid signature * @throws Error if the input is not a replay protection input, index is out of bounds, or scripts are invalid */ - verifyReplayProtectionSignature(inputIndex: number, replayProtection: ReplayProtection): boolean { - return this.wasm.verify_replay_protection_signature(inputIndex, replayProtection); + verifyReplayProtectionSignature( + inputIndex: number, + replayProtection: ReplayProtectionArg, + ): boolean { + const rp = ReplayProtection.from(replayProtection, this.wasm.network()); + return this.wasm.verify_replay_protection_signature(inputIndex, rp.wasm); } /** diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ReplayProtection.ts b/packages/wasm-utxo/js/fixedScriptWallet/ReplayProtection.ts new file mode 100644 index 00000000..d5b02cb0 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ReplayProtection.ts @@ -0,0 +1,112 @@ +import { WasmReplayProtection } from "../wasm/wasm_utxo.js"; +import { type ECPairArg, ECPair } from "../ecpair.js"; + +/** + * ReplayProtectionArg represents the various forms that replay protection can take + * before being converted to a WasmReplayProtection instance + */ +export type ReplayProtectionArg = + | ReplayProtection + | WasmReplayProtection + | { + publicKeys: ECPairArg[]; + } + | { + /** @deprecated - use publicKeys instead */ + outputScripts: Uint8Array[]; + } + | { + /** @deprecated - use publicKeys instead */ + addresses: string[]; + }; + +/** + * ReplayProtection wrapper class for PSBT replay protection inputs + */ +export class ReplayProtection { + private constructor(private _wasm: WasmReplayProtection) {} + + /** + * Create a ReplayProtection instance from a WasmReplayProtection instance (internal use) + * @internal + */ + static fromWasm(wasm: WasmReplayProtection): ReplayProtection { + return new ReplayProtection(wasm); + } + + /** + * Convert ReplayProtectionArg to ReplayProtection instance + * @param arg - The replay protection in various formats + * @param network - Optional network string (required for addresses variant) + * @returns ReplayProtection instance + */ + static from(arg: ReplayProtectionArg, network?: string): ReplayProtection { + // Short-circuit if already a ReplayProtection instance + if (arg instanceof ReplayProtection) { + return arg; + } + // If it's a WasmReplayProtection instance, wrap it + if (arg instanceof WasmReplayProtection) { + return new ReplayProtection(arg); + } + + // Handle object variants + if ("publicKeys" in arg) { + // Convert ECPairArg to public key bytes + const publicKeyBytes = arg.publicKeys.map((key) => ECPair.from(key).publicKey); + const wasm = WasmReplayProtection.from_public_keys(publicKeyBytes); + return new ReplayProtection(wasm); + } + + if ("outputScripts" in arg) { + const wasm = WasmReplayProtection.from_output_scripts(arg.outputScripts); + return new ReplayProtection(wasm); + } + + if ("addresses" in arg) { + if (!network) { + throw new Error("Network is required when using addresses variant"); + } + const wasm = WasmReplayProtection.from_addresses(arg.addresses, network); + return new ReplayProtection(wasm); + } + + throw new Error("Invalid ReplayProtectionArg type"); + } + + /** + * Create from public keys (derives P2SH-P2PK output scripts) + * @param publicKeys - Array of ECPair instances or arguments + * @returns ReplayProtection instance + */ + static fromPublicKeys(publicKeys: ECPairArg[]): ReplayProtection { + return ReplayProtection.from({ publicKeys }); + } + + /** + * Create from output scripts + * @param outputScripts - Array of output script buffers + * @returns ReplayProtection instance + */ + static fromOutputScripts(outputScripts: Uint8Array[]): ReplayProtection { + return ReplayProtection.from({ outputScripts }); + } + + /** + * Create from addresses + * @param addresses - Array of address strings + * @param network - Network string (e.g., "bitcoin", "testnet", "btc", "tbtc") + * @returns ReplayProtection instance + */ + static fromAddresses(addresses: string[], network: string): ReplayProtection { + return ReplayProtection.from({ addresses }, network); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmReplayProtection { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts b/packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts new file mode 100644 index 00000000..b33c2123 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts @@ -0,0 +1,156 @@ +import type { BIP32Interface } from "../bip32.js"; +import { BIP32 } from "../bip32.js"; +import { Triple } from "../triple.js"; +import { WasmRootWalletKeys, WasmBIP32 } from "../wasm/wasm_utxo.js"; + +/** + * IWalletKeys represents the various forms that wallet keys can take + * before being converted to a RootWalletKeys instance + */ +export type IWalletKeys = { + triple: Triple; + derivationPrefixes: Triple; +}; + +export type WalletKeysArg = + /** Just an xpub triple, will assume default derivation prefixes */ + | Triple + /** Compatible with utxolib RootWalletKeys */ + | IWalletKeys + /** RootWalletKeys instance */ + | RootWalletKeys; + +/** + * Convert WalletKeysArg to a triple of BIP32 instances + */ +function toBIP32Triple(keys: WalletKeysArg): Triple { + if (keys instanceof RootWalletKeys) { + return [keys.userKey(), keys.backupKey(), keys.bitgoKey()]; + } + + // Check if it's an IWalletKeys object + if (typeof keys === "object" && "triple" in keys) { + // Extract BIP32 keys from the triple + return keys.triple.map((key) => BIP32.from(key)) as Triple; + } + + // Otherwise it's a triple of strings (xpubs) + return keys.map((xpub) => BIP32.fromWasm(WasmBIP32.from_xpub(xpub))) as Triple; +} + +/** + * Extract derivation prefixes from WalletKeysArg, if present + */ +function extractDerivationPrefixes(keys: WalletKeysArg): Triple | null { + if (typeof keys === "object" && "derivationPrefixes" in keys) { + return keys.derivationPrefixes; + } + return null; +} + +/** + * RootWalletKeys represents a set of three extended public keys with their derivation prefixes + */ +export class RootWalletKeys { + private constructor(private _wasm: WasmRootWalletKeys) {} + + /** + * Create a RootWalletKeys from various input formats + * @param keys - Can be a triple of xpub strings, an IWalletKeys object, or another RootWalletKeys instance + * @returns A RootWalletKeys instance + */ + static from(keys: WalletKeysArg): RootWalletKeys { + if (keys instanceof RootWalletKeys) { + return keys; + } + + const [user, backup, bitgo] = toBIP32Triple(keys); + const derivationPrefixes = extractDerivationPrefixes(keys); + + const wasm = derivationPrefixes + ? WasmRootWalletKeys.with_derivation_prefixes( + user.wasm, + backup.wasm, + bitgo.wasm, + derivationPrefixes[0], + derivationPrefixes[1], + derivationPrefixes[2], + ) + : new WasmRootWalletKeys(user.wasm, backup.wasm, bitgo.wasm); + + return new RootWalletKeys(wasm); + } + + /** + * Create a RootWalletKeys from three xpub strings + * Uses default derivation prefix of m/0/0 for all three keys + * @param xpubs - Triple of xpub strings + * @returns A RootWalletKeys instance + */ + static fromXpubs(xpubs: Triple): RootWalletKeys { + const [user, backup, bitgo] = xpubs.map((xpub) => + WasmBIP32.from_xpub(xpub), + ) as Triple; + const wasm = new WasmRootWalletKeys(user, backup, bitgo); + return new RootWalletKeys(wasm); + } + + /** + * Create a RootWalletKeys from three xpub strings with custom derivation prefixes + * @param xpubs - Triple of xpub strings + * @param derivationPrefixes - Triple of derivation path strings (e.g., ["0/0", "0/0", "0/0"]) + * @returns A RootWalletKeys instance + */ + static withDerivationPrefixes( + xpubs: Triple, + derivationPrefixes: Triple, + ): RootWalletKeys { + const [user, backup, bitgo] = xpubs.map((xpub) => + WasmBIP32.from_xpub(xpub), + ) as Triple; + const wasm = WasmRootWalletKeys.with_derivation_prefixes( + user, + backup, + bitgo, + derivationPrefixes[0], + derivationPrefixes[1], + derivationPrefixes[2], + ); + return new RootWalletKeys(wasm); + } + + /** + * Get the user key (first xpub) + * @returns The user key as a BIP32 instance + */ + userKey(): BIP32 { + const wasm = this._wasm.user_key(); + return BIP32.fromWasm(wasm); + } + + /** + * Get the backup key (second xpub) + * @returns The backup key as a BIP32 instance + */ + backupKey(): BIP32 { + const wasm = this._wasm.backup_key(); + return BIP32.fromWasm(wasm); + } + + /** + * Get the BitGo key (third xpub) + * @returns The BitGo key as a BIP32 instance + */ + bitgoKey(): BIP32 { + const wasm = this._wasm.bitgo_key(); + return BIP32.fromWasm(wasm); + } + + /** + * Get the underlying WASM instance (internal use only) + * @internal + */ + get wasm(): WasmRootWalletKeys { + return this._wasm; + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/address.ts b/packages/wasm-utxo/js/fixedScriptWallet/address.ts new file mode 100644 index 00000000..f88f5710 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/address.ts @@ -0,0 +1,40 @@ +import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js"; +import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; +import type { UtxolibNetwork } from "../utxolibCompat.js"; +import { AddressFormat } from "../address.js"; + +/** + * Create the output script for a given wallet keys and chain and index + */ +export function outputScript( + keys: WalletKeysArg, + chain: number, + index: number, + network: UtxolibNetwork, +): Uint8Array { + const walletKeys = RootWalletKeys.from(keys); + return FixedScriptWalletNamespace.output_script(walletKeys.wasm, chain, index, network); +} + +/** + * Create the address for a given wallet keys and chain and index and network. + * Wrapper for outputScript that also encodes the script to an address. + * @param keys - The wallet keys to use. + * @param chain - The chain to use. + * @param index - The index to use. + * @param network - The network to use. + * @param addressFormat - The address format to use. + * Only relevant for Bitcoin Cash and eCash networks, where: + * - "default" means base58check, + * - "cashaddr" means cashaddr. + */ +export function address( + keys: WalletKeysArg, + chain: number, + index: number, + network: UtxolibNetwork, + addressFormat?: AddressFormat, +): string { + const walletKeys = RootWalletKeys.from(keys); + return FixedScriptWalletNamespace.address(walletKeys.wasm, chain, index, network, addressFormat); +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts new file mode 100644 index 00000000..bd3263f4 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -0,0 +1,12 @@ +export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js"; +export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; +export { outputScript, address } from "./address.js"; +export { + BitGoPsbt, + type NetworkName, + type ScriptId, + type InputScriptType, + type ParsedInput, + type ParsedOutput, + type ParsedTransaction, +} from "./BitGoPsbt.js"; diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 393a2b0d..8bd637f8 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -4,15 +4,25 @@ import * as wasm from "./wasm/wasm_utxo.js"; // and forgets to include it in the bundle void wasm; +// Most exports are namespaced to avoid polluting the top-level namespace +// and to make imports more explicit (e.g., `import { address } from '@bitgo/wasm-utxo'`) export * as address from "./address.js"; export * as ast from "./ast/index.js"; export * as utxolibCompat from "./utxolibCompat.js"; -export * as fixedScriptWallet from "./fixedScriptWallet.js"; +export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; +export * as bip32 from "./bip32.js"; +export * as ecpair from "./ecpair.js"; + +// Only the most commonly used classes and types are exported at the top level for convenience +export { ECPair } from "./ecpair.js"; +export { BIP32 } from "./bip32.js"; export type { CoinName } from "./coinName.js"; export type { Triple } from "./triple.js"; export type { AddressFormat } from "./address.js"; +// TODO: the exports below should be namespaced under `descriptor` in the future + export type DescriptorPkType = "derivable" | "definite" | "string"; export type ScriptContext = "tap" | "segwitv0" | "legacy"; diff --git a/packages/wasm-utxo/js/utxolibCompat.ts b/packages/wasm-utxo/js/utxolibCompat.ts index da93ef2a..eb1bbbe8 100644 --- a/packages/wasm-utxo/js/utxolibCompat.ts +++ b/packages/wasm-utxo/js/utxolibCompat.ts @@ -1,5 +1,4 @@ import type { AddressFormat } from "./address.js"; -import { Triple } from "./triple.js"; import { UtxolibCompatNamespace } from "./wasm/wasm_utxo.js"; export type UtxolibName = @@ -25,26 +24,6 @@ export type UtxolibName = | "zcash" | "zcashTest"; -export type BIP32Interface = { - network: { - bip32: { - public: number; - }; - }; - depth: number; - parentFingerprint: number; - index: number; - chainCode: Uint8Array; - publicKey: Uint8Array; - - toBase58?(): string; -}; - -export type UtxolibRootWalletKeys = { - triple: Triple; - derivationPrefixes: Triple; -}; - export type UtxolibNetwork = { pubKeyHash: number; scriptHash: number; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index f376d8a4..477b5332 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -478,7 +478,7 @@ impl BitGoPsbt { fn parse_inputs( &self, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - replay_protection: &psbt_wallet_input::ReplayProtection, + replay_protection: &crate::fixed_script_wallet::ReplayProtection, ) -> Result, ParseTransactionError> { let psbt = self.psbt(); let network = self.network(); @@ -669,7 +669,7 @@ impl BitGoPsbt { &self, secp: &secp256k1::Secp256k1, input_index: usize, - replay_protection: &psbt_wallet_input::ReplayProtection, + replay_protection: &crate::fixed_script_wallet::ReplayProtection, ) -> Result { use miniscript::bitcoin::{hashes::Hash, sighash::SighashCache}; @@ -728,6 +728,44 @@ impl BitGoPsbt { } } + /// Helper method to verify signature with a compressed public key + /// + /// This method checks if a signature exists for the given public key. + /// It handles both ECDSA and Taproot script path signatures. + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification + /// - `input_index`: The index of the input to check + /// - `public_key`: The compressed public key to verify the signature for + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the public key + /// - `Ok(false)` if no signature exists for the public key + /// - `Err(String)` if verification fails + fn verify_signature_with_pubkey( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + public_key: CompressedPublicKey, + ) -> Result { + let psbt = self.psbt(); + + let input = &psbt.inputs[input_index]; + + // Check for Taproot script path signatures first + if !input.tap_script_sigs.is_empty() { + return psbt_wallet_input::verify_taproot_script_signature( + secp, + psbt, + input_index, + public_key, + ); + } + + // Fall back to ECDSA signature verification for legacy/SegWit inputs + psbt_wallet_input::verify_ecdsa_signature(secp, psbt, input_index, public_key) + } + /// Verify if a valid signature exists for a given extended public key at the specified input index /// /// This method derives the public key from the xpub using the derivation path found in the @@ -745,7 +783,7 @@ impl BitGoPsbt { /// - `Ok(true)` if a valid signature exists for the derived public key /// - `Ok(false)` if no signature exists for the derived public key /// - `Err(String)` if the input index is out of bounds, derivation fails, or verification fails - pub fn verify_signature( + pub fn verify_signature_with_xpub( &self, secp: &secp256k1::Secp256k1, input_index: usize, @@ -795,18 +833,47 @@ impl BitGoPsbt { let public_key = CompressedPublicKey::from_slice(&derived_pubkey.serialize()) .map_err(|e| format!("Failed to convert derived key: {}", e))?; - // Check for Taproot script path signatures first - if !input.tap_script_sigs.is_empty() { - return psbt_wallet_input::verify_taproot_script_signature( - secp, - psbt, - input_index, - public_key, - ); + // Verify signature with the derived public key + self.verify_signature_with_pubkey(secp, input_index, public_key) + } + + /// Verify if a valid signature exists for a given public key at the specified input index + /// + /// This method verifies the signature directly with the provided public key. It supports: + /// - ECDSA signatures (for legacy/SegWit inputs) + /// - Schnorr signatures (for Taproot script path inputs) + /// + /// Note: This method does NOT support MuSig2 inputs, as MuSig2 requires derivation from xpubs. + /// Use `verify_signature_with_xpub` for MuSig2 inputs. + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification + /// - `input_index`: The index of the input to check + /// - `pubkey`: The secp256k1 public key + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the public key + /// - `Ok(false)` if no signature exists for the public key + /// - `Err(String)` if the input index is out of bounds or verification fails + pub fn verify_signature_with_pub( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + pubkey: &secp256k1::PublicKey, + ) -> Result { + let psbt = self.psbt(); + + // Check input index bounds + if input_index >= psbt.inputs.len() { + return Err(format!("Input index {} out of bounds", input_index)); } - // Fall back to ECDSA signature verification for legacy/SegWit inputs - psbt_wallet_input::verify_ecdsa_signature(secp, psbt, input_index, public_key) + // Convert secp256k1::PublicKey to CompressedPublicKey + let public_key = CompressedPublicKey::from_slice(&pubkey.serialize()) + .map_err(|e| format!("Failed to convert public key: {}", e))?; + + // Verify signature with the public key + self.verify_signature_with_pubkey(secp, input_index, public_key) } /// Parse outputs with wallet keys to identify which outputs belong to a particular wallet. @@ -845,7 +912,7 @@ impl BitGoPsbt { pub fn parse_transaction_with_wallet_keys( &self, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - replay_protection: &psbt_wallet_input::ReplayProtection, + replay_protection: &crate::fixed_script_wallet::ReplayProtection, ) -> Result { let psbt = self.psbt(); @@ -1257,7 +1324,7 @@ mod tests { // Create replay protection with this output script let replay_protection = - psbt_wallet_input::ReplayProtection::new(vec![output_script.clone()]); + crate::fixed_script_wallet::ReplayProtection::new(vec![output_script.clone()]); // Verify the signature exists and is valid let has_valid_signature = bitgo_psbt.verify_replay_protection_signature( @@ -1283,12 +1350,12 @@ mod tests { expected_count: usize, stage_name: &str, ) -> Result<(), String> { - // Use verify_signature to count valid signatures for all input types + // Use verify_signature_with_xpub to count valid signatures for all input types // This now handles MuSig2, ECDSA, and Schnorr signatures uniformly let secp = secp256k1::Secp256k1::new(); let mut signature_count = 0; for xpub in &wallet_keys.xpubs { - match bitgo_psbt.verify_signature(&secp, input_index, xpub) { + match bitgo_psbt.verify_signature_with_xpub(&secp, input_index, xpub) { Ok(true) => signature_count += 1, Ok(false) => {} // No signature for this key Err(e) => return Err(e), // Propagate other errors @@ -1536,7 +1603,7 @@ mod tests { let wallet_keys = wallet_xprv.to_root_wallet_keys(); // Create replay protection with the replay protection script from fixture - let replay_protection = psbt_wallet_input::ReplayProtection::new(vec![ + let replay_protection = crate::fixed_script_wallet::ReplayProtection::new(vec![ miniscript::bitcoin::ScriptBuf::from_hex("a91420b37094d82a513451ff0ccd9db23aba05bc5ef387") .expect("Failed to parse replay protection output script"), ]); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index 05f35354..887579c2 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -4,26 +4,9 @@ use miniscript::bitcoin::secp256k1::{self, PublicKey}; use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey}; use crate::bitcoin::bip32::KeySource; -use crate::fixed_script_wallet::{Chain, RootWalletKeys, WalletScripts}; +use crate::fixed_script_wallet::{Chain, ReplayProtection, RootWalletKeys, WalletScripts}; use crate::Network; -#[derive(Debug, Clone)] -pub struct ReplayProtection { - pub permitted_output_scripts: Vec, -} - -impl ReplayProtection { - pub fn new(permitted_output_scripts: Vec) -> Self { - Self { - permitted_output_scripts, - } - } - - pub fn is_replay_protection_input(&self, output_script: &ScriptBuf) -> bool { - self.permitted_output_scripts.contains(output_script) - } -} - pub type Bip32DerivationMap = std::collections::BTreeMap; /// Check if a fingerprint matches any xpub in the wallet diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index af9bb177..55abc136 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -1,11 +1,13 @@ /// This module contains code for the BitGo Fixed Script Wallets. /// These are not based on descriptors. pub mod bitgo_psbt; +pub mod replay_protection; mod wallet_keys; pub mod wallet_scripts; #[cfg(test)] pub mod test_utils; +pub use replay_protection::*; pub use wallet_keys::*; pub use wallet_scripts::*; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/replay_protection.rs b/packages/wasm-utxo/src/fixed_script_wallet/replay_protection.rs new file mode 100644 index 00000000..4b067bfe --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/replay_protection.rs @@ -0,0 +1,36 @@ +use miniscript::bitcoin::{CompressedPublicKey, ScriptBuf}; + +use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk; + +#[derive(Debug, Clone)] +pub struct ReplayProtection { + pub permitted_output_scripts: Vec, +} + +impl ReplayProtection { + pub fn new(permitted_output_scripts: Vec) -> Self { + Self { + permitted_output_scripts, + } + } + + /// Create from public keys by deriving P2SH-P2PK output scripts + /// This is useful for replay protection inputs where we know the public keys + /// but want to automatically create the corresponding output scripts + pub fn from_public_keys(public_keys: Vec) -> Self { + let output_scripts = public_keys + .into_iter() + .map(|key| { + let script = ScriptP2shP2pk::new(key); + script.output_script() + }) + .collect(); + Self { + permitted_output_scripts: output_scripts, + } + } + + pub fn is_replay_protection_input(&self, output_script: &ScriptBuf) -> bool { + self.permitted_output_scripts.contains(output_script) + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index 4e77bb00..aadd2e4a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -142,129 +142,3 @@ pub mod tests { assert!(keys.derive_for_chain_and_index(0, 0).is_ok()); } } - -#[cfg(test)] -#[cfg(target_arch = "wasm32")] -pub mod wasm_tests { - use super::tests::get_test_wallet_xprvs; - use crate::bitcoin::bip32::Xpub; - use crate::wasm::wallet_keys_helpers::root_wallet_keys_from_jsvalue; - use wasm_bindgen::JsValue; - use wasm_bindgen_test::*; - - wasm_bindgen_test_configure!(run_in_browser); - - #[wasm_bindgen_test] - fn test_from_jsvalue_valid_keys_wasm() { - // Get test xpubs as strings - let xpubs = get_test_wallet_xprvs("test"); - let secp = crate::bitcoin::key::Secp256k1::new(); - let xpub_strings: Vec = xpubs - .iter() - .map(|xprv| Xpub::from_priv(&secp, xprv).to_string()) - .collect(); - - // Create a JS array with the xpub strings - let js_array = js_sys::Array::new(); - for xpub_str in xpub_strings.iter() { - js_array.push(&JsValue::from_str(xpub_str)); - } - - // Test from_jsvalue with actual JsValue - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_ok()); - - let wallet_keys = result.unwrap(); - // Verify we can derive keys - assert!(wallet_keys.derive_for_chain_and_index(0, 0).is_ok()); - assert!(wallet_keys.derive_for_chain_and_index(1, 5).is_ok()); - } - - #[wasm_bindgen_test] - fn test_from_jsvalue_invalid_count_wasm() { - // Create a JS array with only 2 xpubs (should fail) - let xpubs = get_test_wallet_xprvs("test"); - let secp = crate::bitcoin::key::Secp256k1::new(); - - let js_array = js_sys::Array::new(); - for i in 0..2 { - let xpub_str = Xpub::from_priv(&secp, &xpubs[i]).to_string(); - js_array.push(&JsValue::from_str(&xpub_str)); - } - - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().to_string(), - "Expected exactly 3 xpub keys" - ); - } - - #[wasm_bindgen_test] - fn test_from_jsvalue_too_many_keys_wasm() { - // Create a JS array with 4 xpubs (should fail) - let xpubs = get_test_wallet_xprvs("test"); - let secp = crate::bitcoin::key::Secp256k1::new(); - - let js_array = js_sys::Array::new(); - for i in 0..3 { - let xpub_str = Xpub::from_priv(&secp, &xpubs[i]).to_string(); - js_array.push(&JsValue::from_str(&xpub_str)); - } - // Add one more - js_array.push(&JsValue::from_str( - &Xpub::from_priv(&secp, &xpubs[0]).to_string(), - )); - - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().to_string(), - "Expected exactly 3 xpub keys" - ); - } - - #[wasm_bindgen_test] - fn test_from_jsvalue_invalid_xpub_wasm() { - // Create a JS array with 3 values, all of which are not valid xpubs - let js_array = js_sys::Array::new(); - js_array.push(&JsValue::from_str("not-a-valid-xpub")); - js_array.push(&JsValue::from_str("also-not-valid")); - js_array.push(&JsValue::from_str("still-not-valid")); - - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Failed to parse xpub")); - } - - #[wasm_bindgen_test] - fn test_from_jsvalue_non_string_element_wasm() { - // Create a JS array with a non-string element - let js_array = js_sys::Array::new(); - js_array.push(&JsValue::from_f64(123.0)); // number instead of string - js_array.push(&JsValue::from_str("xpub2")); - js_array.push(&JsValue::from_str("xpub3")); - - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Key at index 0 is not a string")); - } - - #[wasm_bindgen_test] - fn test_from_jsvalue_mixed_invalid_wasm() { - // Create a JS array with mixed invalid values - let js_array = js_sys::Array::new(); - js_array.push(&JsValue::NULL); - js_array.push(&JsValue::UNDEFINED); - js_array.push(&JsValue::from_bool(true)); - - let result = root_wallet_keys_from_jsvalue(&js_array.into()); - assert!(result.is_err()); - } -} diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 817b9e6e..68c49819 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -16,4 +16,6 @@ pub use address::{ pub use networks::Network; pub mod wasm; -pub use wasm::{WrapDescriptor, WrapMiniscript, WrapPsbt}; +pub use wasm::{ + WasmBIP32, WasmECPair, WasmRootWalletKeys, WrapDescriptor, WrapMiniscript, WrapPsbt, +}; diff --git a/packages/wasm-utxo/src/wasm/bip32.rs b/packages/wasm-utxo/src/wasm/bip32.rs new file mode 100644 index 00000000..0090bc1d --- /dev/null +++ b/packages/wasm-utxo/src/wasm/bip32.rs @@ -0,0 +1,337 @@ +use std::str::FromStr; + +use crate::bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv, Xpub}; +use crate::bitcoin::secp256k1::Secp256k1; +use crate::bitcoin::{PrivateKey, PublicKey}; +use crate::error::WasmUtxoError; +use crate::wasm::try_from_js_value::{get_buffer_field, get_field, get_nested_field}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; + +// Internal enum to hold either Xpub or Xpriv +#[derive(Debug, Clone)] +enum BIP32Key { + Public(Xpub), + Private(Xpriv), +} + +impl BIP32Key { + fn to_xpub(&self) -> Xpub { + match self { + BIP32Key::Public(xpub) => *xpub, + BIP32Key::Private(xpriv) => Xpub::from_priv(&Secp256k1::new(), xpriv), + } + } + + fn is_neutered(&self) -> bool { + matches!(self, BIP32Key::Public(_)) + } + + fn derive(&self, index: u32) -> Result { + let secp = Secp256k1::new(); + let child_number = ChildNumber::Normal { index }; + + match self { + BIP32Key::Public(xpub) => { + let derived = xpub + .derive_pub(&secp, &[child_number]) + .map_err(|e| WasmUtxoError::new(&format!("Failed to derive: {}", e)))?; + Ok(BIP32Key::Public(derived)) + } + BIP32Key::Private(xpriv) => { + let derived = xpriv + .derive_priv(&secp, &[child_number]) + .map_err(|e| WasmUtxoError::new(&format!("Failed to derive: {}", e)))?; + Ok(BIP32Key::Private(derived)) + } + } + } + + fn derive_hardened(&self, index: u32) -> Result { + let secp = Secp256k1::new(); + let child_number = ChildNumber::Hardened { index }; + + match self { + BIP32Key::Public(_) => Err(WasmUtxoError::new( + "Cannot derive hardened key from public key", + )), + BIP32Key::Private(xpriv) => { + let derived = xpriv.derive_priv(&secp, &[child_number]).map_err(|e| { + WasmUtxoError::new(&format!("Failed to derive hardened: {}", e)) + })?; + Ok(BIP32Key::Private(derived)) + } + } + } + + fn derive_path(&self, path: &str) -> Result { + let secp = Secp256k1::new(); + + // Remove leading 'm/' or 'M/' if present + let path_str = path + .strip_prefix("m/") + .or_else(|| path.strip_prefix("M/")) + .unwrap_or(path); + + let derivation_path = DerivationPath::from_str(&format!("m/{}", path_str)) + .map_err(|e| WasmUtxoError::new(&format!("Invalid derivation path: {}", e)))?; + + match self { + BIP32Key::Public(xpub) => { + let derived = xpub + .derive_pub(&secp, &derivation_path) + .map_err(|e| WasmUtxoError::new(&format!("Failed to derive path: {}", e)))?; + Ok(BIP32Key::Public(derived)) + } + BIP32Key::Private(xpriv) => { + let derived = xpriv + .derive_priv(&secp, &derivation_path) + .map_err(|e| WasmUtxoError::new(&format!("Failed to derive path: {}", e)))?; + Ok(BIP32Key::Private(derived)) + } + } + } + + fn to_base58(&self) -> String { + match self { + BIP32Key::Public(xpub) => xpub.to_string(), + BIP32Key::Private(xpriv) => xpriv.to_string(), + } + } + + fn to_wif(&self) -> Result { + match self { + BIP32Key::Public(_) => Err(WasmUtxoError::new("Cannot get WIF from public key")), + BIP32Key::Private(xpriv) => { + let privkey = PrivateKey::new(xpriv.private_key, xpriv.network); + Ok(privkey.to_wif()) + } + } + } +} + +/// WASM wrapper for BIP32 extended keys (Xpub/Xpriv) +/// Implements the BIP32Interface TypeScript interface +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WasmBIP32(BIP32Key); + +#[wasm_bindgen] +impl WasmBIP32 { + /// Create a BIP32 key from a base58 string (xpub/xprv/tpub/tprv) + #[wasm_bindgen] + pub fn from_base58(base58_str: &str) -> Result { + // Try to parse as Xpriv first, then Xpub + if let Ok(xpriv) = Xpriv::from_str(base58_str) { + Ok(WasmBIP32(BIP32Key::Private(xpriv))) + } else if let Ok(xpub) = Xpub::from_str(base58_str) { + Ok(WasmBIP32(BIP32Key::Public(xpub))) + } else { + Err(WasmUtxoError::new("Invalid base58 encoded key")) + } + } + + /// Create a BIP32 key from an xpub string (base58-encoded) + #[wasm_bindgen] + pub fn from_xpub(xpub_str: &str) -> Result { + let xpub = Xpub::from_str(xpub_str) + .map_err(|e| WasmUtxoError::new(&format!("Failed to parse xpub: {}", e)))?; + Ok(WasmBIP32(BIP32Key::Public(xpub))) + } + + /// Create a BIP32 key from an xprv string (base58-encoded) + #[wasm_bindgen] + pub fn from_xprv(xprv_str: &str) -> Result { + let xprv = Xpriv::from_str(xprv_str) + .map_err(|e| WasmUtxoError::new(&format!("Failed to parse xprv: {}", e)))?; + Ok(WasmBIP32(BIP32Key::Private(xprv))) + } + + /// Create a BIP32 key from a BIP32Interface JavaScript object properties + /// Expects an object with: network.bip32.public, depth, parentFingerprint, + /// index, chainCode, and publicKey properties + #[wasm_bindgen] + pub fn from_bip32_interface(bip32_key: &JsValue) -> Result { + Self::from_bip32_properties(bip32_key) + } + + /// Create a BIP32 key from BIP32 properties + /// Extracts properties from a JavaScript object and constructs an xpub + #[wasm_bindgen] + pub fn from_bip32_properties(bip32_key: &JsValue) -> Result { + // Extract properties using helper functions + let version: u32 = get_nested_field(bip32_key, "network.bip32.public")?; + let depth: u8 = get_field(bip32_key, "depth")?; + let parent_fingerprint: u32 = get_field(bip32_key, "parentFingerprint")?; + let index: u32 = get_field(bip32_key, "index")?; + let chain_code_bytes: [u8; 32] = get_buffer_field(bip32_key, "chainCode")?; + let public_key_bytes: [u8; 33] = get_buffer_field(bip32_key, "publicKey")?; + + // Build BIP32 serialization (78 bytes total) + let mut data = Vec::with_capacity(78); + data.extend_from_slice(&version.to_be_bytes()); // 4 bytes: version + data.push(depth); // 1 byte: depth + data.extend_from_slice(&parent_fingerprint.to_be_bytes()); // 4 bytes: parent fingerprint + data.extend_from_slice(&index.to_be_bytes()); // 4 bytes: index + data.extend_from_slice(&chain_code_bytes); // 32 bytes: chain code + data.extend_from_slice(&public_key_bytes); // 33 bytes: public key + + // Use the Xpub::decode method which properly handles network detection and constructs the Xpub + let xpub = Xpub::decode(&data) + .map_err(|e| WasmUtxoError::new(&format!("Failed to decode xpub: {}", e)))?; + Ok(WasmBIP32(BIP32Key::Public(xpub))) + } + + /// Create a BIP32 master key from a seed + #[wasm_bindgen] + pub fn from_seed(seed: &[u8], network: Option) -> Result { + use crate::bitcoin::Network as BitcoinNetwork; + + let network = if let Some(net_str) = network { + crate::Network::from_str(&net_str) + .map_err(|_| WasmUtxoError::new(&format!("Invalid network: {}", net_str)))? + } else { + crate::Network::Bitcoin + }; + + // Map our Network to bitcoin::Network + let bitcoin_network = match network { + crate::Network::Bitcoin => BitcoinNetwork::Bitcoin, + crate::Network::BitcoinTestnet3 => BitcoinNetwork::Testnet, + crate::Network::BitcoinTestnet4 => BitcoinNetwork::Testnet, + crate::Network::BitcoinPublicSignet => BitcoinNetwork::Signet, + crate::Network::BitcoinBitGoSignet => BitcoinNetwork::Signet, + _ => BitcoinNetwork::Bitcoin, // Default for non-bitcoin networks + }; + + let xpriv = Xpriv::new_master(bitcoin_network, seed) + .map_err(|e| WasmUtxoError::new(&format!("Failed to create master key: {}", e)))?; + + Ok(WasmBIP32(BIP32Key::Private(xpriv))) + } + + /// Get the chain code as a Uint8Array + #[wasm_bindgen(getter)] + pub fn chain_code(&self) -> js_sys::Uint8Array { + let chain_code = match &self.0 { + BIP32Key::Public(xpub) => xpub.chain_code.to_bytes(), + BIP32Key::Private(xpriv) => xpriv.chain_code.to_bytes(), + }; + js_sys::Uint8Array::from(&chain_code[..]) + } + + /// Get the depth + #[wasm_bindgen(getter)] + pub fn depth(&self) -> u8 { + match &self.0 { + BIP32Key::Public(xpub) => xpub.depth, + BIP32Key::Private(xpriv) => xpriv.depth, + } + } + + /// Get the child index + #[wasm_bindgen(getter)] + pub fn index(&self) -> u32 { + match &self.0 { + BIP32Key::Public(xpub) => u32::from(xpub.child_number), + BIP32Key::Private(xpriv) => u32::from(xpriv.child_number), + } + } + + /// Get the parent fingerprint + #[wasm_bindgen(getter)] + pub fn parent_fingerprint(&self) -> u32 { + match &self.0 { + BIP32Key::Public(xpub) => u32::from_be_bytes(xpub.parent_fingerprint.to_bytes()), + BIP32Key::Private(xpriv) => u32::from_be_bytes(xpriv.parent_fingerprint.to_bytes()), + } + } + + /// Get the private key as a Uint8Array (if available) + #[wasm_bindgen(getter)] + pub fn private_key(&self) -> Option { + match &self.0 { + BIP32Key::Public(_) => None, + BIP32Key::Private(xpriv) => Some(js_sys::Uint8Array::from( + &xpriv.private_key.secret_bytes()[..], + )), + } + } + + /// Get the public key as a Uint8Array + #[wasm_bindgen(getter)] + pub fn public_key(&self) -> js_sys::Uint8Array { + let xpub = self.0.to_xpub(); + let pubkey = PublicKey::new(xpub.public_key); + js_sys::Uint8Array::from(&pubkey.to_bytes()[..]) + } + + /// Get the identifier as a Uint8Array + #[wasm_bindgen(getter)] + pub fn identifier(&self) -> js_sys::Uint8Array { + let xpub = self.0.to_xpub(); + js_sys::Uint8Array::from(&xpub.identifier()[..]) + } + + /// Get the fingerprint as a Uint8Array + #[wasm_bindgen(getter)] + pub fn fingerprint(&self) -> js_sys::Uint8Array { + let xpub = self.0.to_xpub(); + js_sys::Uint8Array::from(&xpub.fingerprint()[..]) + } + + /// Check if this is a neutered (public) key + #[wasm_bindgen] + pub fn is_neutered(&self) -> bool { + self.0.is_neutered() + } + + /// Get the neutered (public) version of this key + #[wasm_bindgen] + pub fn neutered(&self) -> WasmBIP32 { + WasmBIP32(BIP32Key::Public(self.0.to_xpub())) + } + + /// Serialize to base58 string + #[wasm_bindgen] + pub fn to_base58(&self) -> String { + self.0.to_base58() + } + + /// Get the WIF encoding of the private key + #[wasm_bindgen] + pub fn to_wif(&self) -> Result { + self.0.to_wif() + } + + /// Derive a normal (non-hardened) child key + #[wasm_bindgen] + pub fn derive(&self, index: u32) -> Result { + Ok(WasmBIP32(self.0.derive(index)?)) + } + + /// Derive a hardened child key (only works for private keys) + #[wasm_bindgen] + pub fn derive_hardened(&self, index: u32) -> Result { + Ok(WasmBIP32(self.0.derive_hardened(index)?)) + } + + /// Derive a key using a derivation path (e.g., "0/1/2" or "m/0/1/2") + #[wasm_bindgen] + pub fn derive_path(&self, path: &str) -> Result { + Ok(WasmBIP32(self.0.derive_path(path)?)) + } +} + +// Non-WASM methods for internal use +impl WasmBIP32 { + /// Create from Xpub (for internal Rust use, not exposed to JS) + pub(crate) fn from_xpub_internal(xpub: crate::bitcoin::bip32::Xpub) -> WasmBIP32 { + WasmBIP32(BIP32Key::Public(xpub)) + } + + /// Convert to Xpub (for internal Rust use, not exposed to JS) + pub(crate) fn to_xpub(&self) -> Result { + Ok(self.0.to_xpub()) + } +} diff --git a/packages/wasm-utxo/src/wasm/bip32interface.rs b/packages/wasm-utxo/src/wasm/bip32interface.rs deleted file mode 100644 index 73d47fbc..00000000 --- a/packages/wasm-utxo/src/wasm/bip32interface.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::str::FromStr; - -use crate::bitcoin::bip32::Xpub; -use crate::error::WasmUtxoError; -use crate::wasm::try_from_js_value::{get_buffer_field, get_field, get_nested_field}; -use wasm_bindgen::JsValue; - -fn try_xpub_from_bip32_properties(bip32_key: &JsValue) -> Result { - // Extract properties using helper functions - let version: u32 = get_nested_field(bip32_key, "network.bip32.public")?; - let depth: u8 = get_field(bip32_key, "depth")?; - let parent_fingerprint: u32 = get_field(bip32_key, "parentFingerprint")?; - let index: u32 = get_field(bip32_key, "index")?; - let chain_code_bytes: [u8; 32] = get_buffer_field(bip32_key, "chainCode")?; - let public_key_bytes: [u8; 33] = get_buffer_field(bip32_key, "publicKey")?; - - // Build BIP32 serialization (78 bytes total) - let mut data = Vec::with_capacity(78); - data.extend_from_slice(&version.to_be_bytes()); // 4 bytes: version - data.push(depth); // 1 byte: depth - data.extend_from_slice(&parent_fingerprint.to_be_bytes()); // 4 bytes: parent fingerprint - data.extend_from_slice(&index.to_be_bytes()); // 4 bytes: index - data.extend_from_slice(&chain_code_bytes); // 32 bytes: chain code - data.extend_from_slice(&public_key_bytes); // 33 bytes: public key - - // Use the Xpub::decode method which properly handles network detection and constructs the Xpub - Xpub::decode(&data).map_err(|e| WasmUtxoError::new(&format!("Failed to decode xpub: {}", e))) -} - -fn xpub_from_base58_method(bip32_key: &JsValue) -> Result { - // Fallback: Call toBase58() method on BIP32Interface - let to_base58 = js_sys::Reflect::get(bip32_key, &JsValue::from_str("toBase58")) - .map_err(|_| WasmUtxoError::new("Failed to get 'toBase58' method"))?; - - if !to_base58.is_function() { - return Err(WasmUtxoError::new("'toBase58' is not a function")); - } - - let to_base58_fn = js_sys::Function::from(to_base58); - let xpub_str = to_base58_fn - .call0(bip32_key) - .map_err(|_| WasmUtxoError::new("Failed to call 'toBase58'"))?; - - let xpub_string = xpub_str - .as_string() - .ok_or_else(|| WasmUtxoError::new("'toBase58' did not return a string"))?; - - Xpub::from_str(&xpub_string) - .map_err(|e| WasmUtxoError::new(&format!("Failed to parse xpub: {}", e))) -} - -pub fn xpub_from_bip32interface(bip32_key: &JsValue) -> Result { - // Try to construct from properties first, fall back to toBase58() if that fails - try_xpub_from_bip32_properties(bip32_key).or_else(|_| xpub_from_base58_method(bip32_key)) -} diff --git a/packages/wasm-utxo/src/wasm/ecpair.rs b/packages/wasm-utxo/src/wasm/ecpair.rs new file mode 100644 index 00000000..71ef81a2 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/ecpair.rs @@ -0,0 +1,180 @@ +use crate::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use crate::bitcoin::PrivateKey; +use crate::error::WasmUtxoError; +use wasm_bindgen::prelude::*; + +// Internal enum to hold either public-only or private+public keys +#[derive(Debug, Clone)] +enum ECPairKey { + PublicOnly(PublicKey), + Private { + secret_key: SecretKey, + public_key: PublicKey, + }, +} + +impl ECPairKey { + fn public_key(&self) -> PublicKey { + match self { + ECPairKey::PublicOnly(pk) => *pk, + ECPairKey::Private { public_key, .. } => *public_key, + } + } + + fn secret_key(&self) -> Option { + match self { + ECPairKey::PublicOnly(_) => None, + ECPairKey::Private { secret_key, .. } => Some(*secret_key), + } + } +} + +/// WASM wrapper for elliptic curve key pairs (always uses compressed keys) +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WasmECPair { + key: ECPairKey, +} + +impl WasmECPair { + /// Get the public key as a secp256k1::PublicKey (for internal Rust use) + pub(crate) fn get_public_key(&self) -> PublicKey { + self.key.public_key() + } +} + +#[wasm_bindgen] +impl WasmECPair { + /// Create an ECPair from a private key (always uses compressed keys) + #[wasm_bindgen] + pub fn from_private_key(private_key: &[u8]) -> Result { + if private_key.len() != 32 { + return Err(WasmUtxoError::new("Private key must be 32 bytes")); + } + + let secret_key = SecretKey::from_slice(private_key) + .map_err(|e| WasmUtxoError::new(&format!("Invalid private key: {}", e)))?; + + let secp = Secp256k1::new(); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + Ok(WasmECPair { + key: ECPairKey::Private { + secret_key, + public_key, + }, + }) + } + + /// Create an ECPair from a public key (always uses compressed keys) + #[wasm_bindgen] + pub fn from_public_key(public_key: &[u8]) -> Result { + let public_key = PublicKey::from_slice(public_key) + .map_err(|e| WasmUtxoError::new(&format!("Invalid public key: {}", e)))?; + + Ok(WasmECPair { + key: ECPairKey::PublicOnly(public_key), + }) + } + + fn from_wif_with_network_check( + wif_string: &str, + expected_network: Option, + ) -> Result { + let private_key = PrivateKey::from_wif(wif_string) + .map_err(|e| WasmUtxoError::new(&format!("Invalid WIF: {}", e)))?; + + if let Some(expected) = expected_network { + if private_key.network != expected { + let network_name = match expected { + crate::bitcoin::NetworkKind::Main => "mainnet", + crate::bitcoin::NetworkKind::Test => "testnet", + }; + return Err(WasmUtxoError::new(&format!( + "Expected {} WIF", + network_name + ))); + } + } + + let secp = Secp256k1::new(); + let secret_key = private_key.inner; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + Ok(WasmECPair { + key: ECPairKey::Private { + secret_key, + public_key, + }, + }) + } + + /// Create an ECPair from a WIF string (auto-detects network) + #[wasm_bindgen] + pub fn from_wif(wif_string: &str) -> Result { + Self::from_wif_with_network_check(wif_string, None) + } + + /// Create an ECPair from a mainnet WIF string + #[wasm_bindgen] + pub fn from_wif_mainnet(wif_string: &str) -> Result { + use crate::bitcoin::NetworkKind; + Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Main)) + } + + /// Create an ECPair from a testnet WIF string + #[wasm_bindgen] + pub fn from_wif_testnet(wif_string: &str) -> Result { + use crate::bitcoin::NetworkKind; + Self::from_wif_with_network_check(wif_string, Some(NetworkKind::Test)) + } + + /// Get the private key as a Uint8Array (if available) + #[wasm_bindgen(getter)] + pub fn private_key(&self) -> Option { + self.key + .secret_key() + .map(|sk| js_sys::Uint8Array::from(&sk.secret_bytes()[..])) + } + + /// Get the compressed public key as a Uint8Array (always 33 bytes) + #[wasm_bindgen(getter)] + pub fn public_key(&self) -> js_sys::Uint8Array { + let pk = self.key.public_key(); + let bytes = pk.serialize(); + js_sys::Uint8Array::from(&bytes[..]) + } + + /// Convert to WIF string (mainnet) + #[wasm_bindgen] + pub fn to_wif(&self) -> Result { + self.to_wif_mainnet() + } + + /// Convert to mainnet WIF string + #[wasm_bindgen] + pub fn to_wif_mainnet(&self) -> Result { + use crate::bitcoin::NetworkKind; + self.to_wif_with_network(NetworkKind::Main) + } + + /// Convert to testnet WIF string + #[wasm_bindgen] + pub fn to_wif_testnet(&self) -> Result { + use crate::bitcoin::NetworkKind; + self.to_wif_with_network(NetworkKind::Test) + } + + fn to_wif_with_network( + &self, + network: crate::bitcoin::NetworkKind, + ) -> Result { + let secret_key = self + .key + .secret_key() + .ok_or_else(|| WasmUtxoError::new("Cannot get WIF from public key"))?; + + let private_key = PrivateKey::new(secret_key, network); + Ok(private_key.to_wif()) + } +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 090385ca..4406bb08 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; @@ -6,10 +5,12 @@ use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::{Chain, WalletScripts}; use crate::utxolib_compat::UtxolibNetwork; +use crate::wasm::bip32::WasmBIP32; +use crate::wasm::ecpair::WasmECPair; +use crate::wasm::replay_protection::WasmReplayProtection; use crate::wasm::try_from_js_value::TryFromJsValue; -use crate::wasm::try_from_js_value::{get_buffer_array_field, get_string_array_field}; use crate::wasm::try_into_js_value::TryIntoJsValue; -use crate::wasm::wallet_keys_helpers::root_wallet_keys_from_jsvalue; +use crate::wasm::wallet_keys::WasmRootWalletKeys; /// Parse a network from a string that can be either a utxolib name or a coin name fn parse_network(network_str: &str) -> Result { @@ -23,57 +24,6 @@ fn parse_network(network_str: &str) -> Result Result< - crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection, - WasmUtxoError, -> { - // Try to get outputScripts first - if let Ok(script_bytes) = get_buffer_array_field(replay_protection, "outputScripts") { - let permitted_scripts = script_bytes - .into_iter() - .map(miniscript::bitcoin::ScriptBuf::from_bytes) - .collect(); - - return Ok( - crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection::new( - permitted_scripts, - ), - ); - } - - // Try to get addresses array - let addresses = get_string_array_field(replay_protection, "addresses").map_err(|_| { - WasmUtxoError::new("replay_protection must have either outputScripts or addresses property") - })?; - - // Convert addresses to scripts using provided network - let mut permitted_scripts = Vec::new(); - for address_str in addresses { - let script = crate::address::networks::to_output_script_with_network(&address_str, network) - .map_err(|e| { - WasmUtxoError::new(&format!( - "Failed to decode address '{}': {}", - address_str, e - )) - })?; - - permitted_scripts.push(script); - } - - Ok( - crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection::new( - permitted_scripts, - ), - ) -} - #[wasm_bindgen] pub struct FixedScriptWalletNamespace; @@ -81,7 +31,7 @@ pub struct FixedScriptWalletNamespace; impl FixedScriptWalletNamespace { #[wasm_bindgen] pub fn output_script( - keys: JsValue, + keys: &WasmRootWalletKeys, chain: u32, index: u32, network: JsValue, @@ -90,9 +40,9 @@ impl FixedScriptWalletNamespace { let chain = Chain::try_from(chain) .map_err(|e| WasmUtxoError::new(&format!("Invalid chain: {}", e)))?; - let wallet_keys = root_wallet_keys_from_jsvalue(&keys)?; + let wallet_keys = keys.inner(); let scripts = WalletScripts::from_wallet_keys( - &wallet_keys, + wallet_keys, chain, index, &network.output_script_support(), @@ -102,18 +52,18 @@ impl FixedScriptWalletNamespace { #[wasm_bindgen] pub fn address( - keys: JsValue, + keys: &WasmRootWalletKeys, chain: u32, index: u32, network: JsValue, address_format: Option, ) -> Result { let network = UtxolibNetwork::try_from_js_value(&network)?; - let wallet_keys = root_wallet_keys_from_jsvalue(&keys)?; + let wallet_keys = keys.inner(); let chain = Chain::try_from(chain) .map_err(|e| WasmUtxoError::new(&format!("Invalid chain: {}", e)))?; let scripts = WalletScripts::from_wallet_keys( - &wallet_keys, + wallet_keys, chain, index, &network.output_script_support(), @@ -153,23 +103,25 @@ impl BitGoPsbt { self.psbt.unsigned_txid().to_string() } + /// Get the network of the PSBT + pub fn network(&self) -> String { + self.psbt.network().to_string() + } + /// Parse transaction with wallet keys to identify wallet inputs/outputs pub fn parse_transaction_with_wallet_keys( &self, - wallet_keys: JsValue, - replay_protection: JsValue, + wallet_keys: &WasmRootWalletKeys, + replay_protection: &WasmReplayProtection, ) -> Result { - // Convert wallet keys from JsValue - let wallet_keys = root_wallet_keys_from_jsvalue(&wallet_keys)?; - - // Convert replay protection from JsValue, using the PSBT's network - let network = self.psbt.network(); - let replay_protection = replay_protection_from_js_value(&replay_protection, network)?; + // Get the inner RootWalletKeys and ReplayProtection + let wallet_keys = wallet_keys.inner(); + let replay_protection = replay_protection.inner(); // Call the Rust implementation let parsed_tx = self .psbt - .parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection) + .parse_transaction_with_wallet_keys(wallet_keys, replay_protection) .map_err(|e| WasmUtxoError::new(&format!("Failed to parse transaction: {}", e)))?; // Convert to JsValue directly using TryIntoJsValue @@ -181,15 +133,15 @@ impl BitGoPsbt { /// Note: This method does NOT validate wallet inputs. It only parses outputs. pub fn parse_outputs_with_wallet_keys( &self, - wallet_keys: JsValue, + wallet_keys: &WasmRootWalletKeys, ) -> Result { - // Convert wallet keys from JsValue - let wallet_keys = root_wallet_keys_from_jsvalue(&wallet_keys)?; + // Get the inner RootWalletKeys + let wallet_keys = wallet_keys.inner(); // Call the Rust implementation let parsed_outputs = self .psbt - .parse_outputs_with_wallet_keys(&wallet_keys) + .parse_outputs_with_wallet_keys(wallet_keys) .map_err(|e| WasmUtxoError::new(&format!("Failed to parse outputs: {}", e)))?; // Convert Vec to JsValue @@ -199,32 +151,67 @@ impl BitGoPsbt { /// Verify if a valid signature exists for a given xpub at the specified input index /// /// This method derives the public key from the xpub using the derivation path found in the - /// PSBT input, then verifies the signature. It supports both ECDSA signatures (for legacy/SegWit - /// inputs) and Schnorr signatures (for Taproot script path inputs). + /// PSBT input, then verifies the signature. It supports: + /// - ECDSA signatures (for legacy/SegWit inputs) + /// - Schnorr signatures (for Taproot script path inputs) + /// - MuSig2 partial signatures (for Taproot keypath MuSig2 inputs) /// /// # Arguments /// - `input_index`: The index of the input to check - /// - `xpub_str`: The extended public key as a base58-encoded string + /// - `xpub`: The extended public key as a WasmBIP32 instance /// /// # Returns /// - `Ok(true)` if a valid signature exists for the derived public key /// - `Ok(false)` if no signature exists for the derived public key - /// - `Err(WasmUtxoError)` if the input index is out of bounds, xpub is invalid, derivation fails, or verification fails - pub fn verify_signature( + /// - `Err(WasmUtxoError)` if the input index is out of bounds, derivation fails, or verification fails + pub fn verify_signature_with_xpub( + &self, + input_index: usize, + xpub: &WasmBIP32, + ) -> Result { + // Extract Xpub from WasmBIP32 + let xpub_inner = xpub.to_xpub()?; + + // Create secp context + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + + // Call the Rust implementation + self.psbt + .verify_signature_with_xpub(&secp, input_index, &xpub_inner) + .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) + } + + /// Verify if a valid signature exists for a given ECPair key at the specified input index + /// + /// This method verifies the signature directly with the provided ECPair's public key. It supports: + /// - ECDSA signatures (for legacy/SegWit inputs) + /// - Schnorr signatures (for Taproot script path inputs) + /// + /// Note: This method does NOT support MuSig2 inputs, as MuSig2 requires derivation from xpubs. + /// Use `verify_signature_with_xpub` for MuSig2 inputs. + /// + /// # Arguments + /// - `input_index`: The index of the input to check + /// - `ecpair`: The ECPair key (uses the public key for verification) + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the public key + /// - `Ok(false)` if no signature exists for the public key + /// - `Err(WasmUtxoError)` if the input index is out of bounds or verification fails + pub fn verify_signature_with_pub( &self, input_index: usize, - xpub_str: &str, + ecpair: &WasmECPair, ) -> Result { - // Parse xpub from string - let xpub = miniscript::bitcoin::bip32::Xpub::from_str(xpub_str) - .map_err(|e| WasmUtxoError::new(&format!("Invalid xpub: {}", e)))?; + // Extract the public key from the ECPair + let public_key = ecpair.get_public_key(); // Create secp context let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); // Call the Rust implementation self.psbt - .verify_signature(&secp, input_index, &xpub) + .verify_signature_with_pub(&secp, input_index, &public_key) .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) } @@ -246,18 +233,17 @@ impl BitGoPsbt { pub fn verify_replay_protection_signature( &self, input_index: usize, - replay_protection: JsValue, + replay_protection: &WasmReplayProtection, ) -> Result { - // Convert replay protection from JsValue, using the PSBT's network - let network = self.psbt.network(); - let replay_protection = replay_protection_from_js_value(&replay_protection, network)?; + // Get the inner ReplayProtection + let replay_protection = replay_protection.inner(); // Create secp context let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); // Call the Rust implementation self.psbt - .verify_replay_protection_signature(&secp, input_index, &replay_protection) + .verify_replay_protection_signature(&secp, input_index, replay_protection) .map_err(|e| { WasmUtxoError::new(&format!( "Failed to verify replay protection signature: {}", diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 17a4fbb0..61f9f6d3 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -1,17 +1,23 @@ mod address; -mod bip32interface; +mod bip32; mod descriptor; +mod ecpair; mod fixed_script_wallet; mod miniscript; mod psbt; +mod replay_protection; mod try_from_js_value; mod try_into_js_value; mod utxolib_compat; -pub(crate) mod wallet_keys_helpers; +mod wallet_keys; pub use address::AddressNamespace; +pub use bip32::WasmBIP32; pub use descriptor::WrapDescriptor; +pub use ecpair::WasmECPair; pub use fixed_script_wallet::FixedScriptWalletNamespace; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; +pub use replay_protection::WasmReplayProtection; pub use utxolib_compat::UtxolibCompatNamespace; +pub use wallet_keys::WasmRootWalletKeys; diff --git a/packages/wasm-utxo/src/wasm/replay_protection.rs b/packages/wasm-utxo/src/wasm/replay_protection.rs new file mode 100644 index 00000000..6f749b55 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/replay_protection.rs @@ -0,0 +1,106 @@ +use wasm_bindgen::prelude::*; + +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::replay_protection::ReplayProtection; + +/// WASM wrapper for ReplayProtection +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct WasmReplayProtection { + inner: ReplayProtection, +} + +#[wasm_bindgen] +impl WasmReplayProtection { + /// Create from output scripts directly + #[wasm_bindgen] + pub fn from_output_scripts(output_scripts: Box<[js_sys::Uint8Array]>) -> WasmReplayProtection { + let scripts = output_scripts + .iter() + .map(|arr| { + let bytes = arr.to_vec(); + miniscript::bitcoin::ScriptBuf::from_bytes(bytes) + }) + .collect(); + WasmReplayProtection { + inner: ReplayProtection::new(scripts), + } + } + + /// Create from addresses (requires network for decoding) + #[wasm_bindgen] + pub fn from_addresses( + addresses: Box<[JsValue]>, + network: &str, + ) -> Result { + // Parse network + let network = crate::networks::Network::from_utxolib_name(network) + .or_else(|| crate::networks::Network::from_coin_name(network)) + .ok_or_else(|| { + WasmUtxoError::new(&format!( + "Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') or coin name (e.g., 'btc', 'tbtc')", + network + )) + })?; + + // Convert addresses to scripts + let mut scripts = Vec::new(); + for (i, addr) in addresses.iter().enumerate() { + let address_str = addr.as_string().ok_or_else(|| { + WasmUtxoError::new(&format!("Address at index {} is not a string", i)) + })?; + + let script = + crate::address::networks::to_output_script_with_network(&address_str, network) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to decode address '{}': {}", + address_str, e + )) + })?; + scripts.push(script); + } + + Ok(WasmReplayProtection { + inner: ReplayProtection::new(scripts), + }) + } + + /// Create from public keys (derives P2SH-P2PK output scripts) + #[wasm_bindgen] + pub fn from_public_keys( + public_keys: Box<[js_sys::Uint8Array]>, + ) -> Result { + let compressed_keys = public_keys + .iter() + .enumerate() + .map(|(i, arr)| { + let bytes = arr.to_vec(); + + if bytes.len() != 33 { + return Err(WasmUtxoError::new(&format!( + "Public key at index {} has invalid length: {} (expected 33 bytes)", + i, + bytes.len() + ))); + } + + miniscript::bitcoin::CompressedPublicKey::from_slice(&bytes).map_err(|e| { + WasmUtxoError::new(&format!("Invalid public key at index {}: {}", i, e)) + }) + }) + .collect::, _>>()?; + + Ok(WasmReplayProtection { + inner: ReplayProtection::from_public_keys(compressed_keys), + }) + } +} + +// Non-WASM methods for internal use +impl WasmReplayProtection { + /// Get the inner ReplayProtection (for internal Rust use, not exposed to JS) + pub(crate) fn inner(&self) -> &ReplayProtection { + &self.inner + } +} diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index 73ef0c6a..7371ca13 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -73,59 +73,6 @@ pub(crate) fn get_optional_field( } } -// Helper function to get an array field -pub(crate) fn get_array_field(obj: &JsValue, key: &str) -> Result { - use wasm_bindgen::JsCast; - - let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) - .map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?; - - field_value - .dyn_into::() - .map_err(|_| WasmUtxoError::new(&format!("{} must be an array", key))) -} - -// Helper function to get a string array field -pub(crate) fn get_string_array_field( - obj: &JsValue, - key: &str, -) -> Result, WasmUtxoError> { - let array = get_array_field(obj, key)?; - let mut result = Vec::new(); - - for i in 0..array.length() { - let item = array.get(i); - let string = item - .as_string() - .ok_or_else(|| WasmUtxoError::new(&format!("{} items must be strings", key)))?; - result.push(string); - } - - Ok(result) -} - -// Helper function to get a buffer array field (array of Uint8Array/Buffer) -pub(crate) fn get_buffer_array_field( - obj: &JsValue, - key: &str, -) -> Result>, WasmUtxoError> { - use wasm_bindgen::JsCast; - - let array = get_array_field(obj, key)?; - let mut result = Vec::new(); - - for i in 0..array.length() { - let item = array.get(i); - let buffer = item - .dyn_into::() - .map_err(|_| WasmUtxoError::new(&format!("{} items must be Uint8Array/Buffer", key)))?; - - result.push(buffer.to_vec()); - } - - Ok(result) -} - // Helper function to get a nested field using dot notation (e.g., "network.bip32.public") pub(crate) fn get_nested_field( obj: &JsValue, diff --git a/packages/wasm-utxo/src/wasm/wallet_keys.rs b/packages/wasm-utxo/src/wasm/wallet_keys.rs new file mode 100644 index 00000000..3d47d807 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/wallet_keys.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; +use wasm_bindgen::prelude::*; + +use crate::bitcoin::bip32::DerivationPath; +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::RootWalletKeys; +use crate::wasm::bip32::WasmBIP32; + +/// WASM wrapper for RootWalletKeys +/// Represents a set of three extended public keys with their derivation prefixes +#[wasm_bindgen] +#[derive(Clone)] +pub struct WasmRootWalletKeys { + inner: RootWalletKeys, +} + +impl WasmRootWalletKeys { + /// Get a reference to the inner RootWalletKeys + pub(crate) fn inner(&self) -> &RootWalletKeys { + &self.inner + } +} + +#[wasm_bindgen] +impl WasmRootWalletKeys { + /// Create a RootWalletKeys from three BIP32 keys + /// Uses default derivation prefix of m/0/0 for all three keys + /// + /// # Arguments + /// - `user`: User key (first xpub) + /// - `backup`: Backup key (second xpub) + /// - `bitgo`: BitGo key (third xpub) + #[wasm_bindgen(constructor)] + pub fn new( + user: &WasmBIP32, + backup: &WasmBIP32, + bitgo: &WasmBIP32, + ) -> Result { + let xpubs = [user.to_xpub()?, backup.to_xpub()?, bitgo.to_xpub()?]; + let inner = RootWalletKeys::new_with_derivation_prefixes( + xpubs, + [ + DerivationPath::from_str("m/0/0").unwrap(), + DerivationPath::from_str("m/0/0").unwrap(), + DerivationPath::from_str("m/0/0").unwrap(), + ], + ); + Ok(WasmRootWalletKeys { inner }) + } + + /// Create a RootWalletKeys from three BIP32 keys with custom derivation prefixes + /// + /// # Arguments + /// - `user`: User key (first xpub) + /// - `backup`: Backup key (second xpub) + /// - `bitgo`: BitGo key (third xpub) + /// - `user_derivation`: Derivation path for user key (e.g., "m/0/0") + /// - `backup_derivation`: Derivation path for backup key (e.g., "m/0/0") + /// - `bitgo_derivation`: Derivation path for bitgo key (e.g., "m/0/0") + #[wasm_bindgen] + pub fn with_derivation_prefixes( + user: &WasmBIP32, + backup: &WasmBIP32, + bitgo: &WasmBIP32, + user_derivation: &str, + backup_derivation: &str, + bitgo_derivation: &str, + ) -> Result { + let xpubs = [user.to_xpub()?, backup.to_xpub()?, bitgo.to_xpub()?]; + + let derivation_paths = [user_derivation, backup_derivation, bitgo_derivation] + .iter() + .map(|p| { + // Remove leading 'm/' if present and add it back + let p = p.strip_prefix("m/").unwrap_or(p); + DerivationPath::from_str(&format!("m/{}", p)) + .map_err(|e| WasmUtxoError::new(&format!("Invalid derivation prefix: {}", e))) + }) + .collect::, _>>()? + .try_into() + .map_err(|_| WasmUtxoError::new("Failed to convert derivation paths"))?; + + let inner = RootWalletKeys::new_with_derivation_prefixes(xpubs, derivation_paths); + Ok(WasmRootWalletKeys { inner }) + } + + /// Get the user key (first xpub) + #[wasm_bindgen] + pub fn user_key(&self) -> WasmBIP32 { + WasmBIP32::from_xpub_internal(*self.inner.user_key()) + } + + /// Get the backup key (second xpub) + #[wasm_bindgen] + pub fn backup_key(&self) -> WasmBIP32 { + WasmBIP32::from_xpub_internal(*self.inner.backup_key()) + } + + /// Get the bitgo key (third xpub) + #[wasm_bindgen] + pub fn bitgo_key(&self) -> WasmBIP32 { + WasmBIP32::from_xpub_internal(*self.inner.bitgo_key()) + } +} diff --git a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs b/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs deleted file mode 100644 index 20f439a0..00000000 --- a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::convert::TryInto; -use std::str::FromStr; - -use crate::bitcoin::bip32::DerivationPath; -use crate::error::WasmUtxoError; -use crate::fixed_script_wallet::{xpub_triple_from_strings, RootWalletKeys, XpubTriple}; -use crate::wasm::bip32interface::xpub_from_bip32interface; -use wasm_bindgen::JsValue; - -pub fn xpub_triple_from_jsvalue(keys: &JsValue) -> Result { - let keys_array = js_sys::Array::from(keys); - if keys_array.length() != 3 { - return Err(WasmUtxoError::new("Expected exactly 3 xpub keys")); - } - - let key_strings: Result<[String; 3], _> = (0..3) - .map(|i| { - keys_array - .get(i) - .as_string() - .ok_or_else(|| WasmUtxoError::new(&format!("Key at index {} is not a string", i))) - }) - .collect::, _>>() - .and_then(|v| { - v.try_into() - .map_err(|_| WasmUtxoError::new("Failed to convert to array")) - }); - - xpub_triple_from_strings(&key_strings?) -} - -pub fn root_wallet_keys_from_jsvalue(keys: &JsValue) -> Result { - // Check if keys is an array (xpub strings) or an object (WalletKeys/RootWalletKeys) - if js_sys::Array::is_array(keys) { - // Handle array of xpub strings - let xpubs = xpub_triple_from_jsvalue(keys)?; - Ok(RootWalletKeys::new_with_derivation_prefixes( - xpubs, - [ - DerivationPath::from_str("m/0/0").unwrap(), - DerivationPath::from_str("m/0/0").unwrap(), - DerivationPath::from_str("m/0/0").unwrap(), - ], - )) - } else if keys.is_object() { - // Handle WalletKeys/RootWalletKeys object - let obj = js_sys::Object::from(keys.clone()); - - // Get the triple property - let triple = js_sys::Reflect::get(&obj, &JsValue::from_str("triple")) - .map_err(|_| WasmUtxoError::new("Failed to get 'triple' property"))?; - - if !js_sys::Array::is_array(&triple) { - return Err(WasmUtxoError::new("'triple' property must be an array")); - } - - let triple_array = js_sys::Array::from(&triple); - if triple_array.length() != 3 { - return Err(WasmUtxoError::new("'triple' must contain exactly 3 keys")); - } - - // Extract xpubs from BIP32Interface objects - let xpubs: XpubTriple = (0..3) - .map(|i| { - let bip32_key = triple_array.get(i); - xpub_from_bip32interface(&bip32_key) - }) - .collect::, _>>()? - .try_into() - .map_err(|_| WasmUtxoError::new("Failed to convert to array"))?; - - // Try to get derivationPrefixes if present (for RootWalletKeys) - let derivation_prefixes = - js_sys::Reflect::get(&obj, &JsValue::from_str("derivationPrefixes")) - .ok() - .and_then(|prefixes| { - if prefixes.is_undefined() || prefixes.is_null() { - return None; - } - - if !js_sys::Array::is_array(&prefixes) { - return None; - } - - let prefixes_array = js_sys::Array::from(&prefixes); - if prefixes_array.length() != 3 { - return None; - } - - let prefix_strings: Result<[String; 3], _> = (0..3) - .map(|i| { - prefixes_array - .get(i) - .as_string() - .ok_or_else(|| WasmUtxoError::new("Prefix is not a string")) - }) - .collect::, _>>() - .and_then(|v| { - v.try_into() - .map_err(|_| WasmUtxoError::new("Failed to convert to array")) - }); - - prefix_strings.ok() - }); - - // Convert prefix strings to DerivationPath - let derivation_paths = if let Some(prefixes) = derivation_prefixes { - prefixes - .iter() - .map(|p| { - // Remove leading 'm/' if present and add it back - let p = p.strip_prefix("m/").unwrap_or(p); - DerivationPath::from_str(&format!("m/{}", p)).map_err(|e| { - WasmUtxoError::new(&format!("Invalid derivation prefix: {}", e)) - }) - }) - .collect::, _>>()? - .try_into() - .map_err(|_| WasmUtxoError::new("Failed to convert derivation paths"))? - } else { - [ - DerivationPath::from_str("m/0/0").unwrap(), - DerivationPath::from_str("m/0/0").unwrap(), - DerivationPath::from_str("m/0/0").unwrap(), - ] - }; - - Ok(RootWalletKeys::new_with_derivation_prefixes( - xpubs, - derivation_paths, - )) - } else { - Err(WasmUtxoError::new( - "Expected array of xpub strings or WalletKeys object", - )) - } -} diff --git a/packages/wasm-utxo/test/bip32.ts b/packages/wasm-utxo/test/bip32.ts new file mode 100644 index 00000000..4ddd8f76 --- /dev/null +++ b/packages/wasm-utxo/test/bip32.ts @@ -0,0 +1,339 @@ +import * as assert from "assert"; +import { bip32 as utxolibBip32 } from "@bitgo/utxo-lib"; +import { BIP32 } from "../js/bip32.js"; + +const bip32 = { BIP32 }; + +describe("WasmBIP32", () => { + it("should create from base58 xpub", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = bip32.BIP32.fromBase58(xpub); + + assert.strictEqual(key.isNeutered(), true); + assert.strictEqual(key.depth, 3); + assert.strictEqual(key.toBase58(), xpub); + + // Verify properties exist + assert.ok(key.chainCode instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.ok(key.identifier instanceof Uint8Array); + assert.ok(key.fingerprint instanceof Uint8Array); + assert.strictEqual(key.privateKey, undefined); + }); + + it("should create from base58 xprv", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = bip32.BIP32.fromBase58(xprv); + + assert.strictEqual(key.isNeutered(), false); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.toBase58(), xprv); + + // Verify properties exist + assert.ok(key.chainCode instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.identifier instanceof Uint8Array); + assert.ok(key.fingerprint instanceof Uint8Array); + }); + + it("should derive child keys", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = bip32.BIP32.fromBase58(xpub); + + const child = key.derive(0); + assert.strictEqual(child.depth, 4); + assert.strictEqual(child.isNeutered(), true); + }); + + it("should derive using path", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = bip32.BIP32.fromBase58(xprv); + + const derived1 = key.derivePath("0/1/2"); + assert.strictEqual(derived1.depth, 3); + + const derived2 = key.derivePath("m/0/1/2"); + assert.strictEqual(derived2.depth, 3); + + // Both should produce the same result + assert.strictEqual(derived1.toBase58(), derived2.toBase58()); + }); + + it("should neutered a private key", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = bip32.BIP32.fromBase58(xprv); + const neuteredKey = key.neutered(); + + assert.strictEqual(neuteredKey.isNeutered(), true); + assert.strictEqual(neuteredKey.privateKey, undefined); + assert.ok(neuteredKey.publicKey instanceof Uint8Array); + }); + + it("should derive hardened keys from private key", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = bip32.BIP32.fromBase58(xprv); + + const hardened = key.deriveHardened(0); + assert.strictEqual(hardened.depth, 1); + assert.strictEqual(hardened.isNeutered(), false); + }); + + it("should fail to derive hardened from public key", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = bip32.BIP32.fromBase58(xpub); + + assert.throws(() => { + key.deriveHardened(0); + }); + }); + + it("should export to WIF", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + const key = bip32.BIP32.fromBase58(xprv); + + const wif = key.toWIF(); + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + }); + + it("should fail to export WIF from public key", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + const key = bip32.BIP32.fromBase58(xpub); + + assert.throws(() => { + key.toWIF(); + }); + }); + + it("should create from seed", () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + seed[i] = i; + } + + const key = bip32.BIP32.fromSeed(seed); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.isNeutered(), false); + assert.ok(key.privateKey instanceof Uint8Array); + }); + + it("should create from seed with network", () => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + seed[i] = i; + } + + const key = bip32.BIP32.fromSeed(seed, "BitcoinTestnet3"); + assert.strictEqual(key.depth, 0); + assert.strictEqual(key.isNeutered(), false); + assert.ok(key.toBase58().startsWith("tprv")); + }); +}); + +describe("WasmBIP32 parity with utxolib", () => { + function bufferEqual(a: Uint8Array, b: Buffer): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + it("should match utxolib when creating from base58 xpub", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + + const wasmKey = bip32.BIP32.fromBase58(xpub); + const utxolibKey = utxolibBip32.fromBase58(xpub); + + // Compare all properties + assert.strictEqual(wasmKey.toBase58(), utxolibKey.toBase58()); + assert.strictEqual(wasmKey.depth, utxolibKey.depth); + assert.strictEqual(wasmKey.index, utxolibKey.index); + assert.strictEqual(wasmKey.parentFingerprint, utxolibKey.parentFingerprint); + assert.strictEqual(wasmKey.isNeutered(), utxolibKey.isNeutered()); + assert.ok(bufferEqual(wasmKey.chainCode, utxolibKey.chainCode)); + assert.ok(bufferEqual(wasmKey.publicKey, utxolibKey.publicKey)); + assert.ok(bufferEqual(wasmKey.identifier, utxolibKey.identifier)); + assert.ok(bufferEqual(wasmKey.fingerprint, utxolibKey.fingerprint)); + }); + + it("should match utxolib when creating from base58 xprv", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + // Compare all properties + assert.strictEqual(wasmKey.toBase58(), utxolibKey.toBase58()); + assert.strictEqual(wasmKey.depth, utxolibKey.depth); + assert.strictEqual(wasmKey.index, utxolibKey.index); + assert.strictEqual(wasmKey.parentFingerprint, utxolibKey.parentFingerprint); + assert.strictEqual(wasmKey.isNeutered(), utxolibKey.isNeutered()); + assert.ok(bufferEqual(wasmKey.chainCode, utxolibKey.chainCode)); + assert.ok(bufferEqual(wasmKey.publicKey, utxolibKey.publicKey)); + assert.ok(bufferEqual(wasmKey.identifier, utxolibKey.identifier)); + assert.ok(bufferEqual(wasmKey.fingerprint, utxolibKey.fingerprint)); + assert.ok( + wasmKey.privateKey && + utxolibKey.privateKey && + bufferEqual(wasmKey.privateKey, utxolibKey.privateKey), + ); + }); + + it("should match utxolib when deriving normal child keys", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + // Derive several children and compare + for (const index of [0, 1, 10, 100, 2147483647]) { + const wasmChild = wasmKey.derive(index); + const utxolibChild = utxolibKey.derive(index); + + assert.strictEqual(wasmChild.toBase58(), utxolibChild.toBase58(), `Failed at index ${index}`); + assert.ok(bufferEqual(wasmChild.publicKey, utxolibChild.publicKey)); + assert.ok(bufferEqual(wasmChild.chainCode, utxolibChild.chainCode)); + } + }); + + it("should match utxolib when deriving hardened child keys", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + // Derive several hardened children and compare + for (const index of [0, 1, 10, 2147483647]) { + const wasmChild = wasmKey.deriveHardened(index); + const utxolibChild = utxolibKey.deriveHardened(index); + + assert.strictEqual( + wasmChild.toBase58(), + utxolibChild.toBase58(), + `Failed at hardened index ${index}`, + ); + assert.ok(bufferEqual(wasmChild.publicKey, utxolibChild.publicKey)); + assert.ok(bufferEqual(wasmChild.chainCode, utxolibChild.chainCode)); + } + }); + + it("should match utxolib when deriving using paths", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + const paths = ["0", "0/1", "0/1/2", "m/0/1/2", "0'/1", "m/44'/0'/0'", "m/44'/0'/0'/0/0"]; + + for (const path of paths) { + const wasmDerived = wasmKey.derivePath(path); + const utxolibDerived = utxolibKey.derivePath(path); + + assert.strictEqual( + wasmDerived.toBase58(), + utxolibDerived.toBase58(), + `Failed at path ${path}`, + ); + assert.ok(bufferEqual(wasmDerived.publicKey, utxolibDerived.publicKey)); + assert.ok(bufferEqual(wasmDerived.chainCode, utxolibDerived.chainCode)); + } + }); + + it("should match utxolib when deriving from public keys", () => { + const xpub = + "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5"; + + const wasmKey = bip32.BIP32.fromBase58(xpub); + const utxolibKey = utxolibBip32.fromBase58(xpub); + + // Derive several children from public key + for (const index of [0, 1, 10, 100]) { + const wasmChild = wasmKey.derive(index); + const utxolibChild = utxolibKey.derive(index); + + assert.strictEqual(wasmChild.toBase58(), utxolibChild.toBase58()); + assert.ok(bufferEqual(wasmChild.publicKey, utxolibChild.publicKey)); + } + }); + + it("should match utxolib when neutering", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + const wasmNeutered = wasmKey.neutered(); + const utxolibNeutered = utxolibKey.neutered(); + + assert.strictEqual(wasmNeutered.toBase58(), utxolibNeutered.toBase58()); + assert.ok(bufferEqual(wasmNeutered.publicKey, utxolibNeutered.publicKey)); + assert.ok(bufferEqual(wasmNeutered.chainCode, utxolibNeutered.chainCode)); + assert.strictEqual(wasmNeutered.privateKey, undefined); + }); + + it("should match utxolib when exporting to WIF", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + assert.strictEqual(wasmKey.toWIF(), utxolibKey.toWIF()); + }); + + it("should match utxolib for BIP44 wallet derivation (m/44'/0'/0'/0/0)", () => { + const seed = Buffer.from( + "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542", + "hex", + ); + + const wasmMaster = bip32.BIP32.fromSeed(seed); + const utxolibMaster = utxolibBip32.fromSeed(seed); + + // Standard BIP44 path for Bitcoin: m/44'/0'/0'/0/0 + const path = "m/44'/0'/0'/0/0"; + + const wasmDerived = wasmMaster.derivePath(path); + const utxolibDerived = utxolibMaster.derivePath(path); + + assert.strictEqual(wasmDerived.toBase58(), utxolibDerived.toBase58()); + assert.ok(bufferEqual(wasmDerived.publicKey, utxolibDerived.publicKey)); + assert.ok(bufferEqual(wasmDerived.chainCode, utxolibDerived.chainCode)); + }); + + it("should produce same fingerprint for derived keys", () => { + const xprv = + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; + + const wasmKey = bip32.BIP32.fromBase58(xprv); + const utxolibKey = utxolibBip32.fromBase58(xprv); + + // Derive a child and check its parent fingerprint matches the parent's fingerprint + const wasmChild = wasmKey.derive(0); + const utxolibChild = utxolibKey.derive(0); + + // Parent fingerprints should match + assert.strictEqual(wasmChild.parentFingerprint, utxolibChild.parentFingerprint); + + // The parent fingerprint should match the parent's fingerprint + const wasmParentFp = new DataView(wasmKey.fingerprint.buffer).getUint32(0, false); + assert.strictEqual(wasmChild.parentFingerprint, wasmParentFp); + }); +}); diff --git a/packages/wasm-utxo/test/ecpair.ts b/packages/wasm-utxo/test/ecpair.ts new file mode 100644 index 00000000..effed3e4 --- /dev/null +++ b/packages/wasm-utxo/test/ecpair.ts @@ -0,0 +1,180 @@ +import * as assert from "assert"; +import { ECPair } from "../js/ecpair.js"; + +describe("WasmECPair", () => { + const testPrivateKey = Buffer.from( + "1111111111111111111111111111111111111111111111111111111111111111", + "hex", + ); + + const testWifMainnet = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"; + const testWifTestnet = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87JcbXMTcA"; + + it("should create from private key", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + assert.strictEqual(key.publicKey.length, 33); // Always compressed + }); + + it("should create from public key", () => { + const tempKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = tempKey.publicKey; + + const key = ECPair.fromPublicKey(publicKey); + + assert.strictEqual(key.privateKey, undefined); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.publicKey.length, 33); + }); + + it("should create from mainnet WIF", () => { + const key = ECPair.fromWIF(testWifMainnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + }); + + it("should create from testnet WIF", () => { + const key = ECPair.fromWIF(testWifTestnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + assert.strictEqual(key.privateKey.length, 32); + }); + + it("should create from mainnet WIF using fromWIFMainnet", () => { + const key = ECPair.fromWIFMainnet(testWifMainnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + }); + + it("should create from testnet WIF using fromWIFTestnet", () => { + const key = ECPair.fromWIFTestnet(testWifTestnet); + + assert.ok(key.privateKey instanceof Uint8Array); + assert.ok(key.publicKey instanceof Uint8Array); + }); + + it("should fail when using wrong network WIF method", () => { + assert.throws(() => { + ECPair.fromWIFMainnet(testWifTestnet); + }); + + assert.throws(() => { + ECPair.fromWIFTestnet(testWifMainnet); + }); + }); + + it("should export to WIF mainnet", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const wif = key.toWIF(); + + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + assert.ok(wif.startsWith("K") || wif.startsWith("L")); // Mainnet compressed + }); + + it("should export to WIF testnet", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const wif = key.toWIFTestnet(); + + assert.ok(typeof wif === "string"); + assert.ok(wif.length > 0); + assert.ok(wif.startsWith("c")); // Testnet compressed + }); + + it("should roundtrip WIF mainnet", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const wif = key1.toWIF(); + const key2 = ECPair.fromWIF(wif); + + assert.deepStrictEqual(key1.privateKey, key2.privateKey); + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); + + it("should roundtrip WIF testnet", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const wif = key1.toWIFTestnet(); + const key2 = ECPair.fromWIF(wif); + + assert.deepStrictEqual(key1.privateKey, key2.privateKey); + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); + + it("should fail to export WIF from public key", () => { + const tempKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = tempKey.publicKey; + const key = ECPair.fromPublicKey(publicKey); + + assert.throws(() => { + key.toWIF(); + }); + + assert.throws(() => { + key.toWIFMainnet(); + }); + + assert.throws(() => { + key.toWIFTestnet(); + }); + }); + + it("should reject invalid private keys", () => { + // All zeros + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(32)); + }); + + // Wrong length + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(31)); + }); + + assert.throws(() => { + ECPair.fromPrivateKey(new Uint8Array(33)); + }); + }); + + it("should reject invalid public keys", () => { + // Wrong length + assert.throws(() => { + ECPair.fromPublicKey(new Uint8Array(32)); + }); + + assert.throws(() => { + ECPair.fromPublicKey(new Uint8Array(34)); + }); + + // Invalid format + assert.throws(() => { + const invalidPubkey = new Uint8Array(33); + invalidPubkey[0] = 0x01; // Invalid prefix + ECPair.fromPublicKey(invalidPubkey); + }); + }); + + it("should always produce compressed public keys", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromWIF(testWifMainnet); + + // All public keys should be 33 bytes (compressed) + assert.strictEqual(key1.publicKey.length, 33); + assert.strictEqual(key2.publicKey.length, 33); + + // All should start with 0x02 or 0x03 (compressed format) + assert.ok(key1.publicKey[0] === 0x02 || key1.publicKey[0] === 0x03); + assert.ok(key2.publicKey[0] === 0x02 || key2.publicKey[0] === 0x03); + }); + + it("should derive same public key from same private key", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromPrivateKey(testPrivateKey); + + assert.deepStrictEqual(key1.publicKey, key2.publicKey); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index d482a516..4423ca80 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -1,8 +1,12 @@ +import assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import * as utxolib from "@bitgo/utxo-lib"; +import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import { BIP32, type BIP32Interface } from "../../js/bip32.js"; +import { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import { ECPair } from "../../js/ecpair.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -117,24 +121,27 @@ export function loadPsbtFixture(network: string, signatureState: string): Fixtur /** * Load wallet keys from fixture */ -export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWalletKeys { - const fixturePath = path.join( - __dirname, - "..", - "fixtures", - "fixed-script", - `psbt-lite.${network}.fullsigned.json`, - ); - const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); - const fixture = JSON.parse(fixtureContent) as Fixture; - +export function loadWalletKeysFromFixture(fixture: Fixture): RootWalletKeys { // Parse xprvs and convert to xpubs const xpubs = fixture.walletKeys.map((xprv) => { - const key = utxolib.bip32.fromBase58(xprv); + const key = BIP32.fromBase58(xprv); return key.neutered(); - }); + }) as unknown as Triple; + + const walletKeysLike: IWalletKeys = { + triple: xpubs, + derivationPrefixes: ["0/0", "0/0", "0/0"], + }; + + return RootWalletKeys.from(walletKeysLike); +} - return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); +export function loadReplayProtectionKeyFromFixture(fixture: Fixture): ECPair { + // underived user key + const userBip32 = BIP32.fromBase58(fixture.walletKeys[0]); + assert(userBip32.privateKey); + const userECPair = ECPair.fromPrivateKey(Buffer.from(userBip32.privateKey)); + return userECPair; } /** diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 6b47d283..46e0d357 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -1,8 +1,16 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; import { fixedScriptWallet } from "../../js/index.js"; -import { BitGoPsbt, InputScriptType } from "../../js/fixedScriptWallet.js"; -import { loadPsbtFixture, loadWalletKeysFromFixture, getPsbtBuffer } from "./fixtureUtil.js"; +import { BitGoPsbt, InputScriptType } from "../../js/fixedScriptWallet/index.js"; +import type { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import type { ECPair } from "../../js/index.js"; +import { + loadPsbtFixture, + loadWalletKeysFromFixture, + getPsbtBuffer, + loadReplayProtectionKeyFromFixture, + type Fixture, +} from "./fixtureUtil.js"; function getExpectedInputScriptType(fixtureScriptType: string): InputScriptType { // Map fixture types to InputScriptType values @@ -30,12 +38,6 @@ function getOtherWalletKeys(): utxolib.bitgo.RootWalletKeys { } describe("parseTransactionWithWalletKeys", function () { - // Replay protection script that matches Rust tests - const replayProtectionScript = Buffer.from( - "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", - "hex", - ); - const supportedNetworks = utxolib.getNetworkList().filter((network) => { return ( utxolib.isMainnet(network) && @@ -53,14 +55,16 @@ describe("parseTransactionWithWalletKeys", function () { describe(`network: ${networkName}`, function () { let fullsignedPsbtBytes: Buffer; let bitgoPsbt: BitGoPsbt; - let rootWalletKeys: utxolib.bitgo.RootWalletKeys; - let fixture: ReturnType; + let rootWalletKeys: RootWalletKeys; + let replayProtectionKey: ECPair; + let fixture: Fixture; before(function () { fixture = loadPsbtFixture(networkName, "fullsigned"); fullsignedPsbtBytes = getPsbtBuffer(fixture); bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBytes, networkName); - rootWalletKeys = loadWalletKeysFromFixture(networkName); + rootWalletKeys = loadWalletKeysFromFixture(fixture); + replayProtectionKey = loadReplayProtectionKeyFromFixture(fixture); }); it("should have matching unsigned transaction ID", function () { @@ -74,7 +78,7 @@ describe("parseTransactionWithWalletKeys", function () { it("should parse transaction and identify internal/external outputs", function () { const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], + publicKeys: [replayProtectionKey], }); // Verify all inputs have addresses and values @@ -141,7 +145,7 @@ describe("parseTransactionWithWalletKeys", function () { it("should parse inputs with correct scriptType", function () { const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], + publicKeys: [replayProtectionKey], }); // Verify all inputs have scriptType matching fixture @@ -160,7 +164,7 @@ describe("parseTransactionWithWalletKeys", function () { assert.throws( () => { bitgoPsbt.parseTransactionWithWalletKeys(getOtherWalletKeys(), { - outputScripts: [replayProtectionScript], + publicKeys: [replayProtectionKey], }); }, (error: Error) => { diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts index 8cc0c0bb..a6b5b96c 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -1,12 +1,13 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { fixedScriptWallet } from "../../js/index.js"; -import { BitGoPsbt } from "../../js/fixedScriptWallet.js"; +import { fixedScriptWallet, BIP32, ECPair } from "../../js/index.js"; +import { BitGoPsbt, RootWalletKeys, ParsedTransaction } from "../../js/fixedScriptWallet/index.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, getPsbtBuffer, type Fixture, + loadReplayProtectionKeyFromFixture, } from "./fixtureUtil.js"; type SignatureStage = "unsigned" | "halfsigned" | "fullsigned"; @@ -61,18 +62,16 @@ function getExpectedSignatures( */ function verifyInputSignatures( bitgoPsbt: BitGoPsbt, - rootWalletKeys: utxolib.bitgo.RootWalletKeys, + parsed: ParsedTransaction, + rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, inputIndex: number, expectedSignatures: ExpectedSignatures, ): void { // Handle replay protection inputs (P2shP2pk) if ("hasReplayProtectionSignature" in expectedSignatures) { - const replayProtectionScript = Buffer.from( - "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", - "hex", - ); const hasReplaySig = bitgoPsbt.verifyReplayProtectionSignature(inputIndex, { - outputScripts: [replayProtectionScript], + publicKeys: [replayProtectionKey], }); assert.strictEqual( hasReplaySig, @@ -82,12 +81,24 @@ function verifyInputSignatures( return; } - // Handle standard multisig inputs - const xpubs = rootWalletKeys.triple; + if (parsed.inputs[inputIndex].scriptType === "p2shP2pk") { + const hasReplaySig = bitgoPsbt.verifySignature(inputIndex, replayProtectionKey); + assert.ok( + "hasReplayProtectionSignature" in expectedSignatures, + "Expected hasReplayProtectionSignature to be present", + ); + assert.strictEqual( + hasReplaySig, + expectedSignatures.hasReplayProtectionSignature, + `Input ${inputIndex} replay protection signature mismatch`, + ); + return; + } - const hasUserSig = bitgoPsbt.verifySignature(inputIndex, xpubs[0].toBase58()); - const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, xpubs[1].toBase58()); - const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, xpubs[2].toBase58()); + // Handle standard multisig inputs + const hasUserSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.userKey()); + const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.backupKey()); + const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.bitgoKey()); assert.strictEqual( hasUserSig, @@ -106,6 +117,37 @@ function verifyInputSignatures( ); } +/** + * Helper to verify signatures for all inputs in a PSBT + * @param bitgoPsbt - The PSBT to verify + * @param fixture - The test fixture containing input metadata + * @param rootWalletKeys - Wallet keys for verification + * @param replayProtectionKey - Key for replay protection inputs + * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) + */ +function verifyAllInputSignatures( + bitgoPsbt: BitGoPsbt, + fixture: Fixture, + rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, + signatureStage: SignatureStage, +): void { + const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + publicKeys: [replayProtectionKey], + }); + + fixture.psbtInputs.forEach((input, index) => { + verifyInputSignatures( + bitgoPsbt, + parsed, + rootWalletKeys, + replayProtectionKey, + index, + getExpectedSignatures(input.type, signatureStage), + ); + }); +} + describe("verifySignature", function () { const supportedNetworks = utxolib.getNetworkList().filter((network) => { return ( @@ -122,7 +164,8 @@ describe("verifySignature", function () { const networkName = utxolib.getNetworkName(network); describe(`network: ${networkName}`, function () { - let rootWalletKeys: utxolib.bitgo.RootWalletKeys; + let rootWalletKeys: RootWalletKeys; + let replayProtectionKey: ECPair; let unsignedFixture: Fixture; let halfsignedFixture: Fixture; let fullsignedFixture: Fixture; @@ -131,10 +174,11 @@ describe("verifySignature", function () { let fullsignedBitgoPsbt: BitGoPsbt; before(function () { - rootWalletKeys = loadWalletKeysFromFixture(networkName); unsignedFixture = loadPsbtFixture(networkName, "unsigned"); halfsignedFixture = loadPsbtFixture(networkName, "halfsigned"); fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); + rootWalletKeys = loadWalletKeysFromFixture(fullsignedFixture); + replayProtectionKey = loadReplayProtectionKeyFromFixture(fullsignedFixture); unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( getPsbtBuffer(unsignedFixture), networkName, @@ -151,52 +195,45 @@ describe("verifySignature", function () { describe("unsigned PSBT", function () { it("should return false for unsigned inputs", function () { - // Verify all xpubs return false for all inputs - unsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - unsignedBitgoPsbt, - rootWalletKeys, - index, - getExpectedSignatures(input.type, "unsigned"), - ); - }); + verifyAllInputSignatures( + unsignedBitgoPsbt, + unsignedFixture, + rootWalletKeys, + replayProtectionKey, + "unsigned", + ); }); }); describe("half-signed PSBT", function () { it("should return true for signed xpubs and false for unsigned", function () { - halfsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - halfsignedBitgoPsbt, - rootWalletKeys, - index, - getExpectedSignatures(input.type, "halfsigned"), - ); - }); + verifyAllInputSignatures( + halfsignedBitgoPsbt, + halfsignedFixture, + rootWalletKeys, + replayProtectionKey, + "halfsigned", + ); }); }); describe("fully signed PSBT", function () { it("should have 2 signatures (2-of-3 multisig)", function () { - // In fullsigned fixtures, verify 2 signatures exist per multisig input - fullsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - fullsignedBitgoPsbt, - rootWalletKeys, - index, - getExpectedSignatures(input.type, "fullsigned"), - ); - }); + verifyAllInputSignatures( + fullsignedBitgoPsbt, + fullsignedFixture, + rootWalletKeys, + replayProtectionKey, + "fullsigned", + ); }); }); describe("error handling", function () { it("should throw error for out of bounds input index", function () { - const xpubs = rootWalletKeys.triple; - assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(999, xpubs[0].toBase58()); + fullsignedBitgoPsbt.verifySignature(999, rootWalletKeys.userKey()); }, (error: Error) => { return error.message.includes("Input index 999 out of bounds"); @@ -211,7 +248,7 @@ describe("verifySignature", function () { fullsignedBitgoPsbt.verifySignature(0, "invalid-xpub"); }, (error: Error) => { - return error.message.includes("Invalid xpub"); + return error.message.includes("Invalid"); }, "Should throw error for invalid xpub", ); @@ -221,16 +258,66 @@ describe("verifySignature", function () { // Create a different xpub that's not in the wallet // Use a proper 32-byte seed (256 bits) const differentSeed = Buffer.alloc(32, 0xaa); // 32 bytes filled with 0xaa - const differentKey = utxolib.bip32.fromSeed(differentSeed, network); + const differentKey = BIP32.fromSeed(differentSeed); const differentXpub = differentKey.neutered(); - const result = fullsignedBitgoPsbt.verifySignature(0, differentXpub.toBase58()); + const result = fullsignedBitgoPsbt.verifySignature(0, differentXpub); assert.strictEqual( result, false, "Should return false for xpub not in PSBT derivation paths", ); }); + + it("should verify signature with raw public key (Uint8Array)", function () { + // Verify that xpub-based verification works + const userKey = rootWalletKeys.userKey(); + const hasXpubSig = fullsignedBitgoPsbt.verifySignature(0, userKey); + + // This test specifically checks that raw public key verification works + // We test the underlying WASM API by ensuring both xpub and raw pubkey + // calls reach the correct methods + + // Use a random public key that's not in the PSBT to test the API works + const randomSeed = Buffer.alloc(32, 0xcc); + const randomKey = BIP32.fromSeed(randomSeed); + const randomPubkey = randomKey.publicKey; + + // This should return false (no signature for this key) + const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + assert.strictEqual(result, false, "Should return false for public key not in PSBT"); + + // Verify the xpub check still works (regression test) + assert.strictEqual(hasXpubSig, true, "Should still verify with xpub"); + }); + + it("should return false for raw public key with no signature", function () { + // Create a random public key that's not in the PSBT + const randomSeed = Buffer.alloc(32, 0xbb); + const randomKey = BIP32.fromSeed(randomSeed); + const randomPubkey = randomKey.publicKey; + + const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + assert.strictEqual( + result, + false, + "Should return false for public key not in PSBT signatures", + ); + }); + + it("should throw error for invalid key length", function () { + const invalidKey = Buffer.alloc(31); // Invalid length (should be 32 for private key or 33 for public key) + + assert.throws( + () => { + fullsignedBitgoPsbt.verifySignature(0, invalidKey); + }, + (error: Error) => { + return error.message.includes("Invalid key length"); + }, + "Should throw error for invalid key length", + ); + }); }); }); });