From 56cbcf9d562c185542e21c50ef39f01d408d81ab Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Oct 2025 16:20:10 +0100 Subject: [PATCH 1/4] feat(wasm-utxo): add wasm-bindgen-test for browser testing Add testing dependencies for writing browser-based WASM tests Issue: BTC-2652 Co-authored-by: llm-git --- .github/workflows/ci.yml | 8 +++ packages/wasm-utxo/Cargo.lock | 101 ++++++++++++++++++++++++++++++++ packages/wasm-utxo/Cargo.toml | 1 + packages/wasm-utxo/package.json | 2 + 4 files changed, 112 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 909bc8c4..4df9eb24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,5 +80,13 @@ jobs: - name: Check Source Code Formatting run: npm run check-fmt + - name: Wasm-Pack Test (Node) + run: npm run test:wasm-pack-node + working-directory: packages/wasm-utxo + + - name: Wasm-Pack Test (Chrome) + run: npm run test:wasm-pack-chrome + working-directory: packages/wasm-utxo + - name: Unit Test run: npm --workspaces test diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 4e541b16..bba3fd87 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -148,6 +148,16 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniscript" version = "12.3.4" @@ -193,6 +203,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -267,6 +286,16 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -294,6 +323,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.104" @@ -326,6 +368,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e381134e148c1062f965a42ed1f5ee933eef2927c3f70d1812158f711d39865" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b673bca3298fe582aeef8352330ecbad91849f85090805582400850f8270a2e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasm-utxo" version = "0.1.0" @@ -338,4 +404,39 @@ dependencies = [ "serde", "serde_json", "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 9d6e570f..17b3b145 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -20,6 +20,7 @@ base64 = "0.22.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hex = "0.4" +wasm-bindgen-test = "0.3" [profile.release] # this is required to make webpack happy diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 6cb2bf10..5479f2e7 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -27,6 +27,8 @@ }, "scripts": { "test": "mocha --recursive test", + "test:wasm-pack-node": "wasm-pack test --node", + "test:wasm-pack-chrome": "wasm-pack test --headless --chrome", "build:wasm": "make js/wasm/ && make dist/node/js/wasm/ && make dist/browser/js/wasm/", "build:ts-browser": "tsc --noEmit false --module es2020 --target es2020 --outDir dist/browser", "build:ts-node": "tsc --noEmit false --outDir dist/node", From 6d578165421ff561ee58abed2eefefb1bedae900 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Oct 2025 16:27:28 +0100 Subject: [PATCH 2/4] feat(wasm-utxo): move fixed script wallet tests to separate file Move the wallet key address derivation tests from utxolibCompat.ts to a new fixedScript/address.ts file for better organization and separation of concerns. Co-authored-by: llm-git --- .../wasm-utxo/test/address/utxolibCompat.ts | 49 --------------- .../wasm-utxo/test/fixedScript/address.ts | 63 +++++++++++++++++++ 2 files changed, 63 insertions(+), 49 deletions(-) create mode 100644 packages/wasm-utxo/test/fixedScript/address.ts diff --git a/packages/wasm-utxo/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index 776fbf39..5aa7850f 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -5,7 +5,6 @@ import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; import { utxolibCompat, - FixedScriptWallet, toOutputScriptWithCoin, fromOutputScriptWithCoin, type CoinName, @@ -16,37 +15,6 @@ type Triple = [T, T, T]; type Fixture = [type: string, script: string, address: string]; -function getAddressUtxoLib( - keys: Triple, - chain: number, - index: number, - network: utxolib.Network, -): string { - if (!utxolib.bitgo.isChainCode(chain)) { - throw new Error(`Invalid chain code: ${chain}`); - } - - const walletKeys = new utxolib.bitgo.RootWalletKeys(keys); - const derived = walletKeys.deriveForChainAndIndex(chain, index); - const script = utxolib.bitgo.outputScripts.createOutputScript2of3( - derived.publicKeys, - utxolib.bitgo.outputScripts.scriptTypeForChain(chain), - ); - const address = utxolib.address.fromOutputScript(script.scriptPubKey, network); - return address; -} - -function getAddressWasm( - keys: Triple, - chain: number, - index: number, - network: utxolib.Network, -): string { - const xpubs = keys.map((key) => key.neutered().toBase58()); - const wasmAddress = FixedScriptWallet.address(xpubs, chain, index, network); - return wasmAddress; -} - function getCoinNameForNetwork(name: string): CoinName { switch (name) { case "bitcoin": @@ -146,23 +114,6 @@ function runTest(network: utxolib.Network, addressFormat?: AddressFormat) { assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); } }); - - const keyTriple = utxolib.testutil.getKeyTriple("wasm"); - - const supportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { - const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); - return utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType); - }); - - it(`can recreate address from wallet keys for chain codes ${supportedChainCodes.join(", ")}`, function () { - for (const chainCode of supportedChainCodes) { - for (let index = 0; index < 2; index++) { - const utxolibAddress = getAddressUtxoLib(keyTriple, chainCode, index, network); - const wasmAddress = getAddressWasm(keyTriple, chainCode, index, network); - assert.strictEqual(utxolibAddress, wasmAddress); - } - } - }); }); } diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts new file mode 100644 index 00000000..fc8f0e67 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -0,0 +1,63 @@ +import assert from "node:assert"; + +import * as utxolib from "@bitgo/utxo-lib"; + +import { FixedScriptWallet } from "../../js"; + +type Triple = [T, T, T]; + +function getAddressUtxoLib( + keys: Triple, + chain: number, + index: number, + network: utxolib.Network, +): string { + if (!utxolib.bitgo.isChainCode(chain)) { + throw new Error(`Invalid chain code: ${chain}`); + } + + const walletKeys = new utxolib.bitgo.RootWalletKeys(keys); + const derived = walletKeys.deriveForChainAndIndex(chain, index); + const script = utxolib.bitgo.outputScripts.createOutputScript2of3( + derived.publicKeys, + utxolib.bitgo.outputScripts.scriptTypeForChain(chain), + ); + const address = utxolib.address.fromOutputScript(script.scriptPubKey, network); + return address; +} + +function getAddressWasm( + keys: Triple, + chain: number, + index: number, + network: utxolib.Network, +): string { + const xpubs = keys.map((key) => key.neutered().toBase58()); + const wasmAddress = FixedScriptWallet.address(xpubs, chain, index, network); + return wasmAddress; +} + +function runTest(network: utxolib.Network) { + describe(`address for network ${utxolib.getNetworkName(network)}`, function () { + const keyTriple = utxolib.testutil.getKeyTriple("wasm"); + + const supportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { + const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); + return utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType); + }); + + it(`can recreate address from wallet keys for chain codes ${supportedChainCodes.join(", ")}`, function () { + for (const chainCode of supportedChainCodes) { + for (let index = 0; index < 2; index++) { + const utxolibAddress = getAddressUtxoLib(keyTriple, chainCode, index, network); + const wasmAddress = getAddressWasm(keyTriple, chainCode, index, network); + assert.strictEqual(utxolibAddress, wasmAddress); + } + } + }); + }); +} + +utxolib.getNetworkList().forEach((network) => { + runTest(network); +}); From d157957937cbefcf10a3f9ad25f214190060ff22 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Oct 2025 17:20:50 +0100 Subject: [PATCH 3/4] feat(wasm-utxo): add BIP32Interface support for wallet keys Add ability to parse and use wallet keys from BIP32Interface objects instead of requiring xpub strings. This provides a more flexible API for consuming code that already has BIP32 key objects. The implementation supports both property access and toBase58() method to extract the keys. Issue: BTC-2652 Co-authored-by: llm-git --- .../src/fixed_script_wallet/bip32interface.rs | 56 ++++ .../wasm-utxo/src/fixed_script_wallet/mod.rs | 9 +- .../src/fixed_script_wallet/test_utils/mod.rs | 4 +- .../src/fixed_script_wallet/wallet_keys.rs | 311 +++++++++++++++++- .../wallet_scripts/checkmultisig.rs | 27 +- .../fixed_script_wallet/wallet_scripts/mod.rs | 48 ++- packages/wasm-utxo/src/try_from_js_value.rs | 75 ++++- .../wasm-utxo/test/fixedScript/address.ts | 29 +- 8 files changed, 492 insertions(+), 67 deletions(-) create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/bip32interface.rs diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bip32interface.rs b/packages/wasm-utxo/src/fixed_script_wallet/bip32interface.rs new file mode 100644 index 00000000..01b393d5 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bip32interface.rs @@ -0,0 +1,56 @@ +use std::str::FromStr; + +use crate::bitcoin::bip32::Xpub; +use crate::error::WasmMiniscriptError; +use crate::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| WasmMiniscriptError::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(|_| WasmMiniscriptError::new("Failed to get 'toBase58' method"))?; + + if !to_base58.is_function() { + return Err(WasmMiniscriptError::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(|_| WasmMiniscriptError::new("Failed to call 'toBase58'"))?; + + let xpub_string = xpub_str + .as_string() + .ok_or_else(|| WasmMiniscriptError::new("'toBase58' did not return a string"))?; + + Xpub::from_str(&xpub_string) + .map_err(|e| WasmMiniscriptError::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/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 4118f97c..1651202f 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -1,5 +1,6 @@ /// This module contains code for the BitGo Fixed Script Wallets. /// These are not based on descriptors. +mod bip32interface; mod wallet_keys; pub mod wallet_scripts; @@ -30,8 +31,8 @@ impl FixedScriptWallet { let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; - let xpubs = xpub_triple_from_jsvalue(&keys)?; - let scripts = WalletScripts::from_xpubs(&xpubs, chain, index); + let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; + let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); Ok(scripts.output_script().to_bytes()) } @@ -43,10 +44,10 @@ impl FixedScriptWallet { network: JsValue, ) -> Result { let network = Network::try_from_js_value(&network)?; - let xpubs = xpub_triple_from_jsvalue(&keys)?; + let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; - let scripts = WalletScripts::from_xpubs(&xpubs, chain, index); + let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); let script = scripts.output_script(); let address = crate::address::utxolib_compat::from_output_script_with_network( &script, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs index 0e2f23f9..33c21179 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs @@ -7,6 +7,7 @@ use super::wallet_scripts::{Chain, WalletScripts}; use crate::bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; use crate::bitcoin::psbt::{Input as PsbtInput, Output as PsbtOutput, Psbt}; use crate::bitcoin::{Transaction, TxIn, TxOut}; +use crate::RootWalletKeys; use std::collections::BTreeMap; use std::str::FromStr; @@ -31,7 +32,8 @@ pub fn get_test_wallet_keys(seed: &str) -> XpubTriple { /// Create a PSBT output for an external wallet (different keys) pub fn create_external_output(seed: &str) -> PsbtOutput { let xpubs = get_test_wallet_keys(seed); - let _scripts = WalletScripts::from_xpubs(&xpubs, Chain::P2wshExternal, 0); + let _scripts = + WalletScripts::from_wallet_keys(&RootWalletKeys::new(xpubs), Chain::P2wshExternal, 0); PsbtOutput { bip32_derivation: BTreeMap::new(), // witness_script: scripts.witness_script, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index 0be632e1..a1586f01 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -1,10 +1,13 @@ use std::convert::TryInto; use std::str::FromStr; -use crate::bitcoin::{bip32::Xpub, CompressedPublicKey}; +use crate::bitcoin::bip32::{ChildNumber, DerivationPath}; +use crate::bitcoin::{bip32::Xpub, secp256k1::Secp256k1, CompressedPublicKey}; use crate::error::WasmMiniscriptError; use wasm_bindgen::JsValue; +use super::bip32interface::xpub_from_bip32interface; + pub type XpubTriple = [Xpub; 3]; pub type PubTriple = [CompressedPublicKey; 3]; @@ -55,11 +58,180 @@ pub fn to_pub_triple(xpubs: &XpubTriple) -> PubTriple { .expect("could not convert vec to array") } +#[derive(Debug)] +pub struct RootWalletKeys { + xpubs: XpubTriple, + derivation_prefixes: [DerivationPath; 3], +} + +impl RootWalletKeys { + pub fn new_with_derivation_prefixes( + xpubs: XpubTriple, + derivation_prefixes: [DerivationPath; 3], + ) -> Self { + Self { + xpubs, + derivation_prefixes, + } + } + + pub fn new(xpubs: XpubTriple) -> Self { + Self::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(), + ], + ) + } + + pub fn derive_for_chain_and_index( + &self, + chain: u32, + index: u32, + ) -> Result { + let paths: Vec = self + .derivation_prefixes + .iter() + .map(|p| { + p.child(ChildNumber::Normal { index: chain }) + .child(ChildNumber::Normal { index }) + }) + .collect::>(); + + let ctx = Secp256k1::new(); + + // zip xpubs and paths, and return a Result + self.xpubs + .iter() + .zip(paths.iter()) + .map(|(x, p)| { + x.derive_pub(&ctx, p) + .map_err(|e| WasmMiniscriptError::new(&format!("Error deriving xpub: {}", e))) + }) + .collect::, _>>()? + .try_into() + .map_err(|_| WasmMiniscriptError::new("Expected exactly 3 derived xpubs")) + } + + pub(crate) fn 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(|_| WasmMiniscriptError::new("Failed to get 'triple' property"))?; + + if !js_sys::Array::is_array(&triple) { + return Err(WasmMiniscriptError::new( + "'triple' property must be an array", + )); + } + + let triple_array = js_sys::Array::from(&triple); + if triple_array.length() != 3 { + return Err(WasmMiniscriptError::new( + "'triple' must contain exactly 3 keys", + )); + } + + // Extract xpubs from BIP32Interface objects + let xpubs: XpubTriple = (0..3) + .map(|i| { + let bip32_key = triple_array.get(i); + xpub_from_bip32interface(&bip32_key) + }) + .collect::, _>>()? + .try_into() + .map_err(|_| WasmMiniscriptError::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(|| { + WasmMiniscriptError::new("Prefix is not a string") + }) + }) + .collect::, _>>() + .and_then(|v| { + v.try_into().map_err(|_| { + WasmMiniscriptError::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| { + WasmMiniscriptError::new(&format!("Invalid derivation prefix: {}", e)) + }) + }) + .collect::, _>>()? + .try_into() + .map_err(|_| WasmMiniscriptError::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(WasmMiniscriptError::new( + "Expected array of xpub strings or WalletKeys object", + )) + } + } +} + #[cfg(test)] pub mod tests { use crate::bitcoin::bip32::{Xpriv, Xpub}; use crate::bitcoin::hashes::{sha256, Hash}; - use crate::fixed_script_wallet::wallet_keys::XpubTriple; + use crate::RootWalletKeys; pub type XprivTriple = [Xpriv; 3]; @@ -80,16 +252,141 @@ pub mod tests { [a, b, c] } - pub fn get_test_wallet_keys(seed: &str) -> XpubTriple { + pub fn get_test_wallet_keys(seed: &str) -> RootWalletKeys { let xprvs = get_test_wallet_xprvs(seed); - let secp = crate::bitcoin::secp256k1::Secp256k1::new(); - let xpubs: XpubTriple = xprvs.map(|x| Xpub::from_priv(&secp, &x)); - xpubs + let secp = crate::bitcoin::key::Secp256k1::new(); + RootWalletKeys::new(xprvs.map(|x| Xpub::from_priv(&secp, &x))) } #[test] fn it_works() { let keys = get_test_wallet_keys("test"); - assert_eq!(keys[0].to_string(), "tpubD6NzVbkrYhZ4XUs2skvAi3vaZPKQ2oebm4FNyzbHwo8cWoZ81e2Gt1w836KdQWNtf7AgsPBtZ4t4KuoTuaKdzAbgeoygoKqgU6L2GnisU9a"); + 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::RootWalletKeys; + 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 = RootWalletKeys::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 = RootWalletKeys::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 = RootWalletKeys::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 = RootWalletKeys::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 = RootWalletKeys::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 = RootWalletKeys::from_jsvalue(&js_array.into()); + assert!(result.is_err()); } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs index 1738d577..5b35528a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs @@ -100,14 +100,15 @@ mod tests { use crate::bitcoin::blockdata::script::Builder; use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; use crate::fixed_script_wallet::wallet_keys::to_pub_triple; - use crate::fixed_script_wallet::wallet_scripts::{derive_xpubs, Chain}; + use crate::fixed_script_wallet::wallet_scripts::Chain; #[test] fn test_parse_multisig_script_2_of_3_valid() { // Get test keys let wallet_keys = get_test_wallet_keys("test_parse"); - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(&wallet_keys, &ctx, Chain::P2shExternal, 0); + let derived_keys = wallet_keys + .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) + .unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build a valid 2-of-3 multisig script @@ -125,8 +126,9 @@ mod tests { // Test multiple different key sets for seed in ["seed1", "seed2", "seed3"] { let wallet_keys = get_test_wallet_keys(seed); - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(&wallet_keys, &ctx, Chain::P2shExternal, 42); + let derived_keys = wallet_keys + .derive_for_chain_and_index(Chain::P2shExternal as u32, 42) + .unwrap(); let original_keys = to_pub_triple(&derived_keys); // Build script from keys @@ -166,8 +168,9 @@ mod tests { fn test_parse_multisig_script_2_of_3_wrong_quorum() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_wrong_quorum"); - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(&wallet_keys, &ctx, Chain::P2shExternal, 0); + let derived_keys = wallet_keys + .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) + .unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong quorum (OP_1 instead of OP_2) @@ -191,8 +194,9 @@ mod tests { fn test_parse_multisig_script_2_of_3_wrong_total() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_wrong_total"); - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(&wallet_keys, &ctx, Chain::P2shExternal, 0); + let derived_keys = wallet_keys + .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) + .unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong total (OP_4 instead of OP_3) @@ -216,8 +220,9 @@ mod tests { fn test_parse_multisig_script_2_of_3_missing_checkmultisig() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_missing_checkmultisig"); - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(&wallet_keys, &ctx, Chain::P2shExternal, 0); + let derived_keys = wallet_keys + .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) + .unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script without OP_CHECKMULTISIG diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index 4841683c..25b17fdf 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -15,6 +15,7 @@ pub use singlesig::{build_p2pk_script, ScriptP2shP2pk}; use crate::bitcoin::bip32::{ChildNumber, DerivationPath}; use crate::bitcoin::ScriptBuf; use crate::fixed_script_wallet::wallet_keys::{to_pub_triple, PubTriple, XpubTriple}; +use crate::RootWalletKeys; use std::convert::TryFrom; use std::str::FromStr; @@ -80,11 +81,15 @@ impl WalletScripts { } } - pub fn from_xpubs(xpubs: &XpubTriple, chain: Chain, index: u32) -> WalletScripts { - let ctx = crate::bitcoin::secp256k1::Secp256k1::new(); - let derived_keys = derive_xpubs(xpubs, &ctx, chain, index); - let pub_triple = to_pub_triple(&derived_keys); - WalletScripts::new(&pub_triple, chain) + pub fn from_wallet_keys( + wallet_keys: &RootWalletKeys, + chain: Chain, + index: u32, + ) -> WalletScripts { + let derived_keys = wallet_keys + .derive_for_chain_and_index(chain as u32, index) + .unwrap(); + WalletScripts::new(&to_pub_triple(&derived_keys), chain) } pub fn output_script(&self) -> ScriptBuf { @@ -194,15 +199,14 @@ mod tests { use super::*; use crate::fixed_script_wallet::test_utils::fixtures; use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; - use crate::fixed_script_wallet::wallet_keys::XpubTriple; - fn assert_output_script(keys: &XpubTriple, chain: Chain, expected_script: &str) { - let scripts = WalletScripts::from_xpubs(keys, chain, 0); + fn assert_output_script(keys: &RootWalletKeys, chain: Chain, expected_script: &str) { + let scripts = WalletScripts::from_wallet_keys(keys, chain, 0); let output_script = scripts.output_script(); assert_eq!(output_script.to_hex_string(), expected_script); } - fn test_build_multisig_chain_with(keys: &XpubTriple, chain: Chain) { + fn test_build_multisig_chain_with(keys: &RootWalletKeys, chain: Chain) { match chain { Chain::P2shExternal => { assert_output_script( @@ -295,20 +299,6 @@ mod tests { Ok((chain, index)) } - fn xprvs_to_xpubs(xprvs: &[crate::bitcoin::bip32::Xpriv]) -> Result { - if xprvs.len() != 3 { - return Err(format!("Expected 3 xprvs, got {}", xprvs.len())); - } - let secp = crate::bitcoin::secp256k1::Secp256k1::new(); - let xpubs: Vec = xprvs - .iter() - .map(|xprv| Xpub::from_priv(&secp, xprv)) - .collect(); - xpubs - .try_into() - .map_err(|_| "Failed to convert to XpubTriple".to_string()) - } - fn parse_fixture_paths( fixture_input: &fixtures::PsbtInputFixture, ) -> Result<(Chain, u32), String> { @@ -377,14 +367,22 @@ mod tests { let fixture = fixtures::load_psbt_fixture("bitcoin", fixtures::SignatureState::Fullsigned) .expect("Failed to load fixture"); let xprvs = fixtures::parse_wallet_keys(&fixture).expect("Failed to parse wallet keys"); - let xpubs = xprvs_to_xpubs(&xprvs).expect("Failed to convert to xpubs"); + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + let wallet_keys = RootWalletKeys::new( + xprvs + .iter() + .map(|x| Xpub::from_priv(&secp, x)) + .collect::>() + .try_into() + .expect("Failed to convert to XpubTriple"), + ); let (input_index, input_fixture) = find_input_with_script_type(&fixture, script_type) .expect("Failed to find input with script type"); let (chain, index) = parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths"); - let scripts = WalletScripts::from_xpubs(&xpubs, chain, index); + let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); // Use the new helper methods for validation match (scripts, input_fixture) { diff --git a/packages/wasm-utxo/src/try_from_js_value.rs b/packages/wasm-utxo/src/try_from_js_value.rs index 0461ce6f..f5f0294b 100644 --- a/packages/wasm-utxo/src/try_from_js_value.rs +++ b/packages/wasm-utxo/src/try_from_js_value.rs @@ -18,6 +18,15 @@ impl TryFromJsValue for String { } } +impl TryFromJsValue for u8 { + fn try_from_js_value(value: &JsValue) -> Result { + value + .as_f64() + .ok_or_else(|| WasmMiniscriptError::new("Expected a number")) + .map(|n| n as u8) + } +} + impl TryFromJsValue for u32 { fn try_from_js_value(value: &JsValue) -> Result { value @@ -38,7 +47,10 @@ impl TryFromJsValue for Option { } // Helper function to get a field from an object and convert it using TryFromJsValue -fn get_field(obj: &JsValue, key: &str) -> Result { +pub(crate) fn get_field( + obj: &JsValue, + key: &str, +) -> Result { let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) .map_err(|_| WasmMiniscriptError::new(&format!("Failed to read {} from object", key)))?; @@ -46,6 +58,67 @@ fn get_field(obj: &JsValue, key: &str) -> Result( + obj: &JsValue, + path: &str, +) -> Result { + let parts: Vec<&str> = path.split('.').collect(); + let mut current = obj.clone(); + + for (i, part) in parts.iter().enumerate() { + if i == parts.len() - 1 { + // Last part - extract and convert + return get_field(¤t, part); + } else { + // Intermediate part - just get the object + current = js_sys::Reflect::get(¤t, &JsValue::from_str(part)).map_err(|_| { + WasmMiniscriptError::new(&format!("Failed to read {} from object", part)) + })?; + } + } + + Err(WasmMiniscriptError::new("Empty path")) +} + +// Helper function to get a buffer field as a fixed-size byte array +pub(crate) fn get_buffer_field( + obj: &JsValue, + key: &str, +) -> Result<[u8; N], WasmMiniscriptError> { + let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|_| WasmMiniscriptError::new(&format!("Failed to read {} from object", key)))?; + + let buffer = js_sys::Uint8Array::new(&field_value); + if buffer.length() as usize != N { + return Err(WasmMiniscriptError::new(&format!( + "{} must be {} bytes, got {}", + key, + N, + buffer.length() + ))); + } + + let mut bytes = [0u8; N]; + buffer.copy_to(&mut bytes); + Ok(bytes) +} + +// Helper function to get a buffer field as a Vec +#[allow(dead_code)] +pub(crate) fn get_buffer_field_vec( + obj: &JsValue, + key: &str, +) -> Result, WasmMiniscriptError> { + let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|_| WasmMiniscriptError::new(&format!("Failed to read {} from object", key)))?; + + let buffer = js_sys::Uint8Array::new(&field_value); + let mut bytes = vec![0u8; buffer.length() as usize]; + buffer.copy_to(&mut bytes); + Ok(bytes) +} + impl TryFromJsValue for Network { fn try_from_js_value(value: &JsValue) -> Result { let pub_key_hash = get_field(value, "pubKeyHash")?; diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts index fc8f0e67..95f1ebbd 100644 --- a/packages/wasm-utxo/test/fixedScript/address.ts +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -7,7 +7,7 @@ import { FixedScriptWallet } from "../../js"; type Triple = [T, T, T]; function getAddressUtxoLib( - keys: Triple, + keys: utxolib.bitgo.RootWalletKeys, chain: number, index: number, network: utxolib.Network, @@ -16,8 +16,7 @@ function getAddressUtxoLib( throw new Error(`Invalid chain code: ${chain}`); } - const walletKeys = new utxolib.bitgo.RootWalletKeys(keys); - const derived = walletKeys.deriveForChainAndIndex(chain, index); + const derived = keys.deriveForChainAndIndex(chain, index); const script = utxolib.bitgo.outputScripts.createOutputScript2of3( derived.publicKeys, utxolib.bitgo.outputScripts.scriptTypeForChain(chain), @@ -26,19 +25,8 @@ function getAddressUtxoLib( return address; } -function getAddressWasm( - keys: Triple, - chain: number, - index: number, - network: utxolib.Network, -): string { - const xpubs = keys.map((key) => key.neutered().toBase58()); - const wasmAddress = FixedScriptWallet.address(xpubs, chain, index, network); - return wasmAddress; -} - -function runTest(network: utxolib.Network) { - describe(`address for network ${utxolib.getNetworkName(network)}`, function () { +function runTest(network: utxolib.Network, derivationPrefixes?: Triple) { + describe(`address for network ${utxolib.getNetworkName(network)}, derivationPrefixes=${Boolean(derivationPrefixes)}`, function () { const keyTriple = utxolib.testutil.getKeyTriple("wasm"); const supportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { @@ -49,8 +37,12 @@ function runTest(network: utxolib.Network) { it(`can recreate address from wallet keys for chain codes ${supportedChainCodes.join(", ")}`, function () { for (const chainCode of supportedChainCodes) { for (let index = 0; index < 2; index++) { - const utxolibAddress = getAddressUtxoLib(keyTriple, chainCode, index, network); - const wasmAddress = getAddressWasm(keyTriple, chainCode, index, network); + const rootWalletKeys = new utxolib.bitgo.RootWalletKeys( + keyTriple.map((k) => k.neutered()) as Triple, + derivationPrefixes, + ); + const utxolibAddress = getAddressUtxoLib(rootWalletKeys, chainCode, index, network); + const wasmAddress = FixedScriptWallet.address(rootWalletKeys, chainCode, index, network); assert.strictEqual(utxolibAddress, wasmAddress); } } @@ -60,4 +52,5 @@ function runTest(network: utxolib.Network) { utxolib.getNetworkList().forEach((network) => { runTest(network); + runTest(network, ["m/1/2", "m/0/0", "m/0/0"]); }); From 8ecd880c8f0c403f930c7f7f8751906949bb50e9 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 28 Oct 2025 14:09:55 +0100 Subject: [PATCH 4/4] feat(wasm-utxo): implement namespace wrapper pattern for better TypeScript APIs This PR introduces a cleaner, more type-safe architecture for WASM bindings: 1. Organizes related Rust functions into namespace classes 2. Wraps generated WASM bindings with precise TypeScript types 3. Creates dedicated wrapper modules for each feature area 4. Adds comprehensive documentation for the pattern The pattern replaces loose types (`any`, `string | null`) with precise TypeScript types, improving IDE autocompletion and compile-time safety. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/README.md | 89 ++++++++++++++ packages/wasm-utxo/js/address.ts | 16 +++ packages/wasm-utxo/js/coinName.ts | 23 ++++ packages/wasm-utxo/js/fixedScriptWallet.ts | 29 +++++ packages/wasm-utxo/js/index.ts | 73 ++--------- packages/wasm-utxo/js/triple.ts | 1 + packages/wasm-utxo/js/utxolibCompat.ts | 50 ++++++++ packages/wasm-utxo/src/address/networks.rs | 57 +++++---- .../wasm-utxo/src/address/utxolib_compat.rs | 12 +- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 8 +- packages/wasm-utxo/src/wasm-bindgen.md | 116 ++++++++++++++++++ .../wasm-utxo/test/address/utxolibCompat.ts | 20 +-- .../wasm-utxo/test/fixedScript/address.ts | 4 +- 13 files changed, 378 insertions(+), 120 deletions(-) create mode 100644 packages/wasm-utxo/js/README.md create mode 100644 packages/wasm-utxo/js/address.ts create mode 100644 packages/wasm-utxo/js/coinName.ts create mode 100644 packages/wasm-utxo/js/fixedScriptWallet.ts create mode 100644 packages/wasm-utxo/js/triple.ts create mode 100644 packages/wasm-utxo/js/utxolibCompat.ts create mode 100644 packages/wasm-utxo/src/wasm-bindgen.md diff --git a/packages/wasm-utxo/js/README.md b/packages/wasm-utxo/js/README.md new file mode 100644 index 00000000..c1b35151 --- /dev/null +++ b/packages/wasm-utxo/js/README.md @@ -0,0 +1,89 @@ +# Purpose + +The primary purpose of this directory is to expose better TypeScript signatures than those +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 + +This directory implements a **namespace wrapper pattern** that provides a cleaner, more +type-safe API over the raw WASM bindings. + +### Pattern Overview + +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 loose types (`any`, `string | null`) due to WASM-bindgen limitations + +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 + - Re-export related types for convenience + +3. **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`) + - Uses `export * as` to group related functionality into namespaces + - Re-exports shared types for top-level access + - Augments WASM types with additional TypeScript declarations + +### Example + +Given a WASM-generated class: + +```typescript +// wasm/wasm_utxo.d.ts (generated) +export class AddressNamespace { + static to_output_script_with_coin(address: string, coin: string): Uint8Array; + static from_output_script_with_coin( + script: Uint8Array, + coin: string, + format?: string | null, + ): string; +} +``` + +We create a wrapper module: + +```typescript +// address.ts +import { AddressNamespace } from "./wasm/wasm_utxo"; +import type { CoinName } from "./coinName"; + +export type AddressFormat = "default" | "cashaddr"; + +export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { + return AddressNamespace.to_output_script_with_coin(address, coin); +} + +export function fromOutputScriptWithCoin( + script: Uint8Array, + coin: CoinName, + format?: AddressFormat, +): string { + return AddressNamespace.from_output_script_with_coin(script, coin, format); +} +``` + +And expose it via the main entry point: + +```typescript +// index.ts +export * as address from "./address"; +``` + +### Benefits + +- **Type Safety**: Replace loose `any` and `string` types with precise union types +- **Better DX**: IDE autocomplete works better with concrete types +- **Maintainability**: Centralized type definitions prevent duplication diff --git a/packages/wasm-utxo/js/address.ts b/packages/wasm-utxo/js/address.ts new file mode 100644 index 00000000..7ca01aa1 --- /dev/null +++ b/packages/wasm-utxo/js/address.ts @@ -0,0 +1,16 @@ +import { AddressNamespace } from "./wasm/wasm_utxo"; +import type { CoinName } from "./coinName"; + +export type AddressFormat = "default" | "cashaddr"; + +export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { + return AddressNamespace.to_output_script_with_coin(address, coin); +} + +export function fromOutputScriptWithCoin( + script: Uint8Array, + coin: CoinName, + format?: AddressFormat, +): string { + return AddressNamespace.from_output_script_with_coin(script, coin, format); +} diff --git a/packages/wasm-utxo/js/coinName.ts b/packages/wasm-utxo/js/coinName.ts new file mode 100644 index 00000000..ed8e8ee4 --- /dev/null +++ b/packages/wasm-utxo/js/coinName.ts @@ -0,0 +1,23 @@ +// BitGo coin names (from Network::from_coin_name in src/networks.rs) +export type CoinName = + | "btc" + | "tbtc" + | "tbtc4" + | "tbtcsig" + | "tbtcbgsig" + | "bch" + | "tbch" + | "bcha" + | "tbcha" + | "btg" + | "tbtg" + | "bsv" + | "tbsv" + | "dash" + | "tdash" + | "doge" + | "tdoge" + | "ltc" + | "tltc" + | "zec" + | "tzec"; diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts new file mode 100644 index 00000000..3e25c368 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -0,0 +1,29 @@ +import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo"; +import type { UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat"; +import { Triple } from "./triple"; + +export type WalletKeys = + /** Just an xpub triple, will assume default derivation prefixes */ + | Triple + /** Compatible with utxolib RootWalletKeys */ + | UtxolibRootWalletKeys; + +/** + * Create the output script for a given wallet keys and chain and index + */ +export function outputScript(keys: WalletKeys, chain: number, index: number): Uint8Array { + return FixedScriptWalletNamespace.output_script(keys, chain, index); +} + +/** + * 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. + */ +export function address( + keys: WalletKeys, + chain: number, + index: number, + network: UtxolibNetwork, +): string { + return FixedScriptWalletNamespace.address(keys, chain, index, network); +} diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index c3693673..20aa64d2 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -4,40 +4,23 @@ import * as wasm from "./wasm/wasm_utxo"; // and forgets to include it in the bundle void wasm; +export * as address from "./address"; +export * as ast from "./ast"; +export * as utxolibCompat from "./utxolibCompat"; +export * as fixedScriptWallet from "./fixedScriptWallet"; + +export type { CoinName } from "./coinName"; +export type { Triple } from "./triple"; +export type { AddressFormat } from "./address"; + export type DescriptorPkType = "derivable" | "definite" | "string"; export type ScriptContext = "tap" | "segwitv0" | "legacy"; -export type AddressFormat = "default" | "cashaddr"; - export type SignPsbtResult = { [inputIndex: number]: [pubkey: string][]; }; -// BitGo coin names (from Network::from_coin_name in src/networks.rs) -export type CoinName = - | "btc" - | "tbtc" - | "tbtc4" - | "tbtcsig" - | "tbtcbgsig" - | "bch" - | "tbch" - | "bcha" - | "tbcha" - | "btg" - | "tbtg" - | "bsv" - | "tbsv" - | "dash" - | "tdash" - | "doge" - | "tdoge" - | "ltc" - | "tltc" - | "zec" - | "tzec"; - declare module "./wasm/wasm_utxo" { interface WrapDescriptor { /** These are not the same types of nodes as in the ast module */ @@ -63,46 +46,8 @@ declare module "./wasm/wasm_utxo" { signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; signWithPrv(this: WrapPsbt, prv: Uint8Array): SignPsbtResult; } - - interface Address { - /** - * Convert output script to address string - * @param script - The output script as a byte array - * @param network - The utxolib Network object from JavaScript - * @param format - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) - */ - fromOutputScript(script: Uint8Array, network: any, format?: AddressFormat): string; - /** - * Convert address string to output script - * @param address - The address string - * @param network - The utxolib Network object from JavaScript - * @param format - Optional address format (currently unused for decoding as all formats are accepted) - */ - toOutputScript(address: string, network: any, format?: AddressFormat): Uint8Array; - } } -import { Address as WasmAddress } from "./wasm/wasm_utxo"; - export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo"; export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo"; export { WrapPsbt as Psbt } from "./wasm/wasm_utxo"; -export { FixedScriptWallet } from "./wasm/wasm_utxo"; - -export namespace utxolibCompat { - export const Address = WasmAddress; -} - -export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { - return wasm.toOutputScriptWithCoin(address, coin); -} - -export function fromOutputScriptWithCoin( - script: Uint8Array, - coin: CoinName, - format?: AddressFormat, -): string { - return wasm.fromOutputScriptWithCoin(script, coin, format); -} - -export * as ast from "./ast"; diff --git a/packages/wasm-utxo/js/triple.ts b/packages/wasm-utxo/js/triple.ts new file mode 100644 index 00000000..6d72ccaf --- /dev/null +++ b/packages/wasm-utxo/js/triple.ts @@ -0,0 +1 @@ +export type Triple = [T, T, T]; diff --git a/packages/wasm-utxo/js/utxolibCompat.ts b/packages/wasm-utxo/js/utxolibCompat.ts new file mode 100644 index 00000000..b4a65734 --- /dev/null +++ b/packages/wasm-utxo/js/utxolibCompat.ts @@ -0,0 +1,50 @@ +import type { AddressFormat } from "./address"; +import { Triple } from "./triple"; +import { UtxolibCompatNamespace } from "./wasm/wasm_utxo"; + +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; + cashAddr?: { + prefix: string; + pubKeyHash: number; + scriptHash: number; + }; + bech32?: string; +}; + +export function fromOutputScript( + script: Uint8Array, + network: UtxolibNetwork, + format?: AddressFormat, +): string { + return UtxolibCompatNamespace.from_output_script(script, network, format); +} + +export function toOutputScript( + address: string, + network: UtxolibNetwork, + format?: AddressFormat, +): Uint8Array { + return UtxolibCompatNamespace.to_output_script(address, network, format); +} diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index b8b8e633..f206335c 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -211,36 +211,35 @@ pub fn from_output_script_with_coin_and_format( // WASM bindings use wasm_bindgen::prelude::*; -/// WASM binding: Convert an address string to an output script using a BitGo coin name. -#[wasm_bindgen(js_name = toOutputScriptWithCoin)] -pub fn to_output_script_with_coin_js( - address: &str, - coin: &str, -) -> std::result::Result, JsValue> { - to_output_script_with_coin(address, coin) - .map(|script| script.to_bytes()) - .map_err(|e| JsValue::from_str(&e.to_string())) -} +#[wasm_bindgen] +pub struct AddressNamespace; + +#[wasm_bindgen] +impl AddressNamespace { + #[wasm_bindgen] + pub fn to_output_script_with_coin( + address: &str, + coin: &str, + ) -> std::result::Result, JsValue> { + to_output_script_with_coin(address, coin) + .map(|script| script.to_bytes()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } -/// WASM binding: Convert an output script to an address string using a BitGo coin name. -/// -/// # Arguments -/// * `script` - The output script bytes -/// * `coin` - The BitGo coin name (e.g., "btc", "bch", "ecash") -/// * `format` - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) -#[wasm_bindgen(js_name = fromOutputScriptWithCoin)] -pub fn from_output_script_with_coin_js( - script: &[u8], - coin: &str, - format: Option, -) -> std::result::Result { - let script_obj = Script::from_bytes(script); - let format_str = format.as_deref(); - let address_format = AddressFormat::from_optional_str(format_str) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - from_output_script_with_coin_and_format(script_obj, coin, address_format) - .map_err(|e| JsValue::from_str(&e.to_string())) + #[wasm_bindgen] + pub fn from_output_script_with_coin( + script: &[u8], + coin: &str, + format: Option, + ) -> std::result::Result { + let script_obj = Script::from_bytes(script); + let format_str = format.as_deref(); + let address_format = AddressFormat::from_optional_str(format_str) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + from_output_script_with_coin_and_format(script_obj, coin, address_format) + .map_err(|e| JsValue::from_str(&e.to_string())) + } } #[cfg(test)] diff --git a/packages/wasm-utxo/src/address/utxolib_compat.rs b/packages/wasm-utxo/src/address/utxolib_compat.rs index 5b5e623a..80571944 100644 --- a/packages/wasm-utxo/src/address/utxolib_compat.rs +++ b/packages/wasm-utxo/src/address/utxolib_compat.rs @@ -125,18 +125,18 @@ pub fn to_output_script_with_network(address: &str, network: &Network) -> Result use wasm_bindgen::prelude::*; #[wasm_bindgen] -pub struct Address; +pub struct UtxolibCompatNamespace; #[wasm_bindgen] -impl Address { +impl UtxolibCompatNamespace { /// Convert output script to address string /// /// # Arguments /// * `script` - The output script as a byte array /// * `network` - The utxolib Network object from JavaScript /// * `format` - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) - #[wasm_bindgen(js_name = fromOutputScript)] - pub fn from_output_script_js( + #[wasm_bindgen] + pub fn from_output_script( script: &[u8], network: JsValue, format: Option, @@ -160,8 +160,8 @@ impl Address { /// * `address` - The address string /// * `network` - The utxolib Network object from JavaScript /// * `format` - Optional address format (currently unused for decoding as all formats are accepted) - #[wasm_bindgen(js_name = toOutputScript)] - pub fn to_output_script_js( + #[wasm_bindgen] + pub fn to_output_script( address: &str, network: JsValue, format: Option, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 1651202f..f45c5bd4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -18,11 +18,11 @@ use crate::try_from_js_value::TryFromJsValue; use crate::utxolib_compat::Network; #[wasm_bindgen] -pub struct FixedScriptWallet; +pub struct FixedScriptWalletNamespace; #[wasm_bindgen] -impl FixedScriptWallet { - #[wasm_bindgen(js_name = outputScript)] +impl FixedScriptWalletNamespace { + #[wasm_bindgen] pub fn output_script( keys: JsValue, chain: u32, @@ -36,7 +36,7 @@ impl FixedScriptWallet { Ok(scripts.output_script().to_bytes()) } - #[wasm_bindgen(js_name = address)] + #[wasm_bindgen] pub fn address( keys: JsValue, chain: u32, diff --git a/packages/wasm-utxo/src/wasm-bindgen.md b/packages/wasm-utxo/src/wasm-bindgen.md new file mode 100644 index 00000000..1cbaa1f2 --- /dev/null +++ b/packages/wasm-utxo/src/wasm-bindgen.md @@ -0,0 +1,116 @@ +# `wasm-bindgen` Usage + +This crate exposes Rust functions via the `wasm-bindgen` crate and macros. + +## Namespacing Pattern + +Since `wasm-bindgen` flattens all exports to a single module by default, we use a **namespace struct pattern** to organize related functions into logical groups. + +### Rust Side: Namespace Structs + +Create empty structs with `#[wasm_bindgen]` to serve as namespaces, then implement static methods: + +```rust +// address/mod.rs + +#[wasm_bindgen] +pub struct AddressNamespace; + +#[wasm_bindgen] +impl AddressNamespace { + pub fn to_output_script_with_coin(address: &str, coin: &str) -> Result, WasmError> { + // implementation + } + + pub fn from_output_script_with_coin( + script: &[u8], + coin: &str, + format: Option, + ) -> Result { + // implementation + } +} +``` + +### Key Conventions + +1. **Naming**: Use `*Namespace` suffix for namespace structs (e.g., `AddressNamespace`, `UtxolibCompatNamespace`) + +2. **Structure**: Empty structs with no fields - they exist purely for organization + +3. **Methods**: All functions are static methods on the namespace struct + +4. **Case**: Use `snake_case` for method names (Rust convention) - they'll be available as both `snake_case` and `camelCase` in JavaScript + +5. **Error Handling**: Return `Result` types - `wasm-bindgen` automatically converts these to JavaScript exceptions + +### Generated TypeScript + +The Rust namespace struct becomes a TypeScript class with static methods: + +```typescript +// wasm/wasm_utxo.d.ts (generated by wasm-bindgen) +export class AddressNamespace { + private constructor(); + static to_output_script_with_coin(address: string, coin: string): Uint8Array; + static from_output_script_with_coin( + script: Uint8Array, + coin: string, + format?: string | null, + ): string; +} +``` + +### TypeScript Wrapper Layer + +The generated types have limitations (loose types like `any`, `string | null`). We wrap them with better TypeScript types in the `js/` directory. + +**See `../js/README.md` for the complete TypeScript wrapper pattern.** + +The wrapper layer: + +- Imports the generated namespace classes +- Defines precise TypeScript types (e.g., union types instead of `string`) +- Exports wrapper functions with strong type signatures +- Provides better IDE support and compile-time type checking + +### Example Flow + +1. **Rust**: Define `AddressNamespace` struct with static methods +2. **wasm-bindgen**: Generates `AddressNamespace` class in `wasm_utxo.d.ts` +3. **TypeScript Wrapper**: `address.ts` wraps it with precise types +4. **Main Export**: `index.ts` exports it as `export * as address from "./address"` + +This three-layer approach gives us: + +- Clear organization in Rust +- Automatic WASM bindings +- Type-safe, well-documented TypeScript API + +## Type Mapping + +Common Rust ↔ JavaScript type mappings: + +| Rust | JavaScript/TypeScript | Notes | +| ------------------ | --------------------- | ------------------------------ | +| `&str`, `String` | `string` | Strings are copied | +| `&[u8]`, `Vec` | `Uint8Array` | Efficient binary data | +| `u32`, `i32`, etc. | `number` | JavaScript numbers are f64 | +| `bool` | `boolean` | | +| `Option` | `T \| undefined` | Becomes optional parameter | +| `Result` | `T` (throws on Err) | Errors become exceptions | +| Custom structs | `any` (usually) | Reason for TypeScript wrappers | + +## Best Practices + +1. **Keep namespace structs empty** - They're purely for organization + +2. **Use descriptive namespace names** - Clear what domain they cover (e.g., `AddressNamespace`, `PsbtNamespace`) + +3. **Return `Result` types** - Let `wasm-bindgen` handle error conversion to JavaScript exceptions + +4. **Avoid complex types in signatures** - Stick to primitives and byte arrays when possible; use `JsValue` for complex types + +5. **Document with Rust doc comments** - They'll appear in the generated TypeScript + +6. **Coordinate with TypeScript wrappers** - Keep the wrapper layer in mind when designing the Rust API diff --git a/packages/wasm-utxo/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index 5aa7850f..588be474 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -3,13 +3,7 @@ import * as fs from "node:fs/promises"; import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; -import { - utxolibCompat, - toOutputScriptWithCoin, - fromOutputScriptWithCoin, - type CoinName, - AddressFormat, -} from "../../js"; +import { utxolibCompat, address as addressNs, type CoinName, AddressFormat } from "../../js"; type Triple = [T, T, T]; @@ -87,13 +81,9 @@ function runTest(network: utxolib.Network, addressFormat?: AddressFormat) { for (const fixture of fixtures) { const [_type, script, addressRef] = fixture; const scriptBuf = Buffer.from(script, "hex"); - const address = utxolibCompat.Address.fromOutputScript(scriptBuf, network, addressFormat); + const address = utxolibCompat.fromOutputScript(scriptBuf, network, addressFormat); assert.strictEqual(address, addressRef); - const scriptFromAddress = utxolibCompat.Address.toOutputScript( - address, - network, - addressFormat, - ); + const scriptFromAddress = utxolibCompat.toOutputScript(address, network, addressFormat); assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); } }); @@ -106,11 +96,11 @@ function runTest(network: utxolib.Network, addressFormat?: AddressFormat) { const scriptBuf = Buffer.from(script, "hex"); // Test encoding (script -> address) - const address = fromOutputScriptWithCoin(scriptBuf, coinName, addressFormat); + const address = addressNs.fromOutputScriptWithCoin(scriptBuf, coinName, addressFormat); assert.strictEqual(address, addressRef); // Test decoding (address -> script) - const scriptFromAddress = toOutputScriptWithCoin(addressRef, coinName); + const scriptFromAddress = addressNs.toOutputScriptWithCoin(addressRef, coinName); assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); } }); diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts index 95f1ebbd..2c4758fa 100644 --- a/packages/wasm-utxo/test/fixedScript/address.ts +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { FixedScriptWallet } from "../../js"; +import { fixedScriptWallet } from "../../js"; type Triple = [T, T, T]; @@ -42,7 +42,7 @@ function runTest(network: utxolib.Network, derivationPrefixes?: Triple) derivationPrefixes, ); const utxolibAddress = getAddressUtxoLib(rootWalletKeys, chainCode, index, network); - const wasmAddress = FixedScriptWallet.address(rootWalletKeys, chainCode, index, network); + const wasmAddress = fixedScriptWallet.address(rootWalletKeys, chainCode, index, network); assert.strictEqual(utxolibAddress, wasmAddress); } }