diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 909bc8c..4df9eb2 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 4e541b1..bba3fd8 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 9d6e570..17b3b14 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/js/README.md b/packages/wasm-utxo/js/README.md new file mode 100644 index 0000000..c1b3515 --- /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 0000000..7ca01aa --- /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 0000000..ed8e8ee --- /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 0000000..3e25c36 --- /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 c369367..20aa64d 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 0000000..6d72cca --- /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 0000000..b4a6573 --- /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/package.json b/packages/wasm-utxo/package.json index 6cb2bf1..5479f2e 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", diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index b8b8e63..f206335 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 5b5e623..8057194 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/bip32interface.rs b/packages/wasm-utxo/src/fixed_script_wallet/bip32interface.rs new file mode 100644 index 0000000..01b393d --- /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 4118f97..f45c5bd 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; @@ -17,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, @@ -30,12 +31,12 @@ 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()) } - #[wasm_bindgen(js_name = address)] + #[wasm_bindgen] pub fn address( keys: JsValue, chain: u32, @@ -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 0e2f23f..33c2117 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 0be632e..a1586f0 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 1738d57..5b35528 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 4841683..25b17fd 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 0461ce6..f5f0294 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/src/wasm-bindgen.md b/packages/wasm-utxo/src/wasm-bindgen.md new file mode 100644 index 0000000..1cbaa1f --- /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 776fbf3..588be47 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -3,50 +3,12 @@ import * as fs from "node:fs/promises"; import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; -import { - utxolibCompat, - FixedScriptWallet, - toOutputScriptWithCoin, - fromOutputScriptWithCoin, - type CoinName, - AddressFormat, -} from "../../js"; +import { utxolibCompat, address as addressNs, type CoinName, AddressFormat } from "../../js"; 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": @@ -119,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); } }); @@ -138,31 +96,14 @@ 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); } }); - - 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 0000000..2c4758f --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -0,0 +1,56 @@ +import assert from "node:assert"; + +import * as utxolib from "@bitgo/utxo-lib"; + +import { fixedScriptWallet } from "../../js"; + +type Triple = [T, T, T]; + +function getAddressUtxoLib( + keys: utxolib.bitgo.RootWalletKeys, + chain: number, + index: number, + network: utxolib.Network, +): string { + if (!utxolib.bitgo.isChainCode(chain)) { + throw new Error(`Invalid chain code: ${chain}`); + } + + const derived = keys.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 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) => { + 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 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); + } + } + }); + }); +} + +utxolib.getNetworkList().forEach((network) => { + runTest(network); + runTest(network, ["m/1/2", "m/0/0", "m/0/0"]); +});