From 6d56633b82b28c4f0b1ae7234292510ee1ee2239 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 10 Feb 2026 11:43:16 +0100 Subject: [PATCH 1/5] feat(wasm-utxo): add Bitcoin message signing utilities (BIP-137) Implements Bitcoin message signing and verification according to BIP-137 standard. This adds a new `message` module with `signMessage` and `verifyMessage` functions that work with ECPair keys. Issue: BTC-2866 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 1 + packages/wasm-utxo/js/message.ts | 46 +++++++ packages/wasm-utxo/src/lib.rs | 1 + packages/wasm-utxo/src/message.rs | 167 ++++++++++++++++++++++++ packages/wasm-utxo/src/wasm/message.rs | 38 ++++++ packages/wasm-utxo/src/wasm/mod.rs | 2 + packages/wasm-utxo/test/message.ts | 173 +++++++++++++++++++++++++ 7 files changed, 428 insertions(+) create mode 100644 packages/wasm-utxo/js/message.ts create mode 100644 packages/wasm-utxo/src/message.rs create mode 100644 packages/wasm-utxo/src/wasm/message.rs create mode 100644 packages/wasm-utxo/test/message.ts diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 69d35124..1d7af2a8 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -10,6 +10,7 @@ export * as address from "./address.js"; export * as ast from "./ast/index.js"; export * as bip322 from "./bip322/index.js"; export * as inscriptions from "./inscriptions.js"; +export * as message from "./message.js"; export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; export * as descriptorWallet from "./descriptorWallet/index.js"; diff --git a/packages/wasm-utxo/js/message.ts b/packages/wasm-utxo/js/message.ts new file mode 100644 index 00000000..c649f3b8 --- /dev/null +++ b/packages/wasm-utxo/js/message.ts @@ -0,0 +1,46 @@ +/** + * Bitcoin message signing and verification (BIP-137) + * + * This module provides functions for signing and verifying Bitcoin messages + * using the standard Bitcoin Signed Message format (BIP-137). + * + * @example + * ```typescript + * import { message, ECPair } from '@bitgo/wasm-utxo'; + * + * // Sign a message + * const key = ECPair.fromWIF('L1TnU2zbNaAqMoVh65Cyvmcjzbrj41Gs9iTLcWbpJCpV1iNMXpuR'); + * const signature = message.signMessage('Hello, Bitcoin!', key); + * + * // Verify a message + * const isValid = message.verifyMessage('Hello, Bitcoin!', key, signature); + * ``` + */ + +import { MessageNamespace } from "./wasm/wasm_utxo.js"; +import { ECPair, type ECPairArg } from "./ecpair.js"; + +/** + * Sign a message using Bitcoin message signing (BIP-137) + * + * @param message - The message to sign + * @param key - The key to sign with (must have a private key) + * @returns 65-byte signature (1-byte header + 64-byte signature) + */ +export function signMessage(message: string, key: ECPairArg): Uint8Array { + const ecpair = ECPair.from(key); + return new Uint8Array(MessageNamespace.sign_message(ecpair.wasm, message)); +} + +/** + * Verify a Bitcoin message signature (BIP-137) + * + * @param message - The message that was signed + * @param key - The key to verify against + * @param signature - 65-byte signature (1-byte header + 64-byte signature) + * @returns True if the signature is valid for this key + */ +export function verifyMessage(message: string, key: ECPairArg, signature: Uint8Array): boolean { + const ecpair = ECPair.from(key); + return MessageNamespace.verify_message(ecpair.wasm, message, signature); +} diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 9aa52a0c..f3553b1e 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -4,6 +4,7 @@ pub mod dash; mod error; pub mod fixed_script_wallet; pub mod inscriptions; +pub mod message; mod networks; pub mod paygo; #[cfg(test)] diff --git a/packages/wasm-utxo/src/message.rs b/packages/wasm-utxo/src/message.rs new file mode 100644 index 00000000..51dc0dc2 --- /dev/null +++ b/packages/wasm-utxo/src/message.rs @@ -0,0 +1,167 @@ +//! Bitcoin message signing and verification (BIP-137) + +use crate::bitcoin::{ + consensus::Encodable, + hashes::{sha256d, Hash}, + secp256k1::{self, PublicKey, Secp256k1, SecretKey}, + VarInt, +}; +use crate::error::WasmUtxoError; + +/// Bitcoin message signing prefix +const BITCOIN_SIGNED_MESSAGE_PREFIX: &[u8] = b"\x18Bitcoin Signed Message:\n"; + +/// Compute Bitcoin message hash (double SHA256 with magic prefix) +fn bitcoin_message_hash(message: &str) -> sha256d::Hash { + let message_bytes = message.as_bytes(); + + // Build the full message: magic + varint(len) + message + let mut data = Vec::new(); + data.extend_from_slice(BITCOIN_SIGNED_MESSAGE_PREFIX); + + let varint = VarInt::from(message_bytes.len()); + let mut varint_bytes = Vec::new(); + // consensus_encode on VarInt to Vec is infallible + varint.consensus_encode(&mut varint_bytes).unwrap(); + data.extend_from_slice(&varint_bytes); + + data.extend_from_slice(message_bytes); + + sha256d::Hash::hash(&data) +} + +/// Sign a message using Bitcoin message signing (BIP-137) +/// +/// Returns 65-byte recoverable signature (1-byte header + 64-byte signature). +/// Header = 31 + recovery_id (always compressed keys). +pub fn sign_bitcoin_message( + secret_key: &SecretKey, + message: &str, +) -> Result, WasmUtxoError> { + let message_hash = bitcoin_message_hash(message); + let msg = secp256k1::Message::from_digest(*message_hash.as_ref()); + + let secp = Secp256k1::signing_only(); + let recoverable_sig = secp.sign_ecdsa_recoverable(&msg, secret_key); + let (recovery_id, compact_sig) = recoverable_sig.serialize_compact(); + + // BIP-137 format: 1-byte header + 64-byte signature + // Header: 27 + recovery_id + (compressed ? 4 : 0) + // We always use compressed keys, so header = 31 + recovery_id + let header = 31 + recovery_id.to_i32() as u8; + + let mut sig_bytes = Vec::with_capacity(65); + sig_bytes.push(header); + sig_bytes.extend_from_slice(&compact_sig); + + Ok(sig_bytes) +} + +/// Verify a Bitcoin message signature (BIP-137) +/// +/// Recovers the public key from the 65-byte signature and compares it to the +/// provided public key. Returns `true` if they match. +pub fn verify_bitcoin_message( + public_key: &PublicKey, + message: &str, + signature: &[u8], +) -> Result { + if signature.len() != 65 { + return Err(WasmUtxoError::new("Signature must be 65 bytes")); + } + + let recovery_flags = signature[0]; + let compact_sig = &signature[1..65]; + + // Decode recovery ID from flags + // Compressed keys: 31-34 (recid 0-3), Uncompressed: 27-30 + let recovery_id = if (31..=34).contains(&recovery_flags) { + secp256k1::ecdsa::RecoveryId::from_i32((recovery_flags - 31) as i32) + .map_err(|e| WasmUtxoError::new(&format!("Invalid recovery ID: {}", e)))? + } else if (27..=30).contains(&recovery_flags) { + secp256k1::ecdsa::RecoveryId::from_i32((recovery_flags - 27) as i32) + .map_err(|e| WasmUtxoError::new(&format!("Invalid recovery ID: {}", e)))? + } else { + return Err(WasmUtxoError::new(&format!( + "Invalid signature header: {}", + recovery_flags + ))); + }; + + let recoverable_sig = + secp256k1::ecdsa::RecoverableSignature::from_compact(compact_sig, recovery_id) + .map_err(|e| WasmUtxoError::new(&format!("Invalid signature format: {}", e)))?; + + let message_hash = bitcoin_message_hash(message); + let msg = secp256k1::Message::from_digest(*message_hash.as_ref()); + + let secp = Secp256k1::verification_only(); + let recovered_pubkey = secp + .recover_ecdsa(&msg, &recoverable_sig) + .map_err(|e| WasmUtxoError::new(&format!("Failed to recover public key: {}", e)))?; + + Ok(&recovered_pubkey == public_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_and_verify_roundtrip() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let message = "Hello, Bitcoin!"; + let signature = sign_bitcoin_message(&secret_key, message).unwrap(); + + assert_eq!(signature.len(), 65); + assert!(verify_bitcoin_message(&public_key, message, &signature).unwrap()); + } + + #[test] + fn test_verify_wrong_key() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let wrong_secret_key = + SecretKey::from_slice(&[0x02; 32]).expect("32 bytes, within curve order"); + let wrong_public_key = PublicKey::from_secret_key(&secp, &wrong_secret_key); + + let message = "Hello, Bitcoin!"; + let signature = sign_bitcoin_message(&secret_key, message).unwrap(); + + assert!(!verify_bitcoin_message(&wrong_public_key, message, &signature).unwrap()); + } + + #[test] + fn test_verify_wrong_message() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let signature = sign_bitcoin_message(&secret_key, "original message").unwrap(); + + assert!(!verify_bitcoin_message(&public_key, "different message", &signature).unwrap()); + } + + #[test] + fn test_verify_invalid_signature_length() { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).expect("32 bytes, within curve order"); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + let result = verify_bitcoin_message(&public_key, "test", &[0u8; 32]); + assert!(result.is_err()); + } + + #[test] + fn test_message_hash_deterministic() { + let hash1 = bitcoin_message_hash("test message"); + let hash2 = bitcoin_message_hash("test message"); + assert_eq!(hash1, hash2); + + let hash3 = bitcoin_message_hash("different message"); + assert_ne!(hash1, hash3); + } +} diff --git a/packages/wasm-utxo/src/wasm/message.rs b/packages/wasm-utxo/src/wasm/message.rs new file mode 100644 index 00000000..a96ad7af --- /dev/null +++ b/packages/wasm-utxo/src/wasm/message.rs @@ -0,0 +1,38 @@ +use crate::error::WasmUtxoError; +use crate::message; +use crate::wasm::ecpair::WasmECPair; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct MessageNamespace; + +#[wasm_bindgen] +impl MessageNamespace { + /// Sign a message using Bitcoin message signing (BIP-137) + /// + /// Returns 65-byte signature (1-byte header + 64-byte signature). + /// The key must have a private key (cannot sign with public key only). + #[wasm_bindgen] + pub fn sign_message( + key: &WasmECPair, + message_str: &str, + ) -> Result { + let secret_key = key.get_private_key()?; + let signature = message::sign_bitcoin_message(&secret_key, message_str)?; + Ok(js_sys::Uint8Array::from(&signature[..])) + } + + /// Verify a Bitcoin message signature (BIP-137) + /// + /// Signature must be 65 bytes (1-byte header + 64-byte signature). + /// Returns true if the signature is valid for this key. + #[wasm_bindgen] + pub fn verify_message( + key: &WasmECPair, + message_str: &str, + signature: &[u8], + ) -> Result { + let public_key = key.get_public_key(); + message::verify_bitcoin_message(&public_key, message_str, signature) + } +} diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index a0dc94b4..1d6edac6 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -6,6 +6,7 @@ mod descriptor; mod ecpair; mod fixed_script_wallet; mod inscriptions; +mod message; mod miniscript; mod psbt; mod recursive_tap_tree; @@ -24,6 +25,7 @@ pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; pub use fixed_script_wallet::{BitGoPsbt, FixedScriptWalletNamespace, WasmDimensions}; pub use inscriptions::InscriptionsNamespace; +pub use message::MessageNamespace; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; pub use replay_protection::WasmReplayProtection; diff --git a/packages/wasm-utxo/test/message.ts b/packages/wasm-utxo/test/message.ts new file mode 100644 index 00000000..d87e1964 --- /dev/null +++ b/packages/wasm-utxo/test/message.ts @@ -0,0 +1,173 @@ +import * as assert from "assert"; +import { ECPair } from "../js/ecpair.js"; +import { message } from "../js/index.js"; + +describe("Message Signing (BIP-137)", () => { + const testPrivateKey = new Uint8Array( + Buffer.from("1111111111111111111111111111111111111111111111111111111111111111", "hex"), + ); + + describe("signMessage", () => { + it("should sign a message and return 65-byte signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + + it("should produce a valid BIP-137 header byte", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + // Compressed key headers: 31-34 (31 + recovery_id 0-3) + assert.ok( + signature[0] >= 31 && signature[0] <= 34, + `Expected header 31-34, got ${signature[0]}`, + ); + }); + + it("should fail to sign with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + assert.throws(() => { + message.signMessage("Hello, Bitcoin!", publicKey); + }); + }); + + it("should accept ECPairArg as Uint8Array (private key)", () => { + const signature = message.signMessage("Hello, Bitcoin!", testPrivateKey); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + + it("should accept ECPairArg as WasmECPair", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key.wasm); + + assert.ok(signature instanceof Uint8Array); + assert.strictEqual(signature.length, 65); + }); + }); + + describe("verifyMessage", () => { + it("should verify a valid signature", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key, signature), true); + }); + + it("should verify with public key only", () => { + const privateKey = ECPair.fromPrivateKey(testPrivateKey); + const publicKey = ECPair.fromPublicKey(privateKey.publicKey); + + const signature = message.signMessage("Hello, Bitcoin!", privateKey); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", publicKey, signature), true); + }); + + it("should reject signature for different message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Different message", key, signature), false); + }); + + it("should reject signature for wrong key", () => { + const key1 = ECPair.fromPrivateKey(testPrivateKey); + const key2 = ECPair.fromPrivateKey( + new Uint8Array( + Buffer.from("2222222222222222222222222222222222222222222222222222222222222222", "hex"), + ), + ); + + const signature = message.signMessage("Hello, Bitcoin!", key1); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key2, signature), false); + }); + + it("should reject signature of invalid length", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + + assert.throws(() => { + message.verifyMessage("test", key, new Uint8Array(32)); + }); + + assert.throws(() => { + message.verifyMessage("test", key, new Uint8Array(64)); + }); + }); + + it("should accept ECPairArg as Uint8Array (public key) for verification", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("Hello, Bitcoin!", key); + + assert.strictEqual(message.verifyMessage("Hello, Bitcoin!", key.publicKey, signature), true); + }); + }); + + describe("edge cases", () => { + it("should handle empty message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const signature = message.signMessage("", key); + + assert.strictEqual(signature.length, 65); + assert.strictEqual(message.verifyMessage("", key, signature), true); + }); + + it("should handle long message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const msg = "A".repeat(1000); + const signature = message.signMessage(msg, key); + + assert.strictEqual(message.verifyMessage(msg, key, signature), true); + }); + + it("should handle unicode message", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const msg = "Hello, δΈ–η•Œ! πŸš€"; + const signature = message.signMessage(msg, key); + + assert.strictEqual(message.verifyMessage(msg, key, signature), true); + }); + + it("should produce consistent signatures", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const sig1 = message.signMessage("Test message", key); + const sig2 = message.signMessage("Test message", key); + + assert.strictEqual(message.verifyMessage("Test message", key, sig1), true); + assert.strictEqual(message.verifyMessage("Test message", key, sig2), true); + }); + }); + + describe("cross-verification with wasm-bip32", () => { + // Use a known private key and message to produce a deterministic signature + // that can be verified across implementations + it("should produce signatures verifiable by the same key's public key", () => { + const key = ECPair.fromPrivateKey(testPrivateKey); + const publicOnlyKey = ECPair.fromPublicKey(key.publicKey); + + const testMessages = [ + "Hello, Bitcoin!", + "", + "The quick brown fox jumps over the lazy dog", + "0", + "A".repeat(253), // just below varint boundary + "A".repeat(254), // at varint boundary + ]; + + for (const msg of testMessages) { + const signature = message.signMessage(msg, key); + assert.strictEqual( + message.verifyMessage(msg, publicOnlyKey, signature), + true, + `Failed to verify message: ${JSON.stringify(msg.slice(0, 50))}`, + ); + } + }); + }); +}); From 42fc908f73560ef3fbbeba70e1353159e429bfe1 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 10 Feb 2026 11:52:10 +0100 Subject: [PATCH 2/5] feat(wasm-utxo): add equality check and improved serialization to BIP32 Add methods to properly compare BIP32 keys and safely serialize them: - `equals()` method for checking key equality without serialization - `toJSON()` that safely returns xpub even for private keys - Custom Node.js inspect representation for better debugging - Rust implementation of key equality comparison Issue: BTC-2866 Co-authored-by: llm-git --- packages/wasm-utxo/js/bip32.ts | 32 ++++++++++ packages/wasm-utxo/src/wasm/bip32.rs | 10 +++- packages/wasm-utxo/test/bip32.ts | 89 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/js/bip32.ts b/packages/wasm-utxo/js/bip32.ts index dd5329ef..14471e09 100644 --- a/packages/wasm-utxo/js/bip32.ts +++ b/packages/wasm-utxo/js/bip32.ts @@ -228,6 +228,38 @@ export class BIP32 implements BIP32Interface { return new BIP32(wasm); } + /** + * Check equality with another BIP32 key. + * Two keys are equal if they have the same type (public/private) and identical + * BIP32 metadata (depth, parent fingerprint, child index, chain code, key data). + * This is a fast comparison that does not require serialization. + * + * @param other - The other key to compare with. Accepts BIP32, or any BIP32Interface. + * @returns True if the keys are equal + */ + equals(other: BIP32Interface): boolean { + const otherWasm = other instanceof BIP32 ? other._wasm : BIP32.from(other)._wasm; + return this._wasm.equals(otherWasm); + } + + /** + * Custom JSON representation for debugging. + * Always serializes the public key (xpub) to avoid leaking private keys. + * Includes a `hasPrivateKey` flag to indicate whether the key is neutered. + */ + toJSON(): { xpub: string; hasPrivateKey: boolean } { + return { xpub: this.neutered().toBase58(), hasPrivateKey: !this.isNeutered() }; + } + + /** + * Custom inspect representation for Node.js util.inspect and console.log. + * Always shows the public key (xpub) to avoid leaking private keys. + */ + [Symbol.for("nodejs.util.inspect.custom")](): string { + const flag = this.isNeutered() ? "" : ", hasPrivateKey"; + return `BIP32(${this.neutered().toBase58()}${flag})`; + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-utxo/src/wasm/bip32.rs b/packages/wasm-utxo/src/wasm/bip32.rs index 9a05a943..2d6cc7f9 100644 --- a/packages/wasm-utxo/src/wasm/bip32.rs +++ b/packages/wasm-utxo/src/wasm/bip32.rs @@ -9,7 +9,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; // Internal enum to hold either Xpub or Xpriv -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum BIP32Key { Public(Xpub), Private(Xpriv), @@ -353,6 +353,14 @@ impl WasmBIP32 { pub fn derive_path(&self, path: &str) -> Result { Ok(WasmBIP32(self.0.derive_path(path)?)) } + + /// Check equality with another WasmBIP32 key. + /// Two keys are equal if they have the same type (public/private) and identical + /// BIP32 metadata (depth, parent fingerprint, child index, chain code, key data). + #[wasm_bindgen] + pub fn equals(&self, other: &WasmBIP32) -> bool { + self.0 == other.0 + } } // Non-WASM methods for internal use diff --git a/packages/wasm-utxo/test/bip32.ts b/packages/wasm-utxo/test/bip32.ts index a9257c18..bb9b15c9 100644 --- a/packages/wasm-utxo/test/bip32.ts +++ b/packages/wasm-utxo/test/bip32.ts @@ -159,6 +159,95 @@ describe("WasmBIP32", () => { }); }); +describe("BIP32.equals", () => { + it("should return true for identical keys from same base58", () => { + const a = BIP32.fromBase58(XPUB); + const b = BIP32.fromBase58(XPUB); + assert.ok(a.equals(b)); + assert.ok(b.equals(a)); + }); + + it("should return true for identical private keys", () => { + const a = BIP32.fromBase58(XPRV); + const b = BIP32.fromBase58(XPRV); + assert.ok(a.equals(b)); + }); + + it("should return false for different keys", () => { + const a = BIP32.fromBase58(XPRV); + const b = a.derive(0); + assert.ok(!a.equals(b)); + assert.ok(!b.equals(a)); + }); + + it("should return false for private key vs its neutered form", () => { + const priv = BIP32.fromBase58(XPRV); + const pub_ = priv.neutered(); + assert.ok(!priv.equals(pub_)); + assert.ok(!pub_.equals(priv)); + }); + + it("should return true for neutered keys derived from same private key", () => { + const a = BIP32.fromBase58(XPRV).neutered(); + const b = BIP32.fromBase58(XPRV).neutered(); + assert.ok(a.equals(b)); + }); + + it("should return true for derived keys at same path", () => { + const root = BIP32.fromBase58(XPRV); + const a = root.derivePath("0/1/2").neutered(); + const b = root.derivePath("0/1/2").neutered(); + assert.ok(a.equals(b)); + }); + + it("should work with BIP32Interface from utxolib", () => { + const wasmKey = BIP32.fromBase58(XPUB); + const utxolibKey = utxolibBip32.fromBase58(XPUB); + assert.ok(wasmKey.equals(utxolibKey)); + }); +}); + +describe("BIP32.toJSON and inspect", () => { + it("should return xpub and hasPrivateKey=false from toJSON for public key", () => { + const key = BIP32.fromBase58(XPUB); + assert.deepStrictEqual(key.toJSON(), { xpub: XPUB, hasPrivateKey: false }); + }); + + it("should return xpub and hasPrivateKey=true from toJSON for private key", () => { + const key = BIP32.fromBase58(XPRV); + const json = key.toJSON(); + assert.strictEqual(json.hasPrivateKey, true); + assert.ok(json.xpub.startsWith("xpub"), "should serialize as xpub, not xprv"); + assert.strictEqual(json.xpub, key.neutered().toBase58()); + }); + + it("should never leak xprv in JSON.stringify", () => { + const key = BIP32.fromBase58(XPRV); + const serialized = JSON.stringify({ key }); + assert.ok(!serialized.includes("xprv"), "serialized JSON must not contain xprv"); + assert.ok(serialized.includes("xpub"), "serialized JSON must contain xpub"); + }); + + it("should return formatted string from inspect for public key", () => { + const key = BIP32.fromBase58(XPUB); + const inspect = key[ + Symbol.for("nodejs.util.inspect.custom") as unknown as symbol + ] as () => string; + assert.strictEqual(inspect.call(key), `BIP32(${XPUB})`); + }); + + it("should return formatted string with flag from inspect for private key", () => { + const key = BIP32.fromBase58(XPRV); + const inspect = key[ + Symbol.for("nodejs.util.inspect.custom") as unknown as symbol + ] as () => string; + const result = inspect.call(key) as string; + assert.ok(result.includes("hasPrivateKey"), "should indicate private key presence"); + assert.ok(!result.includes("xprv"), "should not leak xprv"); + assert.ok(result.startsWith("BIP32(xpub"), "should show xpub"); + }); +}); + describe("WasmBIP32 parity with utxolib", () => { it("should match utxolib when creating from base58 xpub", () => { assertKeysMatch(BIP32.fromBase58(XPUB), utxolibBip32.fromBase58(XPUB)); From 587eeb6e5653596866d92e8b8edf8f134f8c18a5 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 10 Feb 2026 11:59:12 +0100 Subject: [PATCH 3/5] feat(wasm-utxo): add descriptor testing utilities Port test utilities from utxo-core for descriptor wallet testing: - Add fixture handling and object serialization helpers - Add descriptor template functions to create common test descriptors - Add mock PSBT utilities for descriptor wallet testing Issue: BTC-2866 Co-authored-by: llm-git --- .../js/testutils/descriptor/descriptors.ts | 211 ++++++++++++++ .../js/testutils/descriptor/index.ts | 2 + .../js/testutils/descriptor/mockPsbt.ts | 137 ++++++++++ packages/wasm-utxo/js/testutils/fixtures.ts | 258 ++++++++++++++++++ packages/wasm-utxo/js/testutils/index.ts | 2 + .../wasm-utxo/test/testutilsDescriptor.ts | 154 +++++++++++ 6 files changed, 764 insertions(+) create mode 100644 packages/wasm-utxo/js/testutils/descriptor/descriptors.ts create mode 100644 packages/wasm-utxo/js/testutils/descriptor/index.ts create mode 100644 packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts create mode 100644 packages/wasm-utxo/js/testutils/fixtures.ts create mode 100644 packages/wasm-utxo/test/testutilsDescriptor.ts diff --git a/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts b/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts new file mode 100644 index 00000000..461fc821 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/descriptors.ts @@ -0,0 +1,211 @@ +/** + * Descriptor test utilities for building common descriptor templates. + * Ported from @bitgo/utxo-core/testutil/descriptor/descriptors.ts. + */ +import assert from "assert"; + +import { BIP32 } from "../../bip32.js"; +import type { BIP32Interface } from "../../bip32.js"; +import { Descriptor, Miniscript, ast } from "../../index.js"; +import type { Triple } from "../../triple.js"; +import { DescriptorMap, PsbtParams } from "../../descriptorWallet/index.js"; +import { getKeyTriple } from "../keys.js"; + +type KeyTriple = Triple; + +export type DescriptorTemplate = + | "Wsh2Of3" + | "Tr1Of3-NoKeyPath-Tree" + // no xpubs, just plain keys + | "Tr1Of3-NoKeyPath-Tree-Plain" + | "Tr2Of3-NoKeyPath" + | "Wsh2Of2" + /** + * Wrapped segwit 2of3 multisig with a relative locktime OP_DROP + * (requiring a miniscript extension). Used in CoreDao staking transactions. + */ + | "Wsh2Of3CltvDrop"; + +/** + * Get the BIP-341 "Nothing Up My Sleeve" (NUMS) unspendable key. + * This is the x-only public key with unknown discrete logarithm + * constructed by hashing the uncompressed secp256k1 base point G. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs + */ +export function getUnspendableKey(): string { + return "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; +} + +export function getDefaultXPubs(seed?: string): Triple { + return getKeyTriple(seed ?? "default").map((k) => k.neutered().toBase58()) as Triple; +} + +function toDescriptorMap(v: Record): DescriptorMap { + return new Map(Object.entries(v).map(([k, v]) => [k, Descriptor.fromString(v, "derivable")])); +} + +function toXPub(k: BIP32Interface | string, path: string): string { + if (typeof k === "string") { + return k + "/" + path; + } + return k.neutered().toBase58() + "/" + path; +} + +function toPlain(k: BIP32Interface | string, { xonly = false } = {}): string { + if (typeof k === "string") { + if (k.startsWith("xpub") || k.startsWith("xprv")) { + return toPlain(BIP32.fromBase58(k), { xonly }); + } + return k; + } + return toHex(k.publicKey.subarray(xonly ? 1 : 0)); +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function toXOnly(k: BIP32Interface | string): string { + return toPlain(k, { xonly: true }); +} + +function multiArgs( + m: number, + n: number, + keys: BIP32Interface[] | string[], + path: string, +): [number, ...string[]] { + if (n < m) { + throw new Error(`Cannot create ${m} of ${n} multisig`); + } + if (keys.length < n) { + throw new Error(`Not enough keys for ${m} of ${n} multisig: keys.length=${keys.length}`); + } + keys = keys.slice(0, n); + return [m, ...keys.map((k: BIP32Interface | string) => toXPub(k, path))]; +} + +export function getPsbtParams(t: DescriptorTemplate): Partial { + switch (t) { + case "Wsh2Of3CltvDrop": + return { locktime: 1 }; + default: + return {}; + } +} + +export function getDescriptorNode( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), + path = "0/*", +): ast.DescriptorNode { + switch (template) { + case "Wsh2Of3": + return { + wsh: { multi: multiArgs(2, 3, keys, path) }, + }; + case "Wsh2Of3CltvDrop": { + const { locktime } = getPsbtParams(template); + assert(locktime); + return { + wsh: { + and_v: [{ "r:after": locktime }, { multi: multiArgs(2, 3, keys, path) }], + }, + }; + } + case "Wsh2Of2": + return { + wsh: { multi: multiArgs(2, 2, keys, path) }, + }; + case "Tr2Of3-NoKeyPath": + return { + tr: [getUnspendableKey(), { multi_a: multiArgs(2, 3, keys, path) }], + }; + case "Tr1Of3-NoKeyPath-Tree": + return { + tr: [ + getUnspendableKey(), + [ + { pk: toXPub(keys[0], path) }, + [{ pk: toXPub(keys[1], path) }, { pk: toXPub(keys[2], path) }], + ], + ], + }; + case "Tr1Of3-NoKeyPath-Tree-Plain": + return { + tr: [ + getUnspendableKey(), + [{ pk: toXOnly(keys[0]) }, [{ pk: toXOnly(keys[1]) }, { pk: toXOnly(keys[2]) }]], + ], + }; + } + throw new Error(`Unknown descriptor template: ${template as string}`); +} + +type TapTree = [TapTree, TapTree] | ast.MiniscriptNode; + +function getTapLeafScriptNodes(t: ast.DescriptorNode | TapTree): ast.MiniscriptNode[] { + if (Array.isArray(t)) { + if (t.length !== 2) { + throw new Error(`expected tuple, got: ${JSON.stringify(t)}`); + } + return t.map((v) => (Array.isArray(v) ? getTapLeafScriptNodes(v) : v)).flat(); + } + + if (typeof t === "object") { + const node = t; + if (!("tr" in node)) { + throw new Error( + `TapLeafScripts are only supported for Taproot descriptors, got: ${JSON.stringify(t)}`, + ); + } + if (!Array.isArray(node.tr) || node.tr.length !== 2) { + throw new Error(`expected tuple, got: ${JSON.stringify(node.tr)}`); + } + const tapscript = node.tr[1]; + if (!Array.isArray(tapscript)) { + throw new Error(`expected tapscript to be an array, got: ${JSON.stringify(tapscript)}`); + } + return getTapLeafScriptNodes(tapscript); + } + + throw new Error(`Invalid input: ${JSON.stringify(t)}`); +} + +export function containsKey( + script: Miniscript | ast.MiniscriptNode, + key: BIP32Interface | string, +): boolean { + if (script instanceof Miniscript) { + script = ast.fromMiniscript(script); + } + if ("pk" in script) { + return script.pk === toXOnly(key); + } + throw new Error(`Unsupported script type: ${JSON.stringify(script)}`); +} + +export function getTapLeafScripts(d: Descriptor): string[] { + return getTapLeafScriptNodes(ast.fromDescriptor(d)).map((n) => + Miniscript.fromString(ast.formatNode(n), "tap").toString(), + ); +} + +export function getDescriptor( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), + path = "0/*", +): Descriptor { + return Descriptor.fromStringDetectType(ast.formatNode(getDescriptorNode(template, keys, path))); +} + +export function getDescriptorMap( + template: DescriptorTemplate, + keys: KeyTriple | string[] = getDefaultXPubs(), +): DescriptorMap { + return toDescriptorMap({ + external: getDescriptor(template, keys, "0/*").toString(), + internal: getDescriptor(template, keys, "1/*").toString(), + }); +} diff --git a/packages/wasm-utxo/js/testutils/descriptor/index.ts b/packages/wasm-utxo/js/testutils/descriptor/index.ts new file mode 100644 index 00000000..6cd9d33b --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/index.ts @@ -0,0 +1,2 @@ +export * from "./descriptors.js"; +export * from "./mockPsbt.js"; diff --git a/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts b/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts new file mode 100644 index 00000000..43046500 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/descriptor/mockPsbt.ts @@ -0,0 +1,137 @@ +/** + * Mock PSBT utilities for descriptor wallet testing. + * Ported from @bitgo/utxo-core/testutil/descriptor/mock.utils.ts. + * + * Key difference from utxo-core: returns wasm-utxo Psbt instances directly + * instead of utxolib.bitgo.UtxoPsbt. PsbtParams does not require a network field. + */ +import { Descriptor, Miniscript, Psbt } from "../../index.js"; +import { + PsbtParams, + DerivedDescriptorTransactionInput, + createPsbt, + createScriptPubKeyFromDescriptor, + WithOptDescriptor, + Output, +} from "../../descriptorWallet/index.js"; + +import { + DescriptorTemplate, + getDefaultXPubs, + getDescriptor, + getPsbtParams, +} from "./descriptors.js"; + +type MockOutputIdParams = { hash?: string; vout?: number }; + +type BaseMockDescriptorOutputParams = { + id?: MockOutputIdParams; + index?: number; + value?: bigint; + sequence?: number; + selectTapLeafScript?: Miniscript; +}; + +function mockOutputId(id?: MockOutputIdParams): { + hash: string; + vout: number; +} { + const hash = id?.hash ?? "0101010101010101010101010101010101010101010101010101010101010101"; + const vout = id?.vout ?? 0; + return { hash, vout }; +} + +export function mockDerivedDescriptorWalletOutput( + descriptor: Descriptor, + outputParams: BaseMockDescriptorOutputParams = {}, +): DerivedDescriptorTransactionInput { + const { value = BigInt(1e6) } = outputParams; + const { hash, vout } = mockOutputId(outputParams.id); + return { + hash, + index: vout, + witnessUtxo: { + script: createScriptPubKeyFromDescriptor(descriptor, undefined), + value, + }, + descriptor, + selectTapLeafScript: outputParams.selectTapLeafScript, + sequence: outputParams.sequence, + }; +} + +type MockInput = BaseMockDescriptorOutputParams & { + index: number; + descriptor: Descriptor; + selectTapLeafScript?: Miniscript; +}; + +type MockOutput = { + descriptor: Descriptor; + index: number; + value: bigint; + external?: boolean; +}; + +function tryDeriveAtIndex(descriptor: Descriptor, index: number): Descriptor { + return descriptor.hasWildcard() ? descriptor.atDerivationIndex(index) : descriptor; +} + +export function mockPsbt( + inputs: MockInput[], + outputs: MockOutput[], + params: Partial = {}, +): Psbt { + return createPsbt( + params, + inputs.map((i) => + mockDerivedDescriptorWalletOutput(tryDeriveAtIndex(i.descriptor, i.index), i), + ), + outputs.map((o): WithOptDescriptor => { + const derivedDescriptor = tryDeriveAtIndex(o.descriptor, o.index); + return { + script: createScriptPubKeyFromDescriptor(derivedDescriptor, undefined), + value: o.value, + descriptor: o.external ? undefined : derivedDescriptor, + }; + }), + ); +} + +export function mockPsbtDefault({ + descriptorSelf = getDescriptor("Wsh2Of3", getDefaultXPubs("a")), + descriptorOther = getDescriptor("Wsh2Of3", getDefaultXPubs("b")), + params = {}, +}: { + descriptorSelf?: Descriptor; + descriptorOther?: Descriptor; + params?: Partial; +} = {}): Psbt { + return mockPsbt( + [ + { descriptor: descriptorSelf, index: 0 }, + { descriptor: descriptorSelf, index: 1, id: { vout: 1 } }, + ], + [ + { + descriptor: descriptorOther, + index: 0, + value: BigInt(4e5), + external: true, + }, + { descriptor: descriptorSelf, index: 0, value: BigInt(4e5) }, + ], + params, + ); +} + +export function mockPsbtDefaultWithDescriptorTemplate( + t: DescriptorTemplate, + params: Partial = {}, +): Psbt { + return mockPsbtDefault({ + descriptorSelf: getDescriptor(t, getDefaultXPubs("a")), + descriptorOther: getDescriptor(t, getDefaultXPubs("b")), + params: { ...getPsbtParams(t), ...params }, + }); +} diff --git a/packages/wasm-utxo/js/testutils/fixtures.ts b/packages/wasm-utxo/js/testutils/fixtures.ts new file mode 100644 index 00000000..9fb66a54 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/fixtures.ts @@ -0,0 +1,258 @@ +/// +/** + * Generic test fixture and serialization utilities. + * Ported from @bitgo/utxo-core/testutil/fixtures.utils.ts + * and @bitgo/utxo-core/testutil/toPlainObject.utils.ts. + * + * NOTE: getFixture requires Node.js (fs, process.env). + * The toPlainObject / jsonNormalize utilities are platform-independent. + */ +import * as fs from "fs"; +import * as mpath from "path"; + +// ===== getFixture / jsonNormalize ===== + +type FixtureEncoding = "json" | "hex" | "txt"; + +function isNodeJsError(e: unknown): e is NodeJS.ErrnoException { + return e instanceof Error && typeof (e as NodeJS.ErrnoException).code === "string"; +} + +function fixtureEncoding(path: string): FixtureEncoding { + if (path.endsWith(".json")) { + return "json"; + } + if (path.endsWith(".hex")) { + return "hex"; + } + if (path.endsWith(".txt")) { + return "txt"; + } + throw new Error(`unknown fixture encoding for ${path}`); +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function decodeFixture(raw: string, encoding: FixtureEncoding): unknown { + switch (encoding) { + case "json": + return JSON.parse(raw); + case "hex": + return hexToBytes(raw); + case "txt": + return raw; + } +} + +function encodeFixture(value: unknown, encoding: FixtureEncoding): string { + switch (encoding) { + case "json": + return JSON.stringify(value, null, 2) + "\n"; + case "hex": + if (!(value instanceof Uint8Array)) { + throw new Error(`expected Uint8Array, got ${typeof value}`); + } + return bytesToHex(value); + case "txt": + if (typeof value !== "string") { + throw new Error(`expected string, got ${typeof value}`); + } + return value; + } +} + +/** + * Return fixture described in `path`. + * + * If the file does not exist and `defaultValue` is provided, + * writes defaultValue to `path` and throws an error prompting + * the developer to inspect and re-run. + * + * @param path - Path to the fixture file (.json, .hex, or .txt) + * @param defaultValue - Default value to write if fixture is missing + * @returns The fixture content + */ +export async function getFixture( + path: string, + defaultValue?: T | (() => Promise), +): Promise { + try { + await fs.promises.stat(mpath.dirname(path)); + } catch (e) { + if (isNodeJsError(e) && e.code === "ENOENT") { + throw new Error(`fixture directory ${mpath.dirname(path)} not found, please create it first`); + } + throw e; + } + + const encoding = fixtureEncoding(path); + + try { + return decodeFixture(await fs.promises.readFile(path, "utf8"), encoding) as T; + } catch (e) { + if (isNodeJsError(e) && e.code === "ENOENT") { + // eslint-disable-next-line no-restricted-globals + if (process.env.WRITE_FIXTURES === "0") { + throw new Error(`fixture ${path} not found, WRITE_FIXTURES=0`); + } + if (defaultValue === undefined) { + throw new Error(`fixture ${path} not found and no default value given`); + } + if (typeof defaultValue === "function") { + defaultValue = await (defaultValue as () => Promise)(); + } + await fs.promises.writeFile(path, encodeFixture(defaultValue, encoding)); + throw new Error(`wrote default value for ${path}, please inspect and restart test`); + } + + throw e; + } +} + +/** + * JSON round-trip normalization. + * Converts a value to JSON and back, stripping non-serializable properties + * and normalizing types (e.g. undefined -> null in arrays). + */ +export function jsonNormalize(v: T): T { + return JSON.parse(JSON.stringify(v)) as T; +} + +// ===== toPlainObject ===== + +export type ToPlainObjectOpts = { + propertyDescriptors?: boolean; + skipUndefinedValues?: boolean; + ignorePaths?: string[] | ((path: PathElement[]) => boolean); + apply?: (v: unknown, path: PathElement[]) => unknown; +}; + +export type PathElement = string | number; + +export function matchPath(a: PathElement[], b: PathElement[]): boolean { + return a.length === b.length && a.every((e, i) => e === b[i]); +} + +function includePath(opts: ToPlainObjectOpts, path: PathElement[]): boolean { + if (!opts.ignorePaths) { + return true; + } + if (typeof opts.ignorePaths === "function") { + return !opts.ignorePaths(path); + } + return !opts.ignorePaths.some((ignorePath) => matchPath(path, ignorePath.split("."))); +} + +function toPlainEntries( + key: string, + value: unknown, + opts: ToPlainObjectOpts, + path: PathElement[], +): [] | [[string, unknown]] { + if (!includePath(opts, [...path, key])) { + return []; + } + if (value === undefined && (opts.skipUndefinedValues ?? true)) { + return []; + } + return [[key, toPlainObject(value, opts, [...path, key])]]; +} + +function getAllPropertyDescriptors(v: unknown): PropertyDescriptorMap { + if (v === null || typeof v !== "object") { + return {}; + } + const descriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(v); + const proto: unknown = Object.getPrototypeOf(v); + if (proto) { + Object.assign(descriptors, getAllPropertyDescriptors(proto)); + } + return descriptors; +} + +function toPlainObjectFromPropertyDescriptors( + v: unknown, + opts: ToPlainObjectOpts, + path: PathElement[], +) { + const descriptors = getAllPropertyDescriptors(v); + return Object.fromEntries( + Object.entries(descriptors).flatMap(([key, descriptor]) => { + if (typeof descriptor.value === "function") { + return []; + } + if (descriptor.value !== undefined) { + return toPlainEntries(key, descriptor.value, opts, path); + } + if (typeof descriptor.get === "function") { + return toPlainEntries(key, descriptor.get.call(v), opts, path); + } + return []; + }), + ); +} + +/** + * Recursively convert a value to a plain JSON-serializable object. + * Handles Uint8Array (to hex), bigint (to string), Buffer, and more. + * + * @param v - The value to convert + * @param opts - Options for customizing the conversion + * @param path - Current property path (used for ignorePaths) + */ +export function toPlainObject( + v: unknown, + opts: ToPlainObjectOpts = {}, + path: PathElement[] = [], +): unknown { + if (opts.apply) { + const result = opts.apply(v, path); + if (result !== undefined) { + return result; + } + } + + switch (typeof v) { + case "string": + case "number": + case "boolean": + case "undefined": + return v; + case "bigint": + return v.toString(); + case "function": + case "symbol": + return undefined; + } + + if (v === null) { + return v; + } + + if (v instanceof Uint8Array) { + return Array.from(v, (b) => b.toString(16).padStart(2, "0")).join(""); + } + if (Array.isArray(v)) { + return v.map((e, i) => toPlainObject(e, opts, [...path, i])); + } + if (typeof v === "object") { + const result = Object.fromEntries( + Object.entries(v).flatMap(([key, value]) => toPlainEntries(key, value, opts, path)), + ); + if (opts.propertyDescriptors) { + Object.assign(result, toPlainObjectFromPropertyDescriptors(v, opts, path)); + } + return result; + } + throw new Error(`unknown v ${typeof v}`); +} diff --git a/packages/wasm-utxo/js/testutils/index.ts b/packages/wasm-utxo/js/testutils/index.ts index ca57a07d..6ed29a76 100644 --- a/packages/wasm-utxo/js/testutils/index.ts +++ b/packages/wasm-utxo/js/testutils/index.ts @@ -1,2 +1,4 @@ export * from "./keys.js"; export * from "./AcidTest.js"; +export * from "./fixtures.js"; +export * as descriptor from "./descriptor/index.js"; diff --git a/packages/wasm-utxo/test/testutilsDescriptor.ts b/packages/wasm-utxo/test/testutilsDescriptor.ts new file mode 100644 index 00000000..af3f5ae9 --- /dev/null +++ b/packages/wasm-utxo/test/testutilsDescriptor.ts @@ -0,0 +1,154 @@ +import * as assert from "node:assert"; + +import { Descriptor, Psbt, testutils } from "../js/index.js"; + +describe("testutils.descriptor", () => { + describe("getDefaultXPubs", () => { + it("returns triple of xpub strings", () => { + const xpubs = dt.getDefaultXPubs(); + assert.strictEqual(xpubs.length, 3); + for (const xpub of xpubs) { + assert.ok(xpub.startsWith("xpub"), `expected xpub, got ${xpub.slice(0, 10)}`); + } + }); + + it("returns deterministic xpubs for same seed", () => { + assert.deepStrictEqual(dt.getDefaultXPubs("foo"), dt.getDefaultXPubs("foo")); + }); + + it("returns different xpubs for different seeds", () => { + const a = dt.getDefaultXPubs("a"); + const b = dt.getDefaultXPubs("b"); + assert.notDeepStrictEqual(a, b); + }); + }); + + describe("getUnspendableKey", () => { + it("returns the BIP-341 NUMS point", () => { + const key = dt.getUnspendableKey(); + assert.strictEqual(key.length, 64); + assert.strictEqual(key, "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"); + }); + }); + + describe("getDescriptor", () => { + const templates: dt.DescriptorTemplate[] = [ + "Wsh2Of3", + "Wsh2Of2", + "Tr1Of3-NoKeyPath-Tree", + "Tr1Of3-NoKeyPath-Tree-Plain", + "Tr2Of3-NoKeyPath", + "Wsh2Of3CltvDrop", + ]; + + for (const t of templates) { + it(`creates valid descriptor for ${t}`, () => { + const d = dt.getDescriptor(t); + assert.ok(d instanceof Descriptor); + const str = d.toString(); + assert.ok(str.length > 0, "descriptor string should not be empty"); + }); + } + }); + + describe("getDescriptorMap", () => { + it("returns map with external and internal keys", () => { + const map = dt.getDescriptorMap("Wsh2Of3"); + assert.ok(map.has("external")); + assert.ok(map.has("internal")); + assert.ok(map.get("external") instanceof Descriptor); + assert.ok(map.get("internal") instanceof Descriptor); + assert.notStrictEqual(map.get("external")?.toString(), map.get("internal")?.toString()); + }); + }); + + describe("getPsbtParams", () => { + it("returns locktime for Wsh2Of3CltvDrop", () => { + assert.deepStrictEqual(dt.getPsbtParams("Wsh2Of3CltvDrop"), { locktime: 1 }); + }); + + it("returns empty for Wsh2Of3", () => { + assert.deepStrictEqual(dt.getPsbtParams("Wsh2Of3"), {}); + }); + }); +}); + +describe("testutils.descriptor.mockPsbt", () => { + describe("mockPsbtDefault", () => { + it("creates a valid Psbt", () => { + const psbt = dt.mockPsbtDefault(); + assert.ok(psbt instanceof Psbt); + assert.strictEqual(psbt.inputCount(), 2); + assert.strictEqual(psbt.outputCount(), 2); + }); + }); + + describe("mockPsbtDefaultWithDescriptorTemplate", () => { + for (const t of ["Wsh2Of3", "Wsh2Of2", "Wsh2Of3CltvDrop"] as dt.DescriptorTemplate[]) { + it(`creates valid Psbt for ${t}`, () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate(t); + assert.ok(psbt instanceof Psbt); + assert.strictEqual(psbt.inputCount(), 2); + assert.strictEqual(psbt.outputCount(), 2); + }); + } + }); +}); + +describe("testutils.fixtures", () => { + describe("jsonNormalize", () => { + it("round-trips a simple object", () => { + const obj = { a: 1, b: "hello", c: [1, 2, 3] }; + assert.deepStrictEqual(testutils.jsonNormalize(obj), obj); + }); + + it("strips undefined values", () => { + const obj = { a: 1, b: undefined }; + assert.deepStrictEqual(testutils.jsonNormalize(obj), { a: 1 }); + }); + }); + + describe("toPlainObject", () => { + it("converts bigint to string", () => { + assert.strictEqual(testutils.toPlainObject(BigInt(42)), "42"); + }); + + it("converts Uint8Array to hex", () => { + assert.strictEqual( + testutils.toPlainObject(new Uint8Array([0xde, 0xad, 0xbe, 0xef])), + "deadbeef", + ); + }); + + it("converts nested objects", () => { + const obj = { a: BigInt(1), b: { c: new Uint8Array([0xff]) } }; + assert.deepStrictEqual(testutils.toPlainObject(obj), { + a: "1", + b: { c: "ff" }, + }); + }); + + it("converts functions to undefined", () => { + const obj = { a: 1, fn: () => {} }; + assert.deepStrictEqual(testutils.toPlainObject(obj), { a: 1, fn: undefined }); + }); + + it("supports ignorePaths", () => { + const obj = { a: 1, secret: "hidden", b: 2 }; + assert.deepStrictEqual(testutils.toPlainObject(obj, { ignorePaths: ["secret"] }), { + a: 1, + b: 2, + }); + }); + + it("supports apply transform", () => { + const obj = { a: 1, b: 2 }; + assert.deepStrictEqual( + testutils.toPlainObject(obj, { + apply: (v) => (typeof v === "number" ? v * 10 : undefined), + }), + { a: 10, b: 20 }, + ); + }); + }); +}); From db2b2e947741461f39176ab76388d4c27ba664ad Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 10 Feb 2026 12:15:27 +0100 Subject: [PATCH 4/5] feat(wasm-utxo): add getOutputsWithAddress method to PSBT Adds a new method to PSBT that returns outputs with resolved address strings derived from each output script. This allows getting proper addresses formatted for specific coin networks without needing to call address.fromOutputScript separately for each output. Issue: BTC-2866 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 6 ++ packages/wasm-utxo/src/wasm/psbt.rs | 51 ++++++++++++++- .../wasm-utxo/src/wasm/try_into_js_value.rs | 12 ++++ .../wasm-utxo/test/testutilsDescriptor.ts | 65 ++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 1d7af2a8..ec520383 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -88,6 +88,11 @@ declare module "./wasm/wasm_utxo.js" { tapBip32Derivation: PsbtBip32Derivation[]; } + /** PSBT output data with resolved address, returned by getOutputsWithAddress() */ + interface PsbtOutputDataWithAddress extends PsbtOutputData { + address: string; + } + interface WrapPsbt { // Signing methods (legacy - kept for backwards compatibility) signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; @@ -102,6 +107,7 @@ declare module "./wasm/wasm_utxo.js" { outputCount(): number; getInputs(): PsbtInputData[]; getOutputs(): PsbtOutputData[]; + getOutputsWithAddress(coin: import("./coinName.js").CoinName): PsbtOutputDataWithAddress[]; getPartialSignatures(inputIndex: number): Array<{ pubkey: Uint8Array; signature: Uint8Array; diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 56ac8e25..f03cbf49 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1,3 +1,4 @@ +use crate::address::networks::{from_output_script_with_network_and_format, AddressFormat}; use crate::error::WasmUtxoError; use crate::wasm::bip32::WasmBIP32; use crate::wasm::descriptor::WrapDescriptorEnum; @@ -11,7 +12,7 @@ use miniscript::bitcoin::transaction::{Transaction, Version}; use miniscript::bitcoin::{ bip32, psbt, Amount, OutPoint, PublicKey, ScriptBuf, Sequence, XOnlyPublicKey, }; -use miniscript::bitcoin::{PrivateKey, Psbt, TxIn, TxOut, Txid}; +use miniscript::bitcoin::{PrivateKey, Psbt, Script, TxIn, TxOut, Txid}; use miniscript::descriptor::{SinglePub, SinglePubKey}; use miniscript::psbt::PsbtExt; use miniscript::{DescriptorPublicKey, ToPublicKey}; @@ -176,6 +177,32 @@ impl PsbtOutputData { } } +/// PSBT output data with a resolved address string (requires a coin name for encoding). +#[derive(Debug, Clone)] +pub struct PsbtOutputDataWithAddress { + pub script: Vec, + pub value: u64, + pub address: String, + pub bip32_derivation: Vec, + pub tap_bip32_derivation: Vec, +} + +impl PsbtOutputDataWithAddress { + pub fn from(base: PsbtOutputData, network: crate::Network) -> Result { + let script_obj = Script::from_bytes(&base.script); + let address = + from_output_script_with_network_and_format(script_obj, network, AddressFormat::Default) + .map_err(|e| WasmUtxoError::new(&e.to_string()))?; + Ok(PsbtOutputDataWithAddress { + script: base.script, + value: base.value, + address, + bip32_derivation: base.bip32_derivation, + tap_bip32_derivation: base.tap_bip32_derivation, + }) + } +} + #[wasm_bindgen] pub struct WrapPsbt(Psbt); @@ -579,6 +606,28 @@ impl WrapPsbt { outputs.try_to_js_value() } + /// Get all PSBT outputs with resolved address strings. + /// + /// Like `getOutputs()` but each element also includes an `address` field + /// derived from the output script using the given coin name (e.g. "btc", "tbtc"). + #[wasm_bindgen(js_name = getOutputsWithAddress)] + pub fn get_outputs_with_address(&self, coin: &str) -> Result { + let network = crate::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + let outputs: Vec = self + .0 + .unsigned_tx + .output + .iter() + .zip(self.0.outputs.iter()) + .map(|(tx_out, psbt_out)| { + let base = PsbtOutputData::from(tx_out, psbt_out); + PsbtOutputDataWithAddress::from(base, network) + }) + .collect::, _>>()?; + outputs.try_to_js_value() + } + /// Get partial signatures for an input /// Returns array of { pubkey: Uint8Array, signature: Uint8Array } #[wasm_bindgen(js_name = getPartialSignatures)] diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index 8858cef1..0134a0f4 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -447,6 +447,18 @@ impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputData { } } +impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputDataWithAddress { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value, + "address" => self.address.clone(), + "bip32Derivation" => self.bip32_derivation.clone(), + "tapBip32Derivation" => self.tap_bip32_derivation.clone() + ) + } +} + /// A partial signature with its associated public key #[derive(Clone)] pub struct PartialSignature { diff --git a/packages/wasm-utxo/test/testutilsDescriptor.ts b/packages/wasm-utxo/test/testutilsDescriptor.ts index af3f5ae9..c7d80689 100644 --- a/packages/wasm-utxo/test/testutilsDescriptor.ts +++ b/packages/wasm-utxo/test/testutilsDescriptor.ts @@ -1,6 +1,8 @@ import * as assert from "node:assert"; -import { Descriptor, Psbt, testutils } from "../js/index.js"; +import { Descriptor, Psbt, address } from "../js/index.js"; +import * as dt from "../js/testutils/descriptor/index.js"; +import * as testutils from "../js/testutils/index.js"; describe("testutils.descriptor", () => { describe("getDefaultXPubs", () => { @@ -95,6 +97,67 @@ describe("testutils.descriptor.mockPsbt", () => { }); }); +describe("Psbt.getOutputsWithAddress", () => { + it("returns outputs with address strings for btc", () => { + const psbt = dt.mockPsbtDefault(); + const outputs = psbt.getOutputsWithAddress("btc"); + assert.strictEqual(outputs.length, 2); + for (const output of outputs) { + assert.ok(typeof output.address === "string", "address should be a string"); + assert.ok(output.address.length > 0, "address should not be empty"); + assert.ok(output.script instanceof Uint8Array, "script should be Uint8Array"); + assert.ok(typeof output.value === "bigint", "value should be bigint"); + assert.ok(Array.isArray(output.bip32Derivation), "bip32Derivation should be array"); + assert.ok(Array.isArray(output.tapBip32Derivation), "tapBip32Derivation should be array"); + } + }); + + it("returns consistent addresses with address.fromOutputScriptWithCoin", () => { + const psbt = dt.mockPsbtDefault(); + const outputsWithAddr = psbt.getOutputsWithAddress("btc"); + const rawOutputs = psbt.getOutputs(); + for (let i = 0; i < rawOutputs.length; i++) { + const expected = address.fromOutputScriptWithCoin(rawOutputs[i].script, "btc"); + assert.strictEqual(outputsWithAddr[i].address, expected); + } + }); + + it("returns btc mainnet addresses starting with expected prefixes", () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate("Wsh2Of3"); + const outputs = psbt.getOutputsWithAddress("btc"); + for (const output of outputs) { + // p2wsh addresses start with bc1 + assert.ok(output.address.startsWith("bc1"), `expected bc1 prefix, got ${output.address}`); + } + }); + + it("returns tbtc testnet addresses starting with expected prefixes", () => { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate("Wsh2Of3"); + const outputs = psbt.getOutputsWithAddress("tbtc"); + for (const output of outputs) { + // p2wsh testnet addresses start with tb1 + assert.ok(output.address.startsWith("tb1"), `expected tb1 prefix, got ${output.address}`); + } + }); + + it("works for all wsh/tr templates", () => { + for (const t of [ + "Wsh2Of3", + "Wsh2Of2", + "Wsh2Of3CltvDrop", + "Tr2Of3-NoKeyPath", + "Tr1Of3-NoKeyPath-Tree", + ] as dt.DescriptorTemplate[]) { + const psbt = dt.mockPsbtDefaultWithDescriptorTemplate(t, dt.getPsbtParams(t)); + const outputs = psbt.getOutputsWithAddress("btc"); + assert.strictEqual(outputs.length, 2, `${t}: expected 2 outputs`); + for (const output of outputs) { + assert.ok(output.address.length > 0, `${t}: address should not be empty`); + } + } + }); +}); + describe("testutils.fixtures", () => { describe("jsonNormalize", () => { it("round-trips a simple object", () => { From 7e285671064f73e3474898113ed221914cd2c869 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 10 Feb 2026 13:28:45 +0100 Subject: [PATCH 5/5] feat(wasm-utxo): expose testutils as a submodule Add a new export for testutils in package.json to allow other modules to import helper functions and test fixtures. Issue: BTC-2866 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 2 -- packages/wasm-utxo/package.json | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index ec520383..b6412e15 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -16,8 +16,6 @@ export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; export * as descriptorWallet from "./descriptorWallet/index.js"; export * as bip32 from "./bip32.js"; export * as ecpair from "./ecpair.js"; -export * as testutils from "./testutils/index.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"; diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 94b8b02b..40aad907 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -23,6 +23,16 @@ "types": "./dist/cjs/js/index.d.ts", "default": "./dist/cjs/js/index.js" } + }, + "./testutils": { + "import": { + "types": "./dist/esm/js/testutils/index.d.ts", + "default": "./dist/esm/js/testutils/index.js" + }, + "require": { + "types": "./dist/cjs/js/testutils/index.d.ts", + "default": "./dist/cjs/js/testutils/index.js" + } } }, "main": "./dist/cjs/js/index.js",