From 4d7fb3d4ac3fd03cbc514d8c9bef63d83501385d Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 25 Nov 2025 15:56:23 +0100 Subject: [PATCH 1/9] feat(wasm-utxo): add ECPair implementation Add elliptic curve key pair functionality with ECPair class that wraps the WasmECPair Rust implementation. This provides key generation, import/export functions including WIF format, and supports both private and public keys. Issue: BTC-2786 Co-authored-by: llm-git --- packages/wasm-utxo/js/ecpair.ts | 162 +++++++++++++++++++++++ packages/wasm-utxo/js/index.ts | 2 + packages/wasm-utxo/src/wasm/ecpair.rs | 180 ++++++++++++++++++++++++++ packages/wasm-utxo/src/wasm/mod.rs | 2 + packages/wasm-utxo/test/ecpair.ts | 180 ++++++++++++++++++++++++++ 5 files changed, 526 insertions(+) create mode 100644 packages/wasm-utxo/js/ecpair.ts create mode 100644 packages/wasm-utxo/src/wasm/ecpair.rs create mode 100644 packages/wasm-utxo/test/ecpair.ts 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/index.ts b/packages/wasm-utxo/js/index.ts index 393a2b0d..755e4e36 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -9,6 +9,8 @@ export * as ast from "./ast/index.js"; export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet.js"; +export { ECPair } from "./ecpair.js"; + export type { CoinName } from "./coinName.js"; export type { Triple } from "./triple.js"; export type { AddressFormat } from "./address.js"; 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/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 17a4fbb0..561ada16 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -1,6 +1,7 @@ mod address; mod bip32interface; mod descriptor; +mod ecpair; mod fixed_script_wallet; mod miniscript; mod psbt; @@ -11,6 +12,7 @@ pub(crate) mod wallet_keys_helpers; pub use address::AddressNamespace; pub use descriptor::WrapDescriptor; +pub use ecpair::WasmECPair; pub use fixed_script_wallet::FixedScriptWalletNamespace; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; 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); + }); +}); From 93ce5dffdda988f5af17d7ebd5165c8382f6660a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 25 Nov 2025 15:57:10 +0100 Subject: [PATCH 2/9] feat(wasm-utxo): implement BIP32 extended key functionality Add complete BIP32 implementation with both Rust and TypeScript interfaces. The implementation handles key derivation for both private and public keys, including hardened derivation, path-based derivation, and WIF export. This provides a full replacement for the BIP32 functionality from utxo-lib, with test coverage to ensure API parity. Issue: BTC-2786 Co-authored-by: llm-git --- packages/wasm-utxo/js/bip32.ts | 226 ++++++++++++ packages/wasm-utxo/js/index.ts | 1 + packages/wasm-utxo/src/wasm/bip32.rs | 337 +++++++++++++++++ packages/wasm-utxo/src/wasm/bip32interface.rs | 55 --- packages/wasm-utxo/src/wasm/mod.rs | 3 +- packages/wasm-utxo/src/wasm/wallet_keys.rs | 115 ++++++ .../wasm-utxo/src/wasm/wallet_keys_helpers.rs | 5 +- packages/wasm-utxo/test/bip32.ts | 339 ++++++++++++++++++ 8 files changed, 1023 insertions(+), 58 deletions(-) create mode 100644 packages/wasm-utxo/js/bip32.ts create mode 100644 packages/wasm-utxo/src/wasm/bip32.rs delete mode 100644 packages/wasm-utxo/src/wasm/bip32interface.rs create mode 100644 packages/wasm-utxo/src/wasm/wallet_keys.rs create mode 100644 packages/wasm-utxo/test/bip32.ts 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/index.ts b/packages/wasm-utxo/js/index.ts index 755e4e36..f8f0f8c8 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -10,6 +10,7 @@ export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet.js"; export { ECPair } from "./ecpair.js"; +export { BIP32 } from "./bip32.js"; export type { CoinName } from "./coinName.js"; export type { Triple } from "./triple.js"; 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/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 561ada16..0c39bbe9 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -1,5 +1,5 @@ mod address; -mod bip32interface; +mod bip32; mod descriptor; mod ecpair; mod fixed_script_wallet; @@ -11,6 +11,7 @@ mod utxolib_compat; pub(crate) mod wallet_keys_helpers; pub use address::AddressNamespace; +pub use bip32::WasmBIP32; pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; pub use fixed_script_wallet::FixedScriptWalletNamespace; 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..13c40a10 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/wallet_keys.rs @@ -0,0 +1,115 @@ +use std::str::FromStr; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; + +use crate::bitcoin::bip32::DerivationPath; +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::RootWalletKeys; +use crate::wasm::bip32::WasmBIP32; +use crate::wasm::wallet_keys_helpers::root_wallet_keys_from_jsvalue; + +/// 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 any compatible format + /// Uses default derivation prefix of m/0/0 for all three keys + #[wasm_bindgen(constructor)] + pub fn new(xpubs: JsValue) -> Result { + let inner = root_wallet_keys_from_jsvalue(&xpubs)?; + Ok(WasmRootWalletKeys { inner }) + } + + /// Create a RootWalletKeys from three xpub strings + /// Uses default derivation prefix of m/0/0 for all three keys + #[wasm_bindgen] + pub fn new_from_xpubs(xpubs: JsValue) -> Result { + let inner = root_wallet_keys_from_jsvalue(&xpubs)?; + Ok(WasmRootWalletKeys { inner }) + } + + /// Create a RootWalletKeys from three xpub strings with custom derivation prefixes + /// + /// # Arguments + /// - `xpubs`: Array of 3 xpub strings or WalletKeys object + /// - `derivation_prefixes`: Array of 3 derivation path strings (e.g., ["m/0/0", "m/0/0", "m/0/0"]) + #[wasm_bindgen] + pub fn with_derivation_prefixes( + xpubs: JsValue, + derivation_prefixes: JsValue, + ) -> Result { + // First get the xpubs + let inner = root_wallet_keys_from_jsvalue(&xpubs)?; + + // Parse derivation prefixes if provided + if !derivation_prefixes.is_undefined() && !derivation_prefixes.is_null() { + let prefixes_array = js_sys::Array::from(&derivation_prefixes); + if prefixes_array.length() != 3 { + return Err(WasmUtxoError::new("Expected exactly 3 derivation prefixes")); + } + + 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")) + }); + + let derivation_paths: [DerivationPath; 3] = prefix_strings? + .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"))?; + + Ok(WasmRootWalletKeys { + inner: RootWalletKeys::new_with_derivation_prefixes(inner.xpubs, derivation_paths), + }) + } else { + 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 index 20f439a0..195aecf8 100644 --- a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs +++ b/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs @@ -4,7 +4,7 @@ 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 crate::wasm::bip32::WasmBIP32; use wasm_bindgen::JsValue; pub fn xpub_triple_from_jsvalue(keys: &JsValue) -> Result { @@ -63,7 +63,8 @@ pub fn root_wallet_keys_from_jsvalue(keys: &JsValue) -> Result, _>>()? .try_into() 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); + }); +}); From 878d8f1ab8e4960513026554843a3a8853e2d92c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 25 Nov 2025 15:57:31 +0100 Subject: [PATCH 3/9] feat(wasm-utxo): add RootWalletKeys class wrapper for wallet keys Added a new TypeScript class wrapper for wallet keys that provides a more type-safe and consistent API over the raw WASM bindings. - Created RootWalletKeys class with proper static factory methods - Updated fixedScriptWallet to work with the new RootWalletKeys class - Extended BitGoPsbt.verifySignature to support both BIP32 and ECPair - Improved README.md with detailed architecture patterns documentation - Updated tests to use the new API This implements a clean class wrapper pattern similar to BIP32 and ECPair, maintaining a consistent API style across the library. Issue: BTC-2786 Co-authored-by: llm-git --- packages/wasm-utxo/js/README.md | 159 +++++++++++++++--- packages/wasm-utxo/js/WalletKeys.ts | 156 +++++++++++++++++ packages/wasm-utxo/js/fixedScriptWallet.ts | 75 ++++++--- packages/wasm-utxo/js/index.ts | 2 + packages/wasm-utxo/js/utxolibCompat.ts | 21 --- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 93 ++++++++-- packages/wasm-utxo/src/lib.rs | 4 +- .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 88 +++++++--- packages/wasm-utxo/src/wasm/mod.rs | 2 + packages/wasm-utxo/src/wasm/wallet_keys.rs | 105 ++++++------ .../wasm-utxo/src/wasm/wallet_keys_helpers.rs | 2 + .../wasm-utxo/test/fixedScript/fixtureUtil.ts | 17 +- .../parseTransactionWithWalletKeys.ts | 12 +- .../test/fixedScript/verifySignature.ts | 75 +++++++-- 14 files changed, 627 insertions(+), 184 deletions(-) create mode 100644 packages/wasm-utxo/js/WalletKeys.ts 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/WalletKeys.ts b/packages/wasm-utxo/js/WalletKeys.ts new file mode 100644 index 00000000..f177c674 --- /dev/null +++ b/packages/wasm-utxo/js/WalletKeys.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 WasmBIP32 instances + */ +function toBIP32Triple(keys: WalletKeysArg): Triple { + if (keys instanceof RootWalletKeys) { + return [keys.userKey().wasm, keys.backupKey().wasm, keys.bitgoKey().wasm]; + } + + // 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).wasm) as Triple; + } + + // Otherwise it's a triple of strings (xpubs) + return keys.map((xpub) => 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, + backup, + bitgo, + derivationPrefixes[0], + derivationPrefixes[1], + derivationPrefixes[2], + ) + : new WasmRootWalletKeys(user, backup, bitgo); + + 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.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index 62756fee..7c359893 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -1,27 +1,24 @@ import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo.js"; -import type { UtxolibName, UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat.js"; +import { type WalletKeysArg, RootWalletKeys } from "./WalletKeys.js"; +import { type BIP32Arg, BIP32 } from "./bip32.js"; +import { type ECPairArg, ECPair } from "./ecpair.js"; +import type { UtxolibName, UtxolibNetwork } from "./utxolibCompat.js"; import type { CoinName } from "./coinName.js"; -import { Triple } from "./triple.js"; import { AddressFormat } from "./address.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, + keys: WalletKeysArg, chain: number, index: number, network: UtxolibNetwork, ): Uint8Array { - return FixedScriptWalletNamespace.output_script(keys, chain, index, network); + const walletKeys = RootWalletKeys.from(keys); + return FixedScriptWalletNamespace.output_script(walletKeys.wasm, chain, index, network); } /** @@ -37,13 +34,14 @@ export function outputScript( * - "cashaddr" means cashaddr. */ export function address( - keys: WalletKeys, + keys: WalletKeysArg, chain: number, index: number, network: UtxolibNetwork, addressFormat?: AddressFormat, ): string { - return FixedScriptWalletNamespace.address(keys, chain, index, network, addressFormat); + const walletKeys = RootWalletKeys.from(keys); + return FixedScriptWalletNamespace.address(walletKeys.wasm, chain, index, network, addressFormat); } type ReplayProtection = @@ -119,11 +117,12 @@ export class BitGoPsbt { * @returns Parsed transaction information */ parseTransactionWithWalletKeys( - walletKeys: WalletKeys, + walletKeys: WalletKeysArg, replayProtection: ReplayProtection, ): ParsedTransaction { + const keys = RootWalletKeys.from(walletKeys); return this.wasm.parse_transaction_with_wallet_keys( - walletKeys, + keys.wasm, replayProtection, ) as ParsedTransaction; } @@ -139,26 +138,56 @@ 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 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 * - * This method derives the public key from the xpub using the derivation path found in the - * PSBT input, then verifies the signature. It supports: + * 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); } /** diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index f8f0f8c8..338902b2 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -8,6 +8,8 @@ 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 bip32 from "./bip32.js"; +export * as ecpair from "./ecpair.js"; export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; 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..54d6b084 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 @@ -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. @@ -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 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/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 090385ca..3d5dc4c3 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::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 { @@ -81,7 +82,7 @@ pub struct FixedScriptWalletNamespace; impl FixedScriptWalletNamespace { #[wasm_bindgen] pub fn output_script( - keys: JsValue, + keys: &WasmRootWalletKeys, chain: u32, index: u32, network: JsValue, @@ -90,9 +91,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 +103,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(), @@ -156,11 +157,11 @@ impl BitGoPsbt { /// Parse transaction with wallet keys to identify wallet inputs/outputs pub fn parse_transaction_with_wallet_keys( &self, - wallet_keys: JsValue, + wallet_keys: &WasmRootWalletKeys, replay_protection: JsValue, ) -> 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(); // Convert replay protection from JsValue, using the PSBT's network let network = self.psbt.network(); @@ -169,7 +170,7 @@ impl BitGoPsbt { // 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 +182,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 +200,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_str: &str, + xpub: &WasmBIP32, ) -> 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 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(&secp, input_index, &xpub) + .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, + ecpair: &WasmECPair, + ) -> Result { + // 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_with_pub(&secp, input_index, &public_key) .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) } diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 0c39bbe9..0e4289bd 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -8,6 +8,7 @@ mod psbt; mod try_from_js_value; mod try_into_js_value; mod utxolib_compat; +mod wallet_keys; pub(crate) mod wallet_keys_helpers; pub use address::AddressNamespace; @@ -18,3 +19,4 @@ pub use fixed_script_wallet::FixedScriptWalletNamespace; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; pub use utxolib_compat::UtxolibCompatNamespace; +pub use wallet_keys::WasmRootWalletKeys; diff --git a/packages/wasm-utxo/src/wasm/wallet_keys.rs b/packages/wasm-utxo/src/wasm/wallet_keys.rs index 13c40a10..3d47d807 100644 --- a/packages/wasm-utxo/src/wasm/wallet_keys.rs +++ b/packages/wasm-utxo/src/wasm/wallet_keys.rs @@ -1,12 +1,10 @@ use std::str::FromStr; use wasm_bindgen::prelude::*; -use wasm_bindgen::JsValue; use crate::bitcoin::bip32::DerivationPath; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::RootWalletKeys; use crate::wasm::bip32::WasmBIP32; -use crate::wasm::wallet_keys_helpers::root_wallet_keys_from_jsvalue; /// WASM wrapper for RootWalletKeys /// Represents a set of three extended public keys with their derivation prefixes @@ -25,74 +23,65 @@ impl WasmRootWalletKeys { #[wasm_bindgen] impl WasmRootWalletKeys { - /// Create a RootWalletKeys from any compatible format + /// 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(xpubs: JsValue) -> Result { - let inner = root_wallet_keys_from_jsvalue(&xpubs)?; - Ok(WasmRootWalletKeys { inner }) - } - - /// Create a RootWalletKeys from three xpub strings - /// Uses default derivation prefix of m/0/0 for all three keys - #[wasm_bindgen] - pub fn new_from_xpubs(xpubs: JsValue) -> Result { - let inner = root_wallet_keys_from_jsvalue(&xpubs)?; + 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 xpub strings with custom derivation prefixes + /// Create a RootWalletKeys from three BIP32 keys with custom derivation prefixes /// /// # Arguments - /// - `xpubs`: Array of 3 xpub strings or WalletKeys object - /// - `derivation_prefixes`: Array of 3 derivation path strings (e.g., ["m/0/0", "m/0/0", "m/0/0"]) + /// - `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( - xpubs: JsValue, - derivation_prefixes: JsValue, + user: &WasmBIP32, + backup: &WasmBIP32, + bitgo: &WasmBIP32, + user_derivation: &str, + backup_derivation: &str, + bitgo_derivation: &str, ) -> Result { - // First get the xpubs - let inner = root_wallet_keys_from_jsvalue(&xpubs)?; - - // Parse derivation prefixes if provided - if !derivation_prefixes.is_undefined() && !derivation_prefixes.is_null() { - let prefixes_array = js_sys::Array::from(&derivation_prefixes); - if prefixes_array.length() != 3 { - return Err(WasmUtxoError::new("Expected exactly 3 derivation prefixes")); - } - - 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")) - }); + let xpubs = [user.to_xpub()?, backup.to_xpub()?, bitgo.to_xpub()?]; - let derivation_paths: [DerivationPath; 3] = prefix_strings? - .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"))?; - - Ok(WasmRootWalletKeys { - inner: RootWalletKeys::new_with_derivation_prefixes(inner.xpubs, derivation_paths), + 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))) }) - } else { - Ok(WasmRootWalletKeys { inner }) - } + .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) diff --git a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs b/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs index 195aecf8..2cb029a0 100644 --- a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs +++ b/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs @@ -7,6 +7,7 @@ use crate::fixed_script_wallet::{xpub_triple_from_strings, RootWalletKeys, XpubT use crate::wasm::bip32::WasmBIP32; use wasm_bindgen::JsValue; +#[allow(dead_code)] // Used in tests pub fn xpub_triple_from_jsvalue(keys: &JsValue) -> Result { let keys_array = js_sys::Array::from(keys); if keys_array.length() != 3 { @@ -29,6 +30,7 @@ pub fn xpub_triple_from_jsvalue(keys: &JsValue) -> Result Result { // Check if keys is an array (xpub strings) or an object (WalletKeys/RootWalletKeys) if js_sys::Array::is_array(keys) { diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index d482a516..6f1c85e8 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -2,7 +2,9 @@ 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/WalletKeys.js"; +import { BIP32, type BIP32Interface } from "../../js/bip32.js"; +import { RootWalletKeys } from "../../js/WalletKeys.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -117,7 +119,7 @@ export function loadPsbtFixture(network: string, signatureState: string): Fixtur /** * Load wallet keys from fixture */ -export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWalletKeys { +export function loadWalletKeysFromFixture(network: string): RootWalletKeys { const fixturePath = path.join( __dirname, "..", @@ -130,11 +132,16 @@ export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWa // 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; - return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); + const walletKeysLike: IWalletKeys = { + triple: xpubs, + derivationPrefixes: ["0/0", "0/0", "0/0"], + }; + + return RootWalletKeys.from(walletKeysLike); } /** diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 6b47d283..2f2c16ae 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -2,7 +2,13 @@ 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 type { RootWalletKeys } from "../../js/WalletKeys.js"; +import { + loadPsbtFixture, + loadWalletKeysFromFixture, + getPsbtBuffer, + type Fixture, +} from "./fixtureUtil.js"; function getExpectedInputScriptType(fixtureScriptType: string): InputScriptType { // Map fixture types to InputScriptType values @@ -53,8 +59,8 @@ 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 fixture: Fixture; before(function () { fixture = loadPsbtFixture(networkName, "fullsigned"); diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts index 8cc0c0bb..20d0d56a 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { fixedScriptWallet } from "../../js/index.js"; +import { fixedScriptWallet, BIP32 } from "../../js/index.js"; import { BitGoPsbt } from "../../js/fixedScriptWallet.js"; +import type { RootWalletKeys } from "../../js/WalletKeys.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, @@ -61,7 +62,7 @@ function getExpectedSignatures( */ function verifyInputSignatures( bitgoPsbt: BitGoPsbt, - rootWalletKeys: utxolib.bitgo.RootWalletKeys, + rootWalletKeys: RootWalletKeys, inputIndex: number, expectedSignatures: ExpectedSignatures, ): void { @@ -83,11 +84,9 @@ function verifyInputSignatures( } // Handle standard multisig inputs - const xpubs = rootWalletKeys.triple; - - const hasUserSig = bitgoPsbt.verifySignature(inputIndex, xpubs[0].toBase58()); - const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, xpubs[1].toBase58()); - const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, xpubs[2].toBase58()); + const hasUserSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.userKey()); + const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.backupKey()); + const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.bitgoKey()); assert.strictEqual( hasUserSig, @@ -122,7 +121,7 @@ describe("verifySignature", function () { const networkName = utxolib.getNetworkName(network); describe(`network: ${networkName}`, function () { - let rootWalletKeys: utxolib.bitgo.RootWalletKeys; + let rootWalletKeys: RootWalletKeys; let unsignedFixture: Fixture; let halfsignedFixture: Fixture; let fullsignedFixture: Fixture; @@ -192,11 +191,9 @@ describe("verifySignature", function () { 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 +208,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 +218,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", + ); + }); }); }); }); From a7274dbafbfd0a288fe2a72c733fa72ab39a066e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 10:57:53 +0100 Subject: [PATCH 4/9] feat(wasm-utxo): restructure fixedScriptWallet into directory Reorganize fixedScriptWallet into a proper directory structure with separate files for distinct functionality. Move RootWalletKeys and address functions into their own modules while keeping the same API through the index file. Issue: BTC-2786 Co-authored-by: llm-git --- .../BitGoPsbt.ts} | 51 +++---------------- .../RootWalletKeys.ts} | 26 +++++----- .../wasm-utxo/js/fixedScriptWallet/address.ts | 40 +++++++++++++++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 11 ++++ packages/wasm-utxo/js/index.ts | 2 +- .../wasm-utxo/test/fixedScript/fixtureUtil.ts | 4 +- .../parseTransactionWithWalletKeys.ts | 4 +- .../test/fixedScript/verifySignature.ts | 3 +- 8 files changed, 76 insertions(+), 65 deletions(-) rename packages/wasm-utxo/js/{fixedScriptWallet.ts => fixedScriptWallet/BitGoPsbt.ts} (80%) rename packages/wasm-utxo/js/{WalletKeys.ts => fixedScriptWallet/RootWalletKeys.ts} (85%) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/address.ts create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/index.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts similarity index 80% rename from packages/wasm-utxo/js/fixedScriptWallet.ts rename to packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 7c359893..481eab1e 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -1,49 +1,12 @@ -import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo.js"; -import { type WalletKeysArg, RootWalletKeys } from "./WalletKeys.js"; -import { type BIP32Arg, BIP32 } from "./bip32.js"; -import { type ECPairArg, ECPair } from "./ecpair.js"; -import type { UtxolibName, UtxolibNetwork } from "./utxolibCompat.js"; -import type { CoinName } from "./coinName.js"; -import { AddressFormat } from "./address.js"; +import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; +import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.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; -/** - * 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); -} - type ReplayProtection = | { outputScripts: Uint8Array[]; @@ -86,8 +49,6 @@ export type ParsedTransaction = { virtualSize: number; }; -import { BitGoPsbt as WasmBitGoPsbt } from "./wasm/wasm_utxo.js"; - export class BitGoPsbt { private constructor(private wasm: WasmBitGoPsbt) {} diff --git a/packages/wasm-utxo/js/WalletKeys.ts b/packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts similarity index 85% rename from packages/wasm-utxo/js/WalletKeys.ts rename to packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts index f177c674..b33c2123 100644 --- a/packages/wasm-utxo/js/WalletKeys.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/RootWalletKeys.ts @@ -1,7 +1,7 @@ -import type { BIP32Interface } from "./bip32.js"; -import { BIP32 } from "./bip32.js"; -import { Triple } from "./triple.js"; -import { WasmRootWalletKeys, WasmBIP32 } from "./wasm/wasm_utxo.js"; +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 @@ -21,21 +21,21 @@ export type WalletKeysArg = | RootWalletKeys; /** - * Convert WalletKeysArg to a triple of WasmBIP32 instances + * Convert WalletKeysArg to a triple of BIP32 instances */ -function toBIP32Triple(keys: WalletKeysArg): Triple { +function toBIP32Triple(keys: WalletKeysArg): Triple { if (keys instanceof RootWalletKeys) { - return [keys.userKey().wasm, keys.backupKey().wasm, keys.bitgoKey().wasm]; + 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).wasm) as Triple; + return keys.triple.map((key) => BIP32.from(key)) as Triple; } // Otherwise it's a triple of strings (xpubs) - return keys.map((xpub) => WasmBIP32.from_xpub(xpub)) as Triple; + return keys.map((xpub) => BIP32.fromWasm(WasmBIP32.from_xpub(xpub))) as Triple; } /** @@ -69,14 +69,14 @@ export class RootWalletKeys { const wasm = derivationPrefixes ? WasmRootWalletKeys.with_derivation_prefixes( - user, - backup, - bitgo, + user.wasm, + backup.wasm, + bitgo.wasm, derivationPrefixes[0], derivationPrefixes[1], derivationPrefixes[2], ) - : new WasmRootWalletKeys(user, backup, bitgo); + : new WasmRootWalletKeys(user.wasm, backup.wasm, bitgo.wasm); return new RootWalletKeys(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..4a819f9d --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -0,0 +1,11 @@ +export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.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 338902b2..531152d8 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -7,7 +7,7 @@ void wasm; 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"; diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index 6f1c85e8..9bd4f441 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -2,9 +2,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import type { IWalletKeys } from "../../js/WalletKeys.js"; +import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { BIP32, type BIP32Interface } from "../../js/bip32.js"; -import { RootWalletKeys } from "../../js/WalletKeys.js"; +import { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 2f2c16ae..511b6068 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -1,8 +1,8 @@ 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 type { RootWalletKeys } from "../../js/WalletKeys.js"; +import { BitGoPsbt, InputScriptType } from "../../js/fixedScriptWallet/index.js"; +import type { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts index 20d0d56a..cc7d37e7 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -1,8 +1,7 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; import { fixedScriptWallet, BIP32 } from "../../js/index.js"; -import { BitGoPsbt } from "../../js/fixedScriptWallet.js"; -import type { RootWalletKeys } from "../../js/WalletKeys.js"; +import { BitGoPsbt, RootWalletKeys } from "../../js/fixedScriptWallet/index.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, From c63067bcd47a10061b406865db8cc9d24fd2d2b5 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 25 Nov 2025 15:48:36 +0100 Subject: [PATCH 5/9] feat(wasm-utxo): allow verifying replay protection signatures with keys Add support for verifying replay protection signatures using a wallet key directly through the main verifySignature method, rather than requiring the deprecated verifyReplayProtectionSignature method. This adds cleaner support for replay protection verification with the same API as normal signature verification. Issue: BTC-2786 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 2 + .../wasm-utxo/test/fixedScript/fixtureUtil.ts | 22 ++++----- .../parseTransactionWithWalletKeys.ts | 2 +- .../test/fixedScript/verifySignature.ts | 45 +++++++++++++++++-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 481eab1e..31e1283b 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -152,6 +152,8 @@ export class BitGoPsbt { } /** + * @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 diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index 9bd4f441..4423ca80 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -5,6 +6,7 @@ import { dirname } from "node:path"; 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); @@ -119,17 +121,7 @@ export function loadPsbtFixture(network: string, signatureState: string): Fixtur /** * Load wallet keys from fixture */ -export function loadWalletKeysFromFixture(network: string): 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 = BIP32.fromBase58(xprv); @@ -144,6 +136,14 @@ export function loadWalletKeysFromFixture(network: string): RootWalletKeys { return RootWalletKeys.from(walletKeysLike); } +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; +} + /** * Get extracted transaction hex from fixture */ diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 511b6068..c0dad622 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -66,7 +66,7 @@ describe("parseTransactionWithWalletKeys", function () { fixture = loadPsbtFixture(networkName, "fullsigned"); fullsignedPsbtBytes = getPsbtBuffer(fixture); bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBytes, networkName); - rootWalletKeys = loadWalletKeysFromFixture(networkName); + rootWalletKeys = loadWalletKeysFromFixture(fixture); }); it("should have matching unsigned transaction ID", function () { diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts index cc7d37e7..26054c43 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, BIP32 } from "../../js/index.js"; -import { BitGoPsbt, RootWalletKeys } from "../../js/fixedScriptWallet/index.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,7 +62,9 @@ function getExpectedSignatures( */ function verifyInputSignatures( bitgoPsbt: BitGoPsbt, + parsed: ParsedTransaction, rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, inputIndex: number, expectedSignatures: ExpectedSignatures, ): void { @@ -82,6 +85,20 @@ function verifyInputSignatures( return; } + 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; + } + // Handle standard multisig inputs const hasUserSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.userKey()); const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.backupKey()); @@ -121,18 +138,25 @@ describe("verifySignature", function () { describe(`network: ${networkName}`, function () { let rootWalletKeys: RootWalletKeys; + let replayProtectionKey: ECPair; let unsignedFixture: Fixture; let halfsignedFixture: Fixture; let fullsignedFixture: Fixture; let unsignedBitgoPsbt: BitGoPsbt; let halfsignedBitgoPsbt: BitGoPsbt; let fullsignedBitgoPsbt: BitGoPsbt; + let replayProtectionScript: Uint8Array; before(function () { - rootWalletKeys = loadWalletKeysFromFixture(networkName); unsignedFixture = loadPsbtFixture(networkName, "unsigned"); halfsignedFixture = loadPsbtFixture(networkName, "halfsigned"); fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); + rootWalletKeys = loadWalletKeysFromFixture(fullsignedFixture); + replayProtectionKey = loadReplayProtectionKeyFromFixture(fullsignedFixture); + replayProtectionScript = Buffer.from( + "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", + "hex", + ); unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( getPsbtBuffer(unsignedFixture), networkName, @@ -149,11 +173,16 @@ describe("verifySignature", function () { describe("unsigned PSBT", function () { it("should return false for unsigned inputs", function () { + const parsed = unsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + outputScripts: [replayProtectionScript], + }); // Verify all xpubs return false for all inputs unsignedFixture.psbtInputs.forEach((input, index) => { verifyInputSignatures( unsignedBitgoPsbt, + parsed, rootWalletKeys, + replayProtectionKey, index, getExpectedSignatures(input.type, "unsigned"), ); @@ -163,10 +192,15 @@ describe("verifySignature", function () { describe("half-signed PSBT", function () { it("should return true for signed xpubs and false for unsigned", function () { + const parsed = halfsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + outputScripts: [replayProtectionScript], + }); halfsignedFixture.psbtInputs.forEach((input, index) => { verifyInputSignatures( halfsignedBitgoPsbt, + parsed, rootWalletKeys, + replayProtectionKey, index, getExpectedSignatures(input.type, "halfsigned"), ); @@ -177,10 +211,15 @@ describe("verifySignature", function () { 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 + const parsed = fullsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + outputScripts: [replayProtectionScript], + }); fullsignedFixture.psbtInputs.forEach((input, index) => { verifyInputSignatures( fullsignedBitgoPsbt, + parsed, rootWalletKeys, + replayProtectionKey, index, getExpectedSignatures(input.type, "fullsigned"), ); From 457f8d923ea93bcf5bfead7030588520ec3730a0 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 25 Nov 2025 15:50:52 +0100 Subject: [PATCH 6/9] feat(wasm-utxo): add helper to verify all signatures in PSBT Add a new utility function `verifyAllInputSignatures` that handles the common pattern of verifying signatures across all inputs in a PSBT. This simplifies the test code by centralizing the verification logic. Issue: BTC-2786 Co-authored-by: llm-git --- .../test/fixedScript/verifySignature.ts | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts index 26054c43..f6a4b3ba 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -121,6 +121,39 @@ 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 replayProtectionScript - Script for replay protection inputs + * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) + */ +function verifyAllInputSignatures( + bitgoPsbt: BitGoPsbt, + fixture: Fixture, + rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, + replayProtectionScript: Uint8Array, + signatureStage: SignatureStage, +): void { + const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + outputScripts: [replayProtectionScript], + }); + + 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 ( @@ -173,57 +206,40 @@ describe("verifySignature", function () { describe("unsigned PSBT", function () { it("should return false for unsigned inputs", function () { - const parsed = unsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], - }); - // Verify all xpubs return false for all inputs - unsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - unsignedBitgoPsbt, - parsed, - rootWalletKeys, - replayProtectionKey, - index, - getExpectedSignatures(input.type, "unsigned"), - ); - }); + verifyAllInputSignatures( + unsignedBitgoPsbt, + unsignedFixture, + rootWalletKeys, + replayProtectionKey, + replayProtectionScript, + "unsigned", + ); }); }); describe("half-signed PSBT", function () { it("should return true for signed xpubs and false for unsigned", function () { - const parsed = halfsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], - }); - halfsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - halfsignedBitgoPsbt, - parsed, - rootWalletKeys, - replayProtectionKey, - index, - getExpectedSignatures(input.type, "halfsigned"), - ); - }); + verifyAllInputSignatures( + halfsignedBitgoPsbt, + halfsignedFixture, + rootWalletKeys, + replayProtectionKey, + replayProtectionScript, + "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 - const parsed = fullsignedBitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], - }); - fullsignedFixture.psbtInputs.forEach((input, index) => { - verifyInputSignatures( - fullsignedBitgoPsbt, - parsed, - rootWalletKeys, - replayProtectionKey, - index, - getExpectedSignatures(input.type, "fullsigned"), - ); - }); + verifyAllInputSignatures( + fullsignedBitgoPsbt, + fullsignedFixture, + rootWalletKeys, + replayProtectionKey, + replayProtectionScript, + "fullsigned", + ); }); }); From 6c16e6033580c22ae00fedd2f7180b81d7df9285 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 11:40:26 +0100 Subject: [PATCH 7/9] feat(wasm-utxo): implement ReplayProtection class Add a dedicated ReplayProtection class to handle replay protection inputs in transactions. The implementation supports creating replay protection from public keys, output scripts, or addresses, providing a cleaner API than the previous approach using plain objects. Issue: BTC-2786 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 25 ++-- .../js/fixedScriptWallet/ReplayProtection.ts | 112 ++++++++++++++++++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 1 + packages/wasm-utxo/js/index.ts | 5 + .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 10 +- .../bitgo_psbt/psbt_wallet_input.rs | 19 +-- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 2 + .../fixed_script_wallet/replay_protection.rs | 36 ++++++ .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 78 +++--------- packages/wasm-utxo/src/wasm/mod.rs | 2 + .../wasm-utxo/src/wasm/replay_protection.rs | 106 +++++++++++++++++ .../wasm-utxo/src/wasm/try_from_js_value.rs | 53 --------- 12 files changed, 294 insertions(+), 155 deletions(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/ReplayProtection.ts create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/replay_protection.rs create mode 100644 packages/wasm-utxo/src/wasm/replay_protection.rs diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 31e1283b..47103e55 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -1,5 +1,6 @@ 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"; @@ -7,14 +8,6 @@ import type { CoinName } from "../coinName.js"; export type NetworkName = UtxolibName | CoinName; -type ReplayProtection = - | { - outputScripts: Uint8Array[]; - } - | { - addresses: string[]; - }; - export type ScriptId = { chain: number; index: number }; export type InputScriptType = @@ -79,13 +72,11 @@ export class BitGoPsbt { */ parseTransactionWithWalletKeys( walletKeys: WalletKeysArg, - replayProtection: ReplayProtection, + replayProtection: ReplayProtectionArg, ): ParsedTransaction { const keys = RootWalletKeys.from(walletKeys); - return this.wasm.parse_transaction_with_wallet_keys( - keys.wasm, - replayProtection, - ) as ParsedTransaction; + const rp = ReplayProtection.from(replayProtection, this.wasm.network()); + return this.wasm.parse_transaction_with_wallet_keys(keys.wasm, rp.wasm) as ParsedTransaction; } /** @@ -171,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/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 4a819f9d..bd3263f4 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -1,4 +1,5 @@ export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js"; +export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; export { outputScript, address } from "./address.js"; export { BitGoPsbt, diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 531152d8..8bd637f8 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -4,6 +4,8 @@ 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"; @@ -11,6 +13,7 @@ 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"; @@ -18,6 +21,8 @@ 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/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 54d6b084..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}; @@ -912,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(); @@ -1324,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( @@ -1603,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/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 3d5dc4c3..4406bb08 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -7,8 +7,8 @@ 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::WasmRootWalletKeys; @@ -24,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; @@ -154,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: &WasmRootWalletKeys, - replay_protection: JsValue, + replay_protection: &WasmReplayProtection, ) -> Result { - // Get the inner RootWalletKeys + // Get the inner RootWalletKeys and ReplayProtection let wallet_keys = wallet_keys.inner(); - - // 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)?; + 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 @@ -282,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 0e4289bd..4f6c3e5e 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -5,6 +5,7 @@ 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; @@ -18,5 +19,6 @@ 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, From 483cccb881842ec55b223b5b84afd40e7e5cac4c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 11:51:00 +0100 Subject: [PATCH 8/9] feat(wasm-utxo): replace outputScripts with publicKeys in parsing functions Update parseTransactionWithWalletKeys and verifyReplayProtectionSignature to use publicKeys instead of outputScripts for replay protection validation. This provides a cleaner API and better aligns with the key-based security model. Issue: BTC-2786 Co-authored-by: llm-git --- .../parseTransactionWithWalletKeys.ts | 16 +++++++--------- .../test/fixedScript/verifySignature.ts | 18 ++---------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index c0dad622..46e0d357 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -3,10 +3,12 @@ import * as utxolib from "@bitgo/utxo-lib"; import { fixedScriptWallet } from "../../js/index.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"; @@ -36,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) && @@ -60,6 +56,7 @@ describe("parseTransactionWithWalletKeys", function () { let fullsignedPsbtBytes: Buffer; let bitgoPsbt: BitGoPsbt; let rootWalletKeys: RootWalletKeys; + let replayProtectionKey: ECPair; let fixture: Fixture; before(function () { @@ -67,6 +64,7 @@ describe("parseTransactionWithWalletKeys", function () { fullsignedPsbtBytes = getPsbtBuffer(fixture); bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBytes, networkName); rootWalletKeys = loadWalletKeysFromFixture(fixture); + replayProtectionKey = loadReplayProtectionKeyFromFixture(fixture); }); it("should have matching unsigned transaction ID", function () { @@ -80,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 @@ -147,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 @@ -166,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 f6a4b3ba..a6b5b96c 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -70,12 +70,8 @@ function verifyInputSignatures( ): 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, @@ -127,7 +123,6 @@ function verifyInputSignatures( * @param fixture - The test fixture containing input metadata * @param rootWalletKeys - Wallet keys for verification * @param replayProtectionKey - Key for replay protection inputs - * @param replayProtectionScript - Script for replay protection inputs * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) */ function verifyAllInputSignatures( @@ -135,11 +130,10 @@ function verifyAllInputSignatures( fixture: Fixture, rootWalletKeys: RootWalletKeys, replayProtectionKey: ECPair, - replayProtectionScript: Uint8Array, signatureStage: SignatureStage, ): void { const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { - outputScripts: [replayProtectionScript], + publicKeys: [replayProtectionKey], }); fixture.psbtInputs.forEach((input, index) => { @@ -178,7 +172,6 @@ describe("verifySignature", function () { let unsignedBitgoPsbt: BitGoPsbt; let halfsignedBitgoPsbt: BitGoPsbt; let fullsignedBitgoPsbt: BitGoPsbt; - let replayProtectionScript: Uint8Array; before(function () { unsignedFixture = loadPsbtFixture(networkName, "unsigned"); @@ -186,10 +179,6 @@ describe("verifySignature", function () { fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); rootWalletKeys = loadWalletKeysFromFixture(fullsignedFixture); replayProtectionKey = loadReplayProtectionKeyFromFixture(fullsignedFixture); - replayProtectionScript = Buffer.from( - "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", - "hex", - ); unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( getPsbtBuffer(unsignedFixture), networkName, @@ -211,7 +200,6 @@ describe("verifySignature", function () { unsignedFixture, rootWalletKeys, replayProtectionKey, - replayProtectionScript, "unsigned", ); }); @@ -224,7 +212,6 @@ describe("verifySignature", function () { halfsignedFixture, rootWalletKeys, replayProtectionKey, - replayProtectionScript, "halfsigned", ); }); @@ -237,7 +224,6 @@ describe("verifySignature", function () { fullsignedFixture, rootWalletKeys, replayProtectionKey, - replayProtectionScript, "fullsigned", ); }); From 9aae5ffada88c0159e98c1b06e5e6bc7dbd92592 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 26 Nov 2025 15:02:54 +0100 Subject: [PATCH 9/9] refactor(wasm-utxo): remove unused wallet_keys_helpers module Remove WASM-specific test code and the helper module that is no longer needed after implementing wallet keys functionality in Rust rather than JavaScript/TypeScript. Issue: BTC-2786 Co-authored-by: llm-git --- .../src/fixed_script_wallet/wallet_keys.rs | 126 ---------------- packages/wasm-utxo/src/wasm/mod.rs | 1 - .../wasm-utxo/src/wasm/wallet_keys_helpers.rs | 140 ------------------ 3 files changed, 267 deletions(-) delete mode 100644 packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs 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/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 4f6c3e5e..61f9f6d3 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -10,7 +10,6 @@ mod try_from_js_value; mod try_into_js_value; mod utxolib_compat; mod wallet_keys; -pub(crate) mod wallet_keys_helpers; pub use address::AddressNamespace; pub use bip32::WasmBIP32; 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 2cb029a0..00000000 --- a/packages/wasm-utxo/src/wasm/wallet_keys_helpers.rs +++ /dev/null @@ -1,140 +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::bip32::WasmBIP32; -use wasm_bindgen::JsValue; - -#[allow(dead_code)] // Used in tests -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?) -} - -#[allow(dead_code)] // Used in tests -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); - WasmBIP32::from_bip32_interface(&bip32_key) - .and_then(|wasm_bip32| wasm_bip32.to_xpub()) - }) - .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", - )) - } -}