diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index a1d0add6b58..3bace3ef90e 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -3154,6 +3154,7 @@ dependencies = [ "js-sys", "miniscript", "musig2", + "num-bigint", "pastey", "rstest", "serde", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index f4c447a5dba..7546cae9315 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -20,6 +20,10 @@ unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(feature, values("zebra-test"))', ] } +[features] +default = [] +inspect = ["dep:num-bigint", "dep:serde", "dep:serde_json", "dep:hex"] + [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" @@ -28,6 +32,10 @@ bech32 = "0.11" musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } pastey = "0.1" +num-bigint = { version = "0.4", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } +hex = { version = "0.4", optional = true } [dev-dependencies] base64 = "0.22.1" diff --git a/packages/wasm-utxo/README.md b/packages/wasm-utxo/README.md index 9d5c00a89d5..7ddf0c77469 100644 --- a/packages/wasm-utxo/README.md +++ b/packages/wasm-utxo/README.md @@ -31,6 +31,36 @@ Zcash support includes: - **Height-Based API**: Preferred `createEmpty()` method automatically selects correct consensus rules - **Parity Testing**: Validated against `zebra-chain` for accuracy across all network upgrades +## Inspect Feature + +The `inspect` feature adds PSBT and transaction parsing into hierarchical node trees, +useful for building tree-view UIs and CLI formatters. + +It is behind a Cargo feature flag because it pulls in extra dependencies (`serde`, `serde_json`, +`num-bigint`, `hex`) that are not needed for core wallet operations. + +### Rust + +```rust +// Cargo.toml +wasm-utxo = { path = ".", features = ["inspect"] } + +// Usage +use wasm_utxo::inspect::{parse_psbt_bytes_with_network, parse_tx_bytes_with_network, Node}; +``` + +### TypeScript + +Available as a separate import path, not included in the main `@bitgo/wasm-utxo` entry: + +```typescript +import { parsePsbtToNode, parseTxToNode, isInspectEnabled } from "@bitgo/wasm-utxo/inspect"; +``` + +The published npm package includes stub implementations that return `isInspectEnabled() === false` +and throw runtime errors from the parse functions. To get a working build, compile the WASM with +`--features inspect` (see [`packages/webui/scripts/build-wasm.sh`](../webui/scripts/build-wasm.sh)). + ## Building ### Mac diff --git a/packages/wasm-utxo/cli/Cargo.toml b/packages/wasm-utxo/cli/Cargo.toml index e93af42b292..fc60c03f0df 100644 --- a/packages/wasm-utxo/cli/Cargo.toml +++ b/packages/wasm-utxo/cli/Cargo.toml @@ -8,7 +8,7 @@ name = "wasm-utxo-cli" path = "src/main.rs" [dependencies] -wasm-utxo = { path = ".." } +wasm-utxo = { path = "..", features = ["inspect"] } clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" hex = "0.4" diff --git a/packages/wasm-utxo/cli/src/address.rs b/packages/wasm-utxo/cli/src/address.rs index 726ce5697cf..c83ba42877e 100644 --- a/packages/wasm-utxo/cli/src/address.rs +++ b/packages/wasm-utxo/cli/src/address.rs @@ -3,37 +3,39 @@ use clap::Subcommand; use wasm_utxo::bitcoin::Script; use wasm_utxo::{from_output_script_with_network, to_output_script_with_network, Network}; +use crate::network::NetworkArg; + #[derive(Subcommand)] pub enum AddressCommand { /// Decode an address to its output script (hex) Decode { /// The address to decode address: String, - /// Network (bitcoin, testnet, litecoin, zcash, etc.) - #[arg(short, long, default_value = "bitcoin")] - network: String, + /// Network (btc, tbtc, ltc, bch, zec, etc.) + #[arg(short, long, value_enum)] + network: NetworkArg, }, /// Encode an output script (hex) to an address Encode { /// Output script as hex script: String, - /// Network (bitcoin, testnet, litecoin, zcash, etc.) - #[arg(short, long, default_value = "bitcoin")] - network: String, + /// Network (btc, tbtc, ltc, bch, zec, etc.) + #[arg(short, long, value_enum)] + network: NetworkArg, }, } pub fn handle_command(command: AddressCommand) -> Result<()> { match command { AddressCommand::Decode { address, network } => { - let network = parse_network(&network)?; + let network: Network = network.into(); let script = to_output_script_with_network(&address, network) .context("Failed to decode address")?; println!("{}", hex::encode(script.as_bytes())); Ok(()) } AddressCommand::Encode { script, network } => { - let network = parse_network(&network)?; + let network: Network = network.into(); let script_bytes = hex::decode(&script).context("Invalid hex string for output script")?; let script_obj = Script::from_bytes(&script_bytes); @@ -44,32 +46,3 @@ pub fn handle_command(command: AddressCommand) -> Result<()> { } } } - -fn parse_network(network: &str) -> Result { - // Try utxolib name first (e.g., "bitcoin", "testnet", "bitcoincash") - if let Some(net) = Network::from_utxolib_name(network) { - return Ok(net); - } - - // Try coin name (e.g., "btc", "ltc", "bch") - if let Some(net) = Network::from_coin_name(network) { - return Ok(net); - } - - // Try common aliases - let normalized = network.to_lowercase(); - match normalized.as_str() { - "test" | "testnet3" => Ok(Network::BitcoinTestnet3), - "signet" => Ok(Network::BitcoinPublicSignet), - "ltctest" => Ok(Network::LitecoinTestnet), - "bchtest" => Ok(Network::BitcoinCashTestnet), - "bsvtest" => Ok(Network::BitcoinSVTestnet), - "btgtest" => Ok(Network::BitcoinGoldTestnet), - "dashtest" => Ok(Network::DashTestnet), - "zectest" => Ok(Network::ZcashTestnet), - "dogetest" => Ok(Network::DogecoinTestnet), - "xec" => Ok(Network::Ecash), - "xectest" => Ok(Network::EcashTestnet), - _ => anyhow::bail!("Unknown network: {}", network), - } -} diff --git a/packages/wasm-utxo/cli/src/format/fixtures.rs b/packages/wasm-utxo/cli/src/format/fixtures.rs index 220019e60bf..5871c3c89fa 100644 --- a/packages/wasm-utxo/cli/src/format/fixtures.rs +++ b/packages/wasm-utxo/cli/src/format/fixtures.rs @@ -1,13 +1,13 @@ #[cfg(test)] use super::tree::{node_to_string_with_scheme, ColorScheme}; #[cfg(test)] -use crate::node::Node; -#[cfg(test)] use std::env; #[cfg(test)] use std::fs; #[cfg(test)] use std::io::{self, Write}; +#[cfg(test)] +use wasm_utxo::inspect::Node; /// Ensure the generated tree output matches the fixture file /// If the fixture doesn't exist, it will be created diff --git a/packages/wasm-utxo/cli/src/format/tests.rs b/packages/wasm-utxo/cli/src/format/tests.rs index f84a4058f43..b6f4647b03e 100644 --- a/packages/wasm-utxo/cli/src/format/tests.rs +++ b/packages/wasm-utxo/cli/src/format/tests.rs @@ -1,6 +1,6 @@ use crate::format::fixtures::assert_or_update_fixture; -use crate::node::{Node, Primitive}; use num_bigint::BigInt; +use wasm_utxo::inspect::{Node, Primitive}; #[test] fn test_simple_tree() -> std::io::Result<()> { diff --git a/packages/wasm-utxo/cli/src/format/tree.rs b/packages/wasm-utxo/cli/src/format/tree.rs index a39ce328d7c..ab01267aaad 100644 --- a/packages/wasm-utxo/cli/src/format/tree.rs +++ b/packages/wasm-utxo/cli/src/format/tree.rs @@ -1,10 +1,10 @@ -use crate::node::{Node, Primitive}; use colored::*; use ptree::print_tree; #[cfg(test)] use ptree::TreeBuilder; use std::borrow::Cow; use std::io; +use wasm_utxo::inspect::{Node, Primitive}; /// Defines how different parts of the tree should be styled #[derive(Clone, Debug)] diff --git a/packages/wasm-utxo/cli/src/main.rs b/packages/wasm-utxo/cli/src/main.rs index cd19e9cad81..49db661657e 100644 --- a/packages/wasm-utxo/cli/src/main.rs +++ b/packages/wasm-utxo/cli/src/main.rs @@ -4,11 +4,14 @@ use clap::{Parser, Subcommand}; mod address; mod format; mod input; -mod node; -mod parse; +mod network; mod psbt; mod tx; +pub use network::NetworkArg; + +#[cfg(test)] +mod parse_tests; #[cfg(test)] pub mod test_utils; diff --git a/packages/wasm-utxo/cli/src/network.rs b/packages/wasm-utxo/cli/src/network.rs new file mode 100644 index 00000000000..49864abea6d --- /dev/null +++ b/packages/wasm-utxo/cli/src/network.rs @@ -0,0 +1,54 @@ +//! Network argument type for CLI commands + +use clap::ValueEnum; +use wasm_utxo::Network; + +/// CLI argument type for network selection +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum NetworkArg { + Btc, + Tbtc, + Tbtc4, + Ltc, + Tltc, + Bch, + Tbch, + Bcha, + Tbcha, + Btg, + Tbtg, + Bsv, + Tbsv, + Dash, + Tdash, + Doge, + Tdoge, + Zec, + Tzec, +} + +impl From for Network { + fn from(arg: NetworkArg) -> Self { + match arg { + NetworkArg::Btc => Network::Bitcoin, + NetworkArg::Tbtc => Network::BitcoinTestnet3, + NetworkArg::Tbtc4 => Network::BitcoinTestnet4, + NetworkArg::Ltc => Network::Litecoin, + NetworkArg::Tltc => Network::LitecoinTestnet, + NetworkArg::Bch => Network::BitcoinCash, + NetworkArg::Tbch => Network::BitcoinCashTestnet, + NetworkArg::Bcha => Network::Ecash, + NetworkArg::Tbcha => Network::EcashTestnet, + NetworkArg::Btg => Network::BitcoinGold, + NetworkArg::Tbtg => Network::BitcoinGoldTestnet, + NetworkArg::Bsv => Network::BitcoinSV, + NetworkArg::Tbsv => Network::BitcoinSVTestnet, + NetworkArg::Dash => Network::Dash, + NetworkArg::Tdash => Network::DashTestnet, + NetworkArg::Doge => Network::Dogecoin, + NetworkArg::Tdoge => Network::DogecoinTestnet, + NetworkArg::Zec => Network::Zcash, + NetworkArg::Tzec => Network::ZcashTestnet, + } + } +} diff --git a/packages/wasm-utxo/cli/src/parse/mod.rs b/packages/wasm-utxo/cli/src/parse/mod.rs deleted file mode 100644 index ab908bd296a..00000000000 --- a/packages/wasm-utxo/cli/src/parse/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod node; -pub mod node_raw; - -pub use node::{parse_psbt_bytes_internal, parse_tx_bytes_internal}; -pub use node_raw::parse_psbt_bytes_raw; diff --git a/packages/wasm-utxo/cli/src/parse/node.rs b/packages/wasm-utxo/cli/src/parse/node.rs deleted file mode 100644 index 66b37104bcf..00000000000 --- a/packages/wasm-utxo/cli/src/parse/node.rs +++ /dev/null @@ -1,508 +0,0 @@ -/// This contains low-level parsing of PSBT into a node structure suitable for display -use wasm_utxo::bitcoin::consensus::Decodable; -use wasm_utxo::bitcoin::hashes::Hash; -use wasm_utxo::bitcoin::psbt::Psbt; -use wasm_utxo::bitcoin::{Network, ScriptBuf, Transaction}; -use wasm_utxo::fixed_script_wallet::bitgo_psbt::{ - p2tr_musig2_input::{Musig2PartialSig, Musig2Participants, Musig2PubNonce}, - BitGoKeyValue, ProprietaryKeySubtype, BITGO, -}; - -pub use crate::node::{Node, Primitive}; - -fn script_buf_to_node(label: &str, script_buf: &ScriptBuf) -> Node { - let mut node = Node::new(label, Primitive::Buffer(script_buf.to_bytes())); - node.add_child(Node::new( - "asm", - Primitive::String(script_buf.to_asm_string()), - )); - node -} - -fn bip32_derivations_to_nodes( - bip32_derivation: &std::collections::BTreeMap< - wasm_utxo::bitcoin::secp256k1::PublicKey, - ( - wasm_utxo::bitcoin::bip32::Fingerprint, - wasm_utxo::bitcoin::bip32::DerivationPath, - ), - >, -) -> Vec { - bip32_derivation - .iter() - .map(|(pubkey, (fingerprint, path))| { - let mut derivation_node = Node::new("bip32_derivation", Primitive::None); - derivation_node.add_child(Node::new( - "pubkey", - Primitive::Buffer(pubkey.serialize().to_vec()), - )); - derivation_node.add_child(Node::new( - "fingerprint", - Primitive::Buffer(fingerprint.to_bytes().to_vec()), - )); - derivation_node.add_child(Node::new("path", Primitive::String(path.to_string()))); - derivation_node - }) - .collect() -} - -fn musig2_participants_to_node(participants: &Musig2Participants) -> Node { - let mut node = Node::new("musig2_participants", Primitive::None); - node.add_child(Node::new( - "tap_output_key", - Primitive::Buffer(participants.tap_output_key.serialize().to_vec()), - )); - node.add_child(Node::new( - "tap_internal_key", - Primitive::Buffer(participants.tap_internal_key.serialize().to_vec()), - )); - - let mut participants_node = Node::new("participant_pub_keys", Primitive::U64(2)); - for (i, pub_key) in participants.participant_pub_keys.iter().enumerate() { - let pub_key_vec: Vec = pub_key.to_bytes().as_slice().to_vec(); - participants_node.add_child(Node::new( - format!("participant_{}", i), - Primitive::Buffer(pub_key_vec), - )); - } - node.add_child(participants_node); - node -} - -fn musig2_pub_nonce_to_node(nonce: &Musig2PubNonce) -> Node { - let mut node = Node::new("musig2_pub_nonce", Primitive::None); - node.add_child(Node::new( - "participant_pub_key", - Primitive::Buffer(nonce.participant_pub_key.to_bytes().to_vec()), - )); - node.add_child(Node::new( - "tap_output_key", - Primitive::Buffer(nonce.tap_output_key.serialize().to_vec()), - )); - node.add_child(Node::new( - "pub_nonce", - Primitive::Buffer(nonce.pub_nonce.serialize().to_vec()), - )); - node -} - -fn musig2_partial_sig_to_node(sig: &Musig2PartialSig) -> Node { - let mut node = Node::new("musig2_partial_sig", Primitive::None); - node.add_child(Node::new( - "participant_pub_key", - Primitive::Buffer(sig.participant_pub_key.to_bytes().to_vec()), - )); - node.add_child(Node::new( - "tap_output_key", - Primitive::Buffer(sig.tap_output_key.serialize().to_vec()), - )); - node.add_child(Node::new( - "partial_sig", - Primitive::Buffer(sig.partial_sig.clone()), - )); - node -} - -fn bitgo_proprietary_to_node( - prop_key: &wasm_utxo::bitcoin::psbt::raw::ProprietaryKey, - v: &[u8], -) -> Node { - // Try to parse as BitGo key-value - let v_vec = v.to_vec(); - let bitgo_kv_result = BitGoKeyValue::from_key_value(prop_key, &v_vec); - - match bitgo_kv_result { - Ok(bitgo_kv) => { - // Parse based on subtype - match bitgo_kv.subtype { - ProprietaryKeySubtype::Musig2ParticipantPubKeys => { - match Musig2Participants::from_key_value(&bitgo_kv) { - Ok(participants) => musig2_participants_to_node(&participants), - Err(_) => { - // Fall back to raw display - raw_proprietary_to_node("musig2_participants_error", prop_key, v) - } - } - } - ProprietaryKeySubtype::Musig2PubNonce => { - match Musig2PubNonce::from_key_value(&bitgo_kv) { - Ok(nonce) => musig2_pub_nonce_to_node(&nonce), - Err(_) => { - // Fall back to raw display - raw_proprietary_to_node("musig2_pub_nonce_error", prop_key, v) - } - } - } - ProprietaryKeySubtype::Musig2PartialSig => { - match Musig2PartialSig::from_key_value(&bitgo_kv) { - Ok(sig) => musig2_partial_sig_to_node(&sig), - Err(_) => { - // Fall back to raw display - raw_proprietary_to_node("musig2_partial_sig_error", prop_key, v) - } - } - } - _ => { - // Other BitGo subtypes - show with name - let subtype_name = match bitgo_kv.subtype { - ProprietaryKeySubtype::ZecConsensusBranchId => "zec_consensus_branch_id", - ProprietaryKeySubtype::PayGoAddressAttestationProof => { - "paygo_address_attestation_proof" - } - ProprietaryKeySubtype::Bip322Message => "bip322_message", - _ => "unknown", - }; - raw_proprietary_to_node(subtype_name, prop_key, v) - } - } - } - Err(_) => { - // Not a valid BitGo key-value, show raw - raw_proprietary_to_node("unknown", prop_key, v) - } - } -} - -fn raw_proprietary_to_node( - label: &str, - prop_key: &wasm_utxo::bitcoin::psbt::raw::ProprietaryKey, - v: &[u8], -) -> Node { - let mut prop_node = Node::new(label, Primitive::None); - prop_node.add_child(Node::new( - "prefix", - Primitive::String(String::from_utf8_lossy(&prop_key.prefix).to_string()), - )); - prop_node.add_child(Node::new("subtype", Primitive::U8(prop_key.subtype))); - prop_node.add_child(Node::new( - "key_data", - Primitive::Buffer(prop_key.key.to_vec()), - )); - prop_node.add_child(Node::new("value", Primitive::Buffer(v.to_vec()))); - prop_node -} - -fn proprietary_to_nodes( - proprietary: &std::collections::BTreeMap< - wasm_utxo::bitcoin::psbt::raw::ProprietaryKey, - Vec, - >, -) -> Vec { - proprietary - .iter() - .map(|(prop_key, v)| { - // Check if this is a BITGO proprietary key - if prop_key.prefix.as_slice() == BITGO { - bitgo_proprietary_to_node(prop_key, v) - } else { - raw_proprietary_to_node("key", prop_key, v) - } - }) - .collect() -} - -fn xpubs_to_nodes( - xpubs: &std::collections::BTreeMap< - wasm_utxo::bitcoin::bip32::Xpub, - ( - wasm_utxo::bitcoin::bip32::Fingerprint, - wasm_utxo::bitcoin::bip32::DerivationPath, - ), - >, -) -> Vec { - xpubs - .iter() - .map(|(xpub, (fingerprint, path))| { - let mut xpub_node = Node::new("xpub", Primitive::None); - xpub_node.add_child(Node::new("xpub", Primitive::String(xpub.to_string()))); - xpub_node.add_child(Node::new( - "fingerprint", - Primitive::Buffer(fingerprint.to_bytes().to_vec()), - )); - xpub_node.add_child(Node::new("path", Primitive::String(path.to_string()))); - xpub_node - }) - .collect() -} - -pub fn xpubs_to_node( - xpubs: &std::collections::BTreeMap< - wasm_utxo::bitcoin::bip32::Xpub, - ( - wasm_utxo::bitcoin::bip32::Fingerprint, - wasm_utxo::bitcoin::bip32::DerivationPath, - ), - >, -) -> Node { - let mut xpubs_node = Node::new("xpubs", Primitive::U64(xpubs.len() as u64)); - for node in xpubs_to_nodes(xpubs) { - xpubs_node.add_child(node); - } - xpubs_node -} - -pub fn psbt_to_node(psbt: &Psbt, network: Network) -> Node { - let mut psbt_node = Node::new("psbt", Primitive::None); - - let tx = &psbt.unsigned_tx; - psbt_node.add_child(tx_to_node(tx, network)); - - psbt_node.add_child(xpubs_to_node(&psbt.xpub)); - - if !psbt.proprietary.is_empty() { - let mut proprietary_node = - Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); - proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); - psbt_node.add_child(proprietary_node); - } - - psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); - - let mut inputs_node = Node::new("inputs", Primitive::U64(psbt.inputs.len() as u64)); - for (i, input) in psbt.inputs.iter().enumerate() { - let mut input_node = Node::new(format!("input_{}", i), Primitive::None); - - if let Some(utxo) = &input.non_witness_utxo { - input_node.add_child(Node::new( - "non_witness_utxo", - Primitive::Buffer(utxo.compute_txid().to_byte_array().to_vec()), - )); - } - - if let Some(witness_utxo) = &input.witness_utxo { - let mut witness_node = Node::new("witness_utxo", Primitive::None); - witness_node.add_child(Node::new( - "value", - Primitive::U64(witness_utxo.value.to_sat()), - )); - witness_node.add_child(Node::new( - "script_pubkey", - Primitive::Buffer(witness_utxo.script_pubkey.as_bytes().to_vec()), - )); - witness_node.add_child(Node::new( - "address", - Primitive::String( - wasm_utxo::bitcoin::Address::from_script(&witness_utxo.script_pubkey, network) - .map(|a| a.to_string()) - .unwrap_or_else(|_| "".to_string()), - ), - )); - input_node.add_child(witness_node); - } - - if let Some(redeem_script) = &input.redeem_script { - input_node.add_child(script_buf_to_node("redeem_script", redeem_script)); - } - - if let Some(witness_script) = &input.witness_script { - input_node.add_child(script_buf_to_node("witness_script", witness_script)) - } - - let mut sigs_node = Node::new( - "signatures", - Primitive::U64(input.partial_sigs.len() as u64), - ); - for (i, (pubkey, sig)) in input.partial_sigs.iter().enumerate() { - let mut sig_node = Node::new(format!("{}", i), Primitive::None); - sig_node.add_child(Node::new("pubkey", Primitive::Buffer(pubkey.to_bytes()))); - sig_node.add_child(Node::new("signature", Primitive::Buffer(sig.to_vec()))); - sigs_node.add_child(sig_node); - } - - if !input.partial_sigs.is_empty() { - input_node.add_child(sigs_node); - } - - if let Some(sighash) = &input.sighash_type { - input_node.add_child(Node::new("sighash_type", Primitive::U32(sighash.to_u32()))); - input_node.add_child(Node::new( - "sighash_type", - Primitive::String(sighash.to_string()), - )); - } - - input_node.extend(bip32_derivations_to_nodes(&input.bip32_derivation)); - - if !input.proprietary.is_empty() { - let mut prop_node = Node::new( - "proprietary", - Primitive::U64(input.proprietary.len() as u64), - ); - prop_node.extend(proprietary_to_nodes(&input.proprietary)); - input_node.add_child(prop_node); - } - - inputs_node.add_child(input_node); - } - - psbt_node.add_child(inputs_node); - - let mut outputs_node = Node::new("outputs", Primitive::U64(psbt.outputs.len() as u64)); - for (i, output) in psbt.outputs.iter().enumerate() { - let mut output_node = Node::new(format!("{}", i), Primitive::None); - - if let Some(script) = &output.redeem_script { - output_node.add_child(script_buf_to_node("redeem_script", script)); - } - - if let Some(script) = &output.witness_script { - output_node.add_child(script_buf_to_node("witness_script", script)); - } - - if !output.proprietary.is_empty() { - let mut prop_node = Node::new( - "proprietary", - Primitive::U64(output.proprietary.len() as u64), - ); - prop_node.extend(proprietary_to_nodes(&output.proprietary)); - output_node.add_child(prop_node); - } - - output_node.extend(bip32_derivations_to_nodes(&output.bip32_derivation)); - - outputs_node.add_child(output_node); - } - - psbt_node.add_child(outputs_node); - - psbt_node -} - -pub fn tx_to_node(tx: &Transaction, network: wasm_utxo::bitcoin::Network) -> Node { - let mut tx_node = Node::new("tx", Primitive::None); - - tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0))); - tx_node.add_child(Node::new( - "lock_time", - Primitive::U32(tx.lock_time.to_consensus_u32()), - )); - tx_node.add_child(Node::new( - "txid", - Primitive::Buffer(tx.compute_txid().to_byte_array().to_vec()), - )); - tx_node.add_child(Node::new( - "ntxid", - Primitive::Buffer(tx.compute_ntxid().to_byte_array().to_vec()), - )); - tx_node.add_child(Node::new( - "wtxid", - Primitive::Buffer(tx.compute_wtxid().to_byte_array().to_vec()), - )); - - let mut inputs_node = Node::new("inputs", Primitive::U64(tx.input.len() as u64)); - for (i, input) in tx.input.iter().enumerate() { - let mut input_node = Node::new(format!("input_{}", i), Primitive::None); - - input_node.add_child(Node::new( - "prev_txid", - Primitive::Buffer(input.previous_output.txid.to_byte_array().to_vec()), - )); - input_node.add_child(Node::new( - "prev_vout", - Primitive::U32(input.previous_output.vout), - )); - input_node.add_child(Node::new( - "sequence", - Primitive::U32(input.sequence.to_consensus_u32()), - )); - - input_node.add_child(Node::new( - "script_sig", - Primitive::Buffer(input.script_sig.as_bytes().to_vec()), - )); - - if !input.witness.is_empty() { - let mut witness_node = Node::new("witness", Primitive::U64(input.witness.len() as u64)); - - for (j, item) in input.witness.iter().enumerate() { - witness_node.add_child(Node::new( - format!("item_{}", j), - Primitive::Buffer(item.to_vec()), - )); - } - - input_node.add_child(witness_node); - } - - inputs_node.add_child(input_node); - } - - tx_node.add_child(inputs_node); - - let mut outputs_node = Node::new("outputs", Primitive::U64(tx.output.len() as u64)); - for (i, output) in tx.output.iter().enumerate() { - let mut output_node = Node::new(format!("output_{}", i), Primitive::None); - - output_node.add_child(Node::new("value", Primitive::U64(output.value.to_sat()))); - - output_node.add_child(Node::new( - "script_pubkey", - Primitive::Buffer(output.script_pubkey.as_bytes().to_vec()), - )); - - if let Ok(address) = - wasm_utxo::bitcoin::Address::from_script(&output.script_pubkey, network) - { - output_node.add_child(Node::new("address", Primitive::String(address.to_string()))); - } - - outputs_node.add_child(output_node); - } - - tx_node.add_child(outputs_node); - - tx_node -} - -pub fn parse_psbt_bytes_internal(bytes: &[u8]) -> Result { - Psbt::deserialize(bytes) - .map(|psbt| psbt_to_node(&psbt, Network::Bitcoin)) - .map_err(|e| e.to_string()) -} - -pub fn parse_tx_bytes_internal(bytes: &[u8]) -> Result { - Transaction::consensus_decode(&mut &bytes[..]) - .map(|tx| tx_to_node(&tx, Network::Bitcoin)) - .map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_psbt_bitcoin_fullsigned() -> Result<(), Box> { - use crate::format::fixtures::assert_tree_matches_fixture; - use crate::test_utils::{load_psbt_bytes, SignatureState, TxFormat}; - use wasm_utxo::Network as WasmNetwork; - - let psbt_bytes = load_psbt_bytes( - WasmNetwork::Bitcoin, - SignatureState::Fullsigned, - TxFormat::Psbt, - )?; - - let node = parse_psbt_bytes_internal(&psbt_bytes)?; - - assert_tree_matches_fixture(&node, "psbt_bitcoin_fullsigned")?; - Ok(()) - } - - #[test] - fn test_parse_tx_bitcoin_fullsigned() -> Result<(), Box> { - use crate::format::fixtures::assert_tree_matches_fixture; - use crate::test_utils::{load_tx_bytes, SignatureState, TxFormat}; - use wasm_utxo::Network as WasmNetwork; - - let tx_bytes = load_tx_bytes( - WasmNetwork::Bitcoin, - SignatureState::Fullsigned, - TxFormat::PsbtLite, - )?; - - let node = parse_tx_bytes_internal(&tx_bytes)?; - - assert_tree_matches_fixture(&node, "tx_bitcoin_fullsigned")?; - Ok(()) - } -} diff --git a/packages/wasm-utxo/cli/src/parse_tests.rs b/packages/wasm-utxo/cli/src/parse_tests.rs new file mode 100644 index 00000000000..651f2f2ae47 --- /dev/null +++ b/packages/wasm-utxo/cli/src/parse_tests.rs @@ -0,0 +1,51 @@ +//! Integration tests for inspect functionality +//! +//! These tests verify that the inspect functions from wasm-utxo work correctly +//! with the CLI fixtures. + +#[cfg(test)] +mod tests { + use crate::format::fixtures::assert_tree_matches_fixture; + use crate::test_utils::{load_psbt_bytes, load_tx_bytes, SignatureState, TxFormat}; + use wasm_utxo::inspect::{ + parse_psbt_bytes_raw_with_network, parse_psbt_bytes_with_network, + parse_tx_bytes_with_network, + }; + use wasm_utxo::Network; + + #[test] + fn test_parse_psbt_bitcoin_fullsigned() -> Result<(), Box> { + let psbt_bytes = + load_psbt_bytes(Network::Bitcoin, SignatureState::Fullsigned, TxFormat::Psbt)?; + + let node = parse_psbt_bytes_with_network(&psbt_bytes, Network::Bitcoin)?; + + assert_tree_matches_fixture(&node, "psbt_bitcoin_fullsigned")?; + Ok(()) + } + + #[test] + fn test_parse_tx_bitcoin_fullsigned() -> Result<(), Box> { + let tx_bytes = load_tx_bytes( + Network::Bitcoin, + SignatureState::Fullsigned, + TxFormat::PsbtLite, + )?; + + let node = parse_tx_bytes_with_network(&tx_bytes, Network::Bitcoin)?; + + assert_tree_matches_fixture(&node, "tx_bitcoin_fullsigned")?; + Ok(()) + } + + #[test] + fn test_parse_psbt_raw_bitcoin_fullsigned() -> Result<(), Box> { + let psbt_bytes = + load_psbt_bytes(Network::Bitcoin, SignatureState::Fullsigned, TxFormat::Psbt)?; + + let node = parse_psbt_bytes_raw_with_network(&psbt_bytes, Network::Bitcoin)?; + + assert_tree_matches_fixture(&node, "psbt_raw_bitcoin_fullsigned")?; + Ok(()) + } +} diff --git a/packages/wasm-utxo/cli/src/psbt/mod.rs b/packages/wasm-utxo/cli/src/psbt/mod.rs index e8be7fd0696..7489d51ab33 100644 --- a/packages/wasm-utxo/cli/src/psbt/mod.rs +++ b/packages/wasm-utxo/cli/src/psbt/mod.rs @@ -1,6 +1,8 @@ use anyhow::Result; use clap::Subcommand; +use crate::network::NetworkArg; + mod parse; #[derive(Subcommand)] @@ -9,6 +11,9 @@ pub enum PsbtCommand { Parse { /// Path to the PSBT file (use '-' to read from stdin) path: std::path::PathBuf, + /// Network for address formatting + #[arg(long, short, value_enum)] + network: NetworkArg, /// Disable colored output #[arg(long)] no_color: bool, @@ -24,6 +29,7 @@ pub fn handle_command(command: PsbtCommand) -> Result<()> { path, no_color, raw, - } => parse::handle_parse_command(path, no_color, raw), + network, + } => parse::handle_parse_command(path, no_color, raw, network.into()), } } diff --git a/packages/wasm-utxo/cli/src/psbt/parse.rs b/packages/wasm-utxo/cli/src/psbt/parse.rs index ea2e01db459..e29a4670054 100644 --- a/packages/wasm-utxo/cli/src/psbt/parse.rs +++ b/packages/wasm-utxo/cli/src/psbt/parse.rs @@ -3,9 +3,15 @@ use std::path::PathBuf; use crate::format::{render_tree_with_scheme, ColorScheme}; use crate::input::{decode_input, read_input_bytes}; -use crate::parse::{parse_psbt_bytes_internal, parse_psbt_bytes_raw}; +use wasm_utxo::inspect::{parse_psbt_bytes_raw_with_network, parse_psbt_bytes_with_network}; +use wasm_utxo::Network; -pub fn handle_parse_command(path: PathBuf, no_color: bool, raw: bool) -> Result<()> { +pub fn handle_parse_command( + path: PathBuf, + no_color: bool, + raw: bool, + network: Network, +) -> Result<()> { // Read from file or stdin let raw_bytes = read_input_bytes(&path, "PSBT")?; @@ -13,10 +19,10 @@ pub fn handle_parse_command(path: PathBuf, no_color: bool, raw: bool) -> Result< let bytes = decode_input(&raw_bytes)?; let node = if raw { - parse_psbt_bytes_raw(&bytes) + parse_psbt_bytes_raw_with_network(&bytes, network) .map_err(|e| anyhow::anyhow!("Failed to parse PSBT (raw): {}", e))? } else { - parse_psbt_bytes_internal(&bytes) + parse_psbt_bytes_with_network(&bytes, network) .map_err(|e| anyhow::anyhow!("Failed to parse PSBT: {}", e))? }; diff --git a/packages/wasm-utxo/cli/src/tx/mod.rs b/packages/wasm-utxo/cli/src/tx/mod.rs index 0ae0c7f0d43..c9db2136a7f 100644 --- a/packages/wasm-utxo/cli/src/tx/mod.rs +++ b/packages/wasm-utxo/cli/src/tx/mod.rs @@ -1,6 +1,8 @@ use anyhow::Result; use clap::Subcommand; +use crate::network::NetworkArg; + mod parse; #[derive(Subcommand)] @@ -9,6 +11,9 @@ pub enum TxCommand { Parse { /// Path to the transaction file (use '-' to read from stdin) path: std::path::PathBuf, + /// Network for address formatting + #[arg(long, short, value_enum)] + network: NetworkArg, /// Disable colored output #[arg(long)] no_color: bool, @@ -17,6 +22,10 @@ pub enum TxCommand { pub fn handle_command(command: TxCommand) -> Result<()> { match command { - TxCommand::Parse { path, no_color } => parse::handle_parse_command(path, no_color), + TxCommand::Parse { + path, + no_color, + network, + } => parse::handle_parse_command(path, no_color, network.into()), } } diff --git a/packages/wasm-utxo/cli/src/tx/parse.rs b/packages/wasm-utxo/cli/src/tx/parse.rs index 767bba47cd0..449eebc22da 100644 --- a/packages/wasm-utxo/cli/src/tx/parse.rs +++ b/packages/wasm-utxo/cli/src/tx/parse.rs @@ -3,16 +3,17 @@ use std::path::PathBuf; use crate::format::{render_tree_with_scheme, ColorScheme}; use crate::input::{decode_input, read_input_bytes}; -use crate::parse::parse_tx_bytes_internal; +use wasm_utxo::inspect::parse_tx_bytes_with_network; +use wasm_utxo::Network; -pub fn handle_parse_command(path: PathBuf, no_color: bool) -> Result<()> { +pub fn handle_parse_command(path: PathBuf, no_color: bool, network: Network) -> Result<()> { // Read from file or stdin let raw_bytes = read_input_bytes(&path, "transaction")?; // Decode input (auto-detect hex, base64, or raw bytes) let bytes = decode_input(&raw_bytes)?; - let node = parse_tx_bytes_internal(&bytes) + let node = parse_tx_bytes_with_network(&bytes, network) .map_err(|e| anyhow::anyhow!("Failed to parse transaction: {}", e))?; let color_scheme = if no_color { diff --git a/packages/wasm-utxo/js/inspect/index.ts b/packages/wasm-utxo/js/inspect/index.ts new file mode 100644 index 00000000000..40eab8e6ae7 --- /dev/null +++ b/packages/wasm-utxo/js/inspect/index.ts @@ -0,0 +1,201 @@ +/** + * Inspect - TypeScript bindings for PSBT and Transaction parsing + * + * Provides typed wrappers around the WASM inspect functions that return + * hierarchical node structures suitable for display as collapsible trees. + * + * Import via: `import { ... } from "@bitgo/wasm-utxo/inspect"` + */ + +import { + parsePsbtToJson as wasmParsePsbtToJson, + parseTxToJson as wasmParseTxToJson, + parsePsbtRawToJson as wasmParsePsbtRawToJson, + isInspectEnabled as wasmIsInspectEnabled, +} from "../wasm/wasm_utxo.js"; + +import type { CoinName } from "../coinName.js"; + +/** Re-export CoinName for convenience */ +export type { CoinName }; + +/** All supported networks in order of parsing priority */ +export const allNetworks: CoinName[] = [ + "btc", + "tbtc", + "tbtc4", + "tbtcsig", + "tbtcbgsig", + "ltc", + "tltc", + "bch", + "tbch", + "bcha", + "tbcha", + "btg", + "tbtg", + "bsv", + "tbsv", + "dash", + "tdash", + "doge", + "tdoge", + "zec", + "tzec", +]; + +/** + * Primitive value types that can appear in a Node. + * Buffer values are hex-encoded strings, Integer is a decimal string for BigInt support. + */ +export type PrimitiveType = + | "String" + | "Buffer" + | "Integer" + | "U8" + | "U16" + | "U32" + | "U64" + | "I8" + | "I16" + | "I32" + | "I64" + | "Boolean" + | "None"; + +/** + * A tagged union representing primitive values in the parse tree. + */ +export interface Primitive { + type: PrimitiveType; + value?: string | number | boolean; +} + +/** + * A node in the parse tree representing a PSBT or transaction element. + */ +export interface Node { + label: string; + value: Primitive; + children: Node[]; +} + +/** + * Parse a PSBT and return a typed node tree. + * + * @param psbtBytes - The raw PSBT bytes + * @param network - The network coin name (e.g., "btc", "ltc", "bch") + * @returns A Node tree representing the parsed PSBT structure + * @throws If the PSBT bytes are invalid or network is unknown + */ +export function parsePsbtToNode(psbtBytes: Uint8Array, network: CoinName): Node { + const json = wasmParsePsbtToJson(psbtBytes, network); + return JSON.parse(json) as Node; +} + +/** + * Parse a transaction and return a typed node tree. + * + * @param txBytes - The raw transaction bytes + * @param network - The network coin name (e.g., "btc", "ltc", "bch") + * @returns A Node tree representing the parsed transaction structure + * @throws If the transaction bytes are invalid or network is unknown + */ +export function parseTxToNode(txBytes: Uint8Array, network: CoinName): Node { + const json = wasmParseTxToJson(txBytes, network); + return JSON.parse(json) as Node; +} + +/** + * Try to parse a PSBT with all networks and return the first one that succeeds. + * + * @param psbtBytes - The raw PSBT bytes + * @param networks - Optional list of networks to try (defaults to all networks) + * @returns An object with the parsed Node and detected network, or null if all fail + */ +export function tryParsePsbt( + psbtBytes: Uint8Array, + networks: CoinName[] = allNetworks, +): { node: Node; network: CoinName } | null { + for (const network of networks) { + try { + const node = parsePsbtToNode(psbtBytes, network); + return { node, network }; + } catch { + // Try next network + } + } + return null; +} + +/** + * Try to parse a transaction with all networks and return the first one that succeeds. + * + * @param txBytes - The raw transaction bytes + * @param networks - Optional list of networks to try (defaults to all networks) + * @returns An object with the parsed Node and detected network, or null if all fail + */ +export function tryParseTx( + txBytes: Uint8Array, + networks: CoinName[] = allNetworks, +): { node: Node; network: CoinName } | null { + for (const network of networks) { + try { + const node = parseTxToNode(txBytes, network); + return { node, network }; + } catch { + // Try next network + } + } + return null; +} + +/** + * Parse a PSBT at the raw byte level and return a typed node tree. + * + * Unlike `parsePsbtToNode`, this function exposes the raw key-value pair + * structure as defined in BIP-174, showing: + * - Raw key type IDs and their human-readable names + * - Proprietary keys with their structured format + * - Unknown/unrecognized keys that standard parsers might skip + * + * @param psbtBytes - The raw PSBT bytes + * @param network - The network coin name (e.g., "btc", "ltc", "zec") + * @returns A Node tree representing the raw PSBT key-value structure + * @throws If the PSBT bytes are invalid or network is unknown + */ +export function parsePsbtRawToNode(psbtBytes: Uint8Array, network: CoinName): Node { + const json = wasmParsePsbtRawToJson(psbtBytes, network); + return JSON.parse(json) as Node; +} + +/** + * Try to parse a raw PSBT with all networks and return the first one that succeeds. + * + * @param psbtBytes - The raw PSBT bytes + * @param networks - Optional list of networks to try (defaults to all networks) + * @returns An object with the parsed Node and detected network, or null if all fail + */ +export function tryParsePsbtRaw( + psbtBytes: Uint8Array, + networks: CoinName[] = allNetworks, +): { node: Node; network: CoinName } | null { + for (const network of networks) { + try { + const node = parsePsbtRawToNode(psbtBytes, network); + return { node, network }; + } catch { + // Try next network + } + } + return null; +} + +/** + * Check if the inspect feature is enabled in the WASM build. + * + * @returns true if the feature is enabled, false otherwise + */ +export function isInspectEnabled(): boolean { + return wasmIsInspectEnabled(); +} diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 5328dc8ee35..1dc42ff3fe1 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -24,6 +24,16 @@ "default": "./dist/cjs/js/index.js" } }, + "./inspect": { + "import": { + "types": "./dist/esm/js/inspect/index.d.ts", + "default": "./dist/esm/js/inspect/index.js" + }, + "require": { + "types": "./dist/cjs/js/inspect/index.d.ts", + "default": "./dist/cjs/js/inspect/index.js" + } + }, "./testutils": { "import": { "types": "./dist/esm/js/testutils/index.d.ts", @@ -35,6 +45,13 @@ } } }, + "typesVersions": { + "*": { + "inspect": [ + "./dist/esm/js/inspect/index.d.ts" + ] + } + }, "main": "./dist/cjs/js/index.js", "module": "./dist/esm/js/index.js", "types": "./dist/esm/js/index.d.ts", diff --git a/packages/wasm-utxo/src/inspect/mod.rs b/packages/wasm-utxo/src/inspect/mod.rs new file mode 100644 index 00000000000..3a7e558a686 --- /dev/null +++ b/packages/wasm-utxo/src/inspect/mod.rs @@ -0,0 +1,10 @@ +mod node; +mod psbt; +mod psbt_raw; + +pub use node::{Buffer, Node, Primitive}; +pub use psbt::{ + parse_psbt_bytes_with_network, parse_tx_bytes_with_network, psbt_to_node, tx_to_node, + zcash_psbt_to_node, zcash_tx_to_node, +}; +pub use psbt_raw::parse_psbt_bytes_raw_with_network; diff --git a/packages/wasm-utxo/cli/src/node.rs b/packages/wasm-utxo/src/inspect/node.rs similarity index 100% rename from packages/wasm-utxo/cli/src/node.rs rename to packages/wasm-utxo/src/inspect/node.rs diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs new file mode 100644 index 00000000000..797daca079c --- /dev/null +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -0,0 +1,629 @@ +/// This contains low-level parsing of PSBT into a node structure suitable for display +use crate::address::from_output_script_with_network; +use crate::bitcoin::consensus::Decodable; +use crate::bitcoin::hashes::Hash; +use crate::bitcoin::psbt::Psbt; +use crate::bitcoin::{ScriptBuf, Transaction}; +use crate::fixed_script_wallet::bitgo_psbt::{ + p2tr_musig2_input::{Musig2PartialSig, Musig2Participants, Musig2PubNonce}, + BitGoKeyValue, ProprietaryKeySubtype, ZcashBitGoPsbt, BITGO, +}; +use crate::networks::Network; +use crate::zcash::transaction::{decode_zcash_transaction_parts, ZcashTransactionParts}; + +pub use super::node::{Node, Primitive}; + +fn script_buf_to_node(label: &str, script_buf: &ScriptBuf) -> Node { + let mut node = Node::new(label, Primitive::Buffer(script_buf.to_bytes())); + node.add_child(Node::new( + "asm", + Primitive::String(script_buf.to_asm_string()), + )); + node +} + +fn bip32_derivations_to_nodes( + bip32_derivation: &std::collections::BTreeMap< + crate::bitcoin::secp256k1::PublicKey, + ( + crate::bitcoin::bip32::Fingerprint, + crate::bitcoin::bip32::DerivationPath, + ), + >, +) -> Vec { + bip32_derivation + .iter() + .map(|(pubkey, (fingerprint, path))| { + let mut derivation_node = Node::new("bip32_derivation", Primitive::None); + derivation_node.add_child(Node::new( + "pubkey", + Primitive::Buffer(pubkey.serialize().to_vec()), + )); + derivation_node.add_child(Node::new( + "fingerprint", + Primitive::Buffer(fingerprint.to_bytes().to_vec()), + )); + derivation_node.add_child(Node::new("path", Primitive::String(path.to_string()))); + derivation_node + }) + .collect() +} + +fn musig2_participants_to_node(participants: &Musig2Participants) -> Node { + let mut node = Node::new("musig2_participants", Primitive::None); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(participants.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "tap_internal_key", + Primitive::Buffer(participants.tap_internal_key.serialize().to_vec()), + )); + + let mut participants_node = Node::new("participant_pub_keys", Primitive::U64(2)); + for (i, pub_key) in participants.participant_pub_keys.iter().enumerate() { + let pub_key_vec: Vec = pub_key.to_bytes().as_slice().to_vec(); + participants_node.add_child(Node::new( + format!("participant_{}", i), + Primitive::Buffer(pub_key_vec), + )); + } + node.add_child(participants_node); + node +} + +fn musig2_pub_nonce_to_node(nonce: &Musig2PubNonce) -> Node { + let mut node = Node::new("musig2_pub_nonce", Primitive::None); + node.add_child(Node::new( + "participant_pub_key", + Primitive::Buffer(nonce.participant_pub_key.to_bytes().to_vec()), + )); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(nonce.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "pub_nonce", + Primitive::Buffer(nonce.pub_nonce.serialize().to_vec()), + )); + node +} + +fn musig2_partial_sig_to_node(sig: &Musig2PartialSig) -> Node { + let mut node = Node::new("musig2_partial_sig", Primitive::None); + node.add_child(Node::new( + "participant_pub_key", + Primitive::Buffer(sig.participant_pub_key.to_bytes().to_vec()), + )); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(sig.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "partial_sig", + Primitive::Buffer(sig.partial_sig.clone()), + )); + node +} + +fn bitgo_proprietary_to_node( + prop_key: &crate::bitcoin::psbt::raw::ProprietaryKey, + v: &[u8], +) -> Node { + // Try to parse as BitGo key-value + let v_vec = v.to_vec(); + let bitgo_kv_result = BitGoKeyValue::from_key_value(prop_key, &v_vec); + + match bitgo_kv_result { + Ok(bitgo_kv) => { + // Parse based on subtype + match bitgo_kv.subtype { + ProprietaryKeySubtype::Musig2ParticipantPubKeys => { + match Musig2Participants::from_key_value(&bitgo_kv) { + Ok(participants) => musig2_participants_to_node(&participants), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_participants_error", prop_key, v) + } + } + } + ProprietaryKeySubtype::Musig2PubNonce => { + match Musig2PubNonce::from_key_value(&bitgo_kv) { + Ok(nonce) => musig2_pub_nonce_to_node(&nonce), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_pub_nonce_error", prop_key, v) + } + } + } + ProprietaryKeySubtype::Musig2PartialSig => { + match Musig2PartialSig::from_key_value(&bitgo_kv) { + Ok(sig) => musig2_partial_sig_to_node(&sig), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_partial_sig_error", prop_key, v) + } + } + } + _ => { + // Other BitGo subtypes - show with name + let subtype_name = match bitgo_kv.subtype { + ProprietaryKeySubtype::ZecConsensusBranchId => "zec_consensus_branch_id", + ProprietaryKeySubtype::PayGoAddressAttestationProof => { + "paygo_address_attestation_proof" + } + ProprietaryKeySubtype::Bip322Message => "bip322_message", + _ => "unknown", + }; + raw_proprietary_to_node(subtype_name, prop_key, v) + } + } + } + Err(_) => { + // Not a valid BitGo key-value, show raw + raw_proprietary_to_node("unknown", prop_key, v) + } + } +} + +fn raw_proprietary_to_node( + label: &str, + prop_key: &crate::bitcoin::psbt::raw::ProprietaryKey, + v: &[u8], +) -> Node { + let mut prop_node = Node::new(label, Primitive::None); + prop_node.add_child(Node::new( + "prefix", + Primitive::String(String::from_utf8_lossy(&prop_key.prefix).to_string()), + )); + prop_node.add_child(Node::new("subtype", Primitive::U8(prop_key.subtype))); + prop_node.add_child(Node::new( + "key_data", + Primitive::Buffer(prop_key.key.to_vec()), + )); + prop_node.add_child(Node::new("value", Primitive::Buffer(v.to_vec()))); + prop_node +} + +fn proprietary_to_nodes( + proprietary: &std::collections::BTreeMap>, +) -> Vec { + proprietary + .iter() + .map(|(prop_key, v)| { + // Check if this is a BITGO proprietary key + if prop_key.prefix.as_slice() == BITGO { + bitgo_proprietary_to_node(prop_key, v) + } else { + raw_proprietary_to_node("key", prop_key, v) + } + }) + .collect() +} + +fn xpubs_to_nodes( + xpubs: &std::collections::BTreeMap< + crate::bitcoin::bip32::Xpub, + ( + crate::bitcoin::bip32::Fingerprint, + crate::bitcoin::bip32::DerivationPath, + ), + >, +) -> Vec { + xpubs + .iter() + .map(|(xpub, (fingerprint, path))| { + let mut xpub_node = Node::new("xpub", Primitive::None); + xpub_node.add_child(Node::new("xpub", Primitive::String(xpub.to_string()))); + xpub_node.add_child(Node::new( + "fingerprint", + Primitive::Buffer(fingerprint.to_bytes().to_vec()), + )); + xpub_node.add_child(Node::new("path", Primitive::String(path.to_string()))); + xpub_node + }) + .collect() +} + +pub fn xpubs_to_node( + xpubs: &std::collections::BTreeMap< + crate::bitcoin::bip32::Xpub, + ( + crate::bitcoin::bip32::Fingerprint, + crate::bitcoin::bip32::DerivationPath, + ), + >, +) -> Node { + let mut xpubs_node = Node::new("xpubs", Primitive::U64(xpubs.len() as u64)); + for node in xpubs_to_nodes(xpubs) { + xpubs_node.add_child(node); + } + xpubs_node +} + +// ============================================================================ +// Transaction Input/Output Helpers (shared between Bitcoin and Zcash) +// ============================================================================ + +fn tx_input_to_node(input: &crate::bitcoin::TxIn, index: usize) -> Node { + let mut input_node = Node::new(format!("input_{}", index), Primitive::None); + + input_node.add_child(Node::new( + "prev_txid", + Primitive::Buffer(input.previous_output.txid.to_byte_array().to_vec()), + )); + input_node.add_child(Node::new( + "prev_vout", + Primitive::U32(input.previous_output.vout), + )); + input_node.add_child(Node::new( + "sequence", + Primitive::U32(input.sequence.to_consensus_u32()), + )); + + input_node.add_child(Node::new( + "script_sig", + Primitive::Buffer(input.script_sig.as_bytes().to_vec()), + )); + + if !input.witness.is_empty() { + let mut witness_node = Node::new("witness", Primitive::U64(input.witness.len() as u64)); + + for (j, item) in input.witness.iter().enumerate() { + witness_node.add_child(Node::new( + format!("item_{}", j), + Primitive::Buffer(item.to_vec()), + )); + } + + input_node.add_child(witness_node); + } + + input_node +} + +fn tx_inputs_to_node(inputs: &[crate::bitcoin::TxIn]) -> Node { + let mut inputs_node = Node::new("inputs", Primitive::U64(inputs.len() as u64)); + for (i, input) in inputs.iter().enumerate() { + inputs_node.add_child(tx_input_to_node(input, i)); + } + inputs_node +} + +fn tx_output_to_node(output: &crate::bitcoin::TxOut, index: usize, network: Network) -> Node { + let mut output_node = Node::new(format!("output_{}", index), Primitive::None); + + output_node.add_child(Node::new("value", Primitive::U64(output.value.to_sat()))); + + output_node.add_child(Node::new( + "script_pubkey", + Primitive::Buffer(output.script_pubkey.as_bytes().to_vec()), + )); + + if let Ok(address) = from_output_script_with_network(&output.script_pubkey, network) { + output_node.add_child(Node::new("address", Primitive::String(address))); + } + + output_node +} + +fn tx_outputs_to_node(outputs: &[crate::bitcoin::TxOut], network: Network) -> Node { + let mut outputs_node = Node::new("outputs", Primitive::U64(outputs.len() as u64)); + for (i, output) in outputs.iter().enumerate() { + outputs_node.add_child(tx_output_to_node(output, i, network)); + } + outputs_node +} + +// ============================================================================ +// PSBT Input/Output Helpers (shared between Bitcoin and Zcash PSBTs) +// ============================================================================ + +fn psbt_input_to_node(input: &crate::bitcoin::psbt::Input, index: usize, network: Network) -> Node { + let mut input_node = Node::new(format!("input_{}", index), Primitive::None); + + if let Some(utxo) = &input.non_witness_utxo { + input_node.add_child(Node::new( + "non_witness_utxo", + Primitive::Buffer(utxo.compute_txid().to_byte_array().to_vec()), + )); + } + + if let Some(witness_utxo) = &input.witness_utxo { + let mut witness_node = Node::new("witness_utxo", Primitive::None); + witness_node.add_child(Node::new( + "value", + Primitive::U64(witness_utxo.value.to_sat()), + )); + witness_node.add_child(Node::new( + "script_pubkey", + Primitive::Buffer(witness_utxo.script_pubkey.as_bytes().to_vec()), + )); + witness_node.add_child(Node::new( + "address", + Primitive::String( + from_output_script_with_network(&witness_utxo.script_pubkey, network) + .unwrap_or_else(|_| "".to_string()), + ), + )); + input_node.add_child(witness_node); + } + + if let Some(redeem_script) = &input.redeem_script { + input_node.add_child(script_buf_to_node("redeem_script", redeem_script)); + } + + if let Some(witness_script) = &input.witness_script { + input_node.add_child(script_buf_to_node("witness_script", witness_script)) + } + + if let Some(final_script_sig) = &input.final_script_sig { + input_node.add_child(script_buf_to_node("final_script_sig", final_script_sig)); + } + + if let Some(final_script_witness) = &input.final_script_witness { + let mut witness_node = Node::new( + "final_script_witness", + Primitive::U64(final_script_witness.len() as u64), + ); + for (i, item) in final_script_witness.iter().enumerate() { + witness_node.add_child(Node::new( + format!("item_{}", i), + Primitive::Buffer(item.to_vec()), + )); + } + input_node.add_child(witness_node); + } + + let mut sigs_node = Node::new( + "signatures", + Primitive::U64(input.partial_sigs.len() as u64), + ); + for (i, (pubkey, sig)) in input.partial_sigs.iter().enumerate() { + let mut sig_node = Node::new(format!("{}", i), Primitive::None); + sig_node.add_child(Node::new("pubkey", Primitive::Buffer(pubkey.to_bytes()))); + sig_node.add_child(Node::new("signature", Primitive::Buffer(sig.to_vec()))); + sigs_node.add_child(sig_node); + } + + if !input.partial_sigs.is_empty() { + input_node.add_child(sigs_node); + } + + if let Some(sighash) = &input.sighash_type { + input_node.add_child(Node::new("sighash_type", Primitive::U32(sighash.to_u32()))); + input_node.add_child(Node::new( + "sighash_type", + Primitive::String(sighash.to_string()), + )); + } + + input_node.extend(bip32_derivations_to_nodes(&input.bip32_derivation)); + + if !input.proprietary.is_empty() { + let mut prop_node = Node::new( + "proprietary", + Primitive::U64(input.proprietary.len() as u64), + ); + prop_node.extend(proprietary_to_nodes(&input.proprietary)); + input_node.add_child(prop_node); + } + + input_node +} + +fn psbt_inputs_to_node(inputs: &[crate::bitcoin::psbt::Input], network: Network) -> Node { + let mut inputs_node = Node::new("inputs", Primitive::U64(inputs.len() as u64)); + for (i, input) in inputs.iter().enumerate() { + inputs_node.add_child(psbt_input_to_node(input, i, network)); + } + inputs_node +} + +fn psbt_output_to_node(output: &crate::bitcoin::psbt::Output, index: usize) -> Node { + let mut output_node = Node::new(format!("{}", index), Primitive::None); + + if let Some(script) = &output.redeem_script { + output_node.add_child(script_buf_to_node("redeem_script", script)); + } + + if let Some(script) = &output.witness_script { + output_node.add_child(script_buf_to_node("witness_script", script)); + } + + if !output.proprietary.is_empty() { + let mut prop_node = Node::new( + "proprietary", + Primitive::U64(output.proprietary.len() as u64), + ); + prop_node.extend(proprietary_to_nodes(&output.proprietary)); + output_node.add_child(prop_node); + } + + output_node.extend(bip32_derivations_to_nodes(&output.bip32_derivation)); + + output_node +} + +fn psbt_outputs_to_node(outputs: &[crate::bitcoin::psbt::Output]) -> Node { + let mut outputs_node = Node::new("outputs", Primitive::U64(outputs.len() as u64)); + for (i, output) in outputs.iter().enumerate() { + outputs_node.add_child(psbt_output_to_node(output, i)); + } + outputs_node +} + +pub fn psbt_to_node(psbt: &Psbt, network: Network) -> Node { + let mut psbt_node = Node::new("psbt", Primitive::None); + + psbt_node.add_child(tx_to_node(&psbt.unsigned_tx, network)); + psbt_node.add_child(xpubs_to_node(&psbt.xpub)); + + if !psbt.proprietary.is_empty() { + let mut proprietary_node = + Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); + proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); + psbt_node.add_child(proprietary_node); + } + + psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); + psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); + psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); + + psbt_node +} + +pub fn tx_to_node(tx: &Transaction, network: Network) -> Node { + let mut tx_node = Node::new("tx", Primitive::None); + + tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0))); + tx_node.add_child(Node::new( + "lock_time", + Primitive::U32(tx.lock_time.to_consensus_u32()), + )); + tx_node.add_child(Node::new( + "txid", + Primitive::Buffer(tx.compute_txid().to_byte_array().to_vec()), + )); + tx_node.add_child(Node::new( + "ntxid", + Primitive::Buffer(tx.compute_ntxid().to_byte_array().to_vec()), + )); + tx_node.add_child(Node::new( + "wtxid", + Primitive::Buffer(tx.compute_wtxid().to_byte_array().to_vec()), + )); + tx_node.add_child(tx_inputs_to_node(&tx.input)); + tx_node.add_child(tx_outputs_to_node(&tx.output, network)); + + tx_node +} + +/// Convert a Zcash transaction (ZcashTransactionParts) to a Node tree +pub fn zcash_tx_to_node(parts: &ZcashTransactionParts, network: Network) -> Node { + let tx = &parts.transaction; + let mut tx_node = Node::new("tx", Primitive::None); + + // Zcash-specific fields first + if parts.is_overwintered { + tx_node.add_child(Node::new("is_overwintered", Primitive::Boolean(true))); + } + if let Some(vgid) = parts.version_group_id { + tx_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); + } + if let Some(expiry) = parts.expiry_height { + tx_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); + } + if !parts.sapling_fields.is_empty() { + tx_node.add_child(Node::new( + "sapling_fields", + Primitive::Buffer(parts.sapling_fields.clone()), + )); + } + + // Standard transaction fields (reuse helpers) + tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0))); + tx_node.add_child(Node::new( + "lock_time", + Primitive::U32(tx.lock_time.to_consensus_u32()), + )); + tx_node.add_child(Node::new( + "txid", + Primitive::Buffer(tx.compute_txid().to_byte_array().to_vec()), + )); + tx_node.add_child(Node::new( + "ntxid", + Primitive::Buffer(tx.compute_ntxid().to_byte_array().to_vec()), + )); + tx_node.add_child(Node::new( + "wtxid", + Primitive::Buffer(tx.compute_wtxid().to_byte_array().to_vec()), + )); + tx_node.add_child(tx_inputs_to_node(&tx.input)); + tx_node.add_child(tx_outputs_to_node(&tx.output, network)); + + tx_node +} + +/// Convert a ZcashBitGoPsbt to a Node tree +pub fn zcash_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node { + let psbt = &zcash_psbt.psbt; + let mut psbt_node = Node::new("psbt", Primitive::None); + + // Zcash-specific fields at PSBT level + if let Some(vgid) = zcash_psbt.version_group_id { + psbt_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); + } + if let Some(expiry) = zcash_psbt.expiry_height { + psbt_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); + } + if !zcash_psbt.sapling_fields.is_empty() { + psbt_node.add_child(Node::new( + "sapling_fields_len", + Primitive::U64(zcash_psbt.sapling_fields.len() as u64), + )); + } + + // Create ZcashTransactionParts from the PSBT's unsigned_tx + let parts = ZcashTransactionParts { + transaction: psbt.unsigned_tx.clone(), + is_overwintered: zcash_psbt.version_group_id.is_some(), + version_group_id: zcash_psbt.version_group_id, + expiry_height: zcash_psbt.expiry_height, + sapling_fields: zcash_psbt.sapling_fields.clone(), + }; + psbt_node.add_child(zcash_tx_to_node(&parts, network)); + + psbt_node.add_child(xpubs_to_node(&psbt.xpub)); + + if !psbt.proprietary.is_empty() { + let mut proprietary_node = + Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); + proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); + psbt_node.add_child(proprietary_node); + } + + psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); + psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); + psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); + + psbt_node +} + +pub fn parse_psbt_bytes_with_network( + bytes: &[u8], + network: crate::networks::Network, +) -> Result { + use crate::networks::Network as NetEnum; + + // Use Zcash-specific parser for Zcash networks + if matches!(network, NetEnum::Zcash | NetEnum::ZcashTestnet) { + let zcash_psbt = ZcashBitGoPsbt::deserialize(bytes, network) + .map_err(|e| format!("Zcash PSBT parse error: {}", e))?; + return Ok(zcash_psbt_to_node(&zcash_psbt, network)); + } + + // Standard Bitcoin-compatible PSBT parsing + Psbt::deserialize(bytes) + .map(|psbt| psbt_to_node(&psbt, network)) + .map_err(|e| e.to_string()) +} + +pub fn parse_tx_bytes_with_network( + bytes: &[u8], + network: crate::networks::Network, +) -> Result { + use crate::networks::Network as NetEnum; + + // Use Zcash-specific parser for Zcash networks + if matches!(network, NetEnum::Zcash | NetEnum::ZcashTestnet) { + let parts = decode_zcash_transaction_parts(bytes) + .map_err(|e| format!("Zcash transaction parse error: {}", e))?; + return Ok(zcash_tx_to_node(&parts, network)); + } + + // Standard Bitcoin-compatible transaction parsing + Transaction::consensus_decode(&mut &bytes[..]) + .map(|tx| tx_to_node(&tx, network)) + .map_err(|e| e.to_string()) +} diff --git a/packages/wasm-utxo/cli/src/parse/node_raw.rs b/packages/wasm-utxo/src/inspect/psbt_raw.rs similarity index 91% rename from packages/wasm-utxo/cli/src/parse/node_raw.rs rename to packages/wasm-utxo/src/inspect/psbt_raw.rs index 2c36dc9e28d..867901b8c7e 100644 --- a/packages/wasm-utxo/cli/src/parse/node_raw.rs +++ b/packages/wasm-utxo/src/inspect/psbt_raw.rs @@ -14,10 +14,11 @@ /// # Example /// /// ```ignore -/// use parse::node_raw::parse_psbt_bytes_raw; +/// use wasm_utxo::Network; +/// use wasm_utxo::parse_node::parse_psbt_bytes_raw_with_network; /// /// let psbt_bytes = /* your PSBT data */; -/// let node = parse_psbt_bytes_raw(&psbt_bytes)?; +/// let node = parse_psbt_bytes_raw_with_network(&psbt_bytes, Network::Bitcoin)?; /// // Returns a tree structure showing raw PSBT key-value pairs /// ``` /// @@ -25,11 +26,12 @@ /// /// - [BIP-174: PSBT Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) /// - [bitcoin::psbt::raw](https://docs.rs/bitcoin/latest/bitcoin/psbt/raw/index.html) -use wasm_utxo::bitcoin::consensus::Decodable; -use wasm_utxo::bitcoin::psbt::raw::{Key, Pair}; -use wasm_utxo::bitcoin::{Network, Transaction, VarInt}; +use crate::bitcoin::consensus::Decodable; +use crate::bitcoin::psbt::raw::{Key, Pair}; +use crate::bitcoin::{Transaction, VarInt}; +use crate::zcash::transaction::decode_zcash_transaction_parts; -pub use crate::node::{Node, Primitive}; +pub use super::node::{Node, Primitive}; /// Context for interpreting PSBT key types #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -281,11 +283,22 @@ fn decode_pair(bytes: &[u8], pos: usize) -> Result<(Pair, usize), String> { } /// Extract transaction input/output counts from global map -fn extract_tx_counts(global_pairs: &[Pair]) -> Result<(usize, usize), String> { +/// Supports both Bitcoin and Zcash transaction formats +fn extract_tx_counts(global_pairs: &[Pair], is_zcash: bool) -> Result<(usize, usize), String> { // Find the unsigned transaction (type 0x00) for pair in global_pairs { if pair.key.type_value == 0x00 { - // Parse the transaction + // Try Zcash parser first if requested + if is_zcash { + if let Ok(parts) = decode_zcash_transaction_parts(&pair.value) { + return Ok(( + parts.transaction.input.len(), + parts.transaction.output.len(), + )); + } + } + + // Fall back to standard Bitcoin transaction parser let tx = Transaction::consensus_decode(&mut &pair.value[..]) .map_err(|e| format!("Failed to decode unsigned transaction: {}", e))?; return Ok((tx.input.len(), tx.output.len())); @@ -346,7 +359,8 @@ fn decode_map( } /// Parse PSBT showing raw key-value structure from bytes -pub fn psbt_to_raw_node(bytes: &[u8], _network: Network) -> Result { +/// Supports both Bitcoin and Zcash PSBT formats +fn psbt_to_raw_node_internal(bytes: &[u8], is_zcash: bool) -> Result { let mut psbt_node = Node::new("psbt_raw", Primitive::None); // 1. Check magic bytes: "psbt" + 0xff @@ -373,7 +387,7 @@ pub fn psbt_to_raw_node(bytes: &[u8], _network: Network) -> Result pos = new_pos; // 3. Extract transaction input/output counts from unsigned tx - let (expected_input_count, expected_output_count) = extract_tx_counts(&global_pairs)?; + let (expected_input_count, expected_output_count) = extract_tx_counts(&global_pairs, is_zcash)?; // 4. Decode input maps let mut input_maps_node = Node::new("input_maps", Primitive::None); @@ -421,8 +435,14 @@ pub fn psbt_to_raw_node(bytes: &[u8], _network: Network) -> Result Ok(psbt_node) } -pub fn parse_psbt_bytes_raw(bytes: &[u8]) -> Result { - psbt_to_raw_node(bytes, Network::Bitcoin) +/// Parse raw PSBT bytes with network support +pub fn parse_psbt_bytes_raw_with_network( + bytes: &[u8], + network: crate::networks::Network, +) -> Result { + use crate::networks::Network as NetEnum; + let is_zcash = matches!(network, NetEnum::Zcash | NetEnum::ZcashTestnet); + psbt_to_raw_node_internal(bytes, is_zcash) } #[cfg(test)] @@ -479,19 +499,4 @@ mod tests { assert_eq!(magic.len(), 5); assert_eq!(magic[4], 0xff); } - - #[test] - fn test_parse_psbt_bitcoin_fullsigned() -> Result<(), Box> { - use crate::format::fixtures::assert_tree_matches_fixture; - use crate::test_utils::{load_psbt_bytes, SignatureState, TxFormat}; - use wasm_utxo::Network; - - let psbt_bytes = - load_psbt_bytes(Network::Bitcoin, SignatureState::Fullsigned, TxFormat::Psbt)?; - - let node = parse_psbt_bytes_raw(&psbt_bytes)?; - - assert_tree_matches_fixture(&node, "psbt_raw_bitcoin_fullsigned")?; - Ok(()) - } } diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index f3553b1e8a5..046d010bbd8 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -4,6 +4,8 @@ pub mod dash; mod error; pub mod fixed_script_wallet; pub mod inscriptions; +#[cfg(feature = "inspect")] +pub mod inspect; pub mod message; mod networks; pub mod paygo; diff --git a/packages/wasm-utxo/src/networks.rs b/packages/wasm-utxo/src/networks.rs index f6cd35f13a9..11ff3afc74c 100644 --- a/packages/wasm-utxo/src/networks.rs +++ b/packages/wasm-utxo/src/networks.rs @@ -296,6 +296,26 @@ impl Network { pub fn is_testnet(self) -> bool { !self.is_mainnet() } + + /// Convert to bitcoin crate Network type for address encoding + pub fn to_bitcoin_network(self) -> crate::bitcoin::Network { + use crate::bitcoin::Network as BitcoinNetwork; + match self { + Network::Bitcoin => BitcoinNetwork::Bitcoin, + Network::BitcoinTestnet3 => BitcoinNetwork::Testnet, + Network::BitcoinTestnet4 => BitcoinNetwork::Testnet, + Network::BitcoinPublicSignet => BitcoinNetwork::Signet, + Network::BitcoinBitGoSignet => BitcoinNetwork::Signet, + // Non-Bitcoin networks - use Bitcoin mainnet/testnet based on whether they're mainnet + _ => { + if self.is_mainnet() { + BitcoinNetwork::Bitcoin + } else { + BitcoinNetwork::Testnet + } + } + } + } } impl fmt::Display for Network { diff --git a/packages/wasm-utxo/src/wasm/inspect.rs b/packages/wasm-utxo/src/wasm/inspect.rs new file mode 100644 index 00000000000..1201fb17991 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/inspect.rs @@ -0,0 +1,127 @@ +//! WASM bindings for inspect functionality +//! +//! These bindings are always available but will throw runtime errors +//! if the `inspect` feature is not enabled. + +use wasm_bindgen::prelude::*; + +#[cfg(not(feature = "inspect"))] +const FEATURE_NOT_ENABLED_ERROR: &str = + "inspect feature is not enabled. Rebuild with --features inspect"; + +#[cfg(feature = "inspect")] +fn parse_network(coin_name: &str) -> Result { + crate::networks::Network::from_coin_name(coin_name) + .ok_or_else(|| JsError::new(&format!("Unknown network: {}", coin_name))) +} + +/// Parse a PSBT and return a JSON representation of its structure. +/// +/// This function parses the PSBT using the standard bitcoin crate parser +/// and returns a hierarchical node structure suitable for display. +/// +/// # Arguments +/// * `psbt_bytes` - The raw PSBT bytes +/// * `coin_name` - The network coin name (e.g., "btc", "ltc", "bch") +/// +/// # Returns +/// A JSON string representing the parsed PSBT structure +/// +/// # Errors +/// Returns an error if: +/// - The `inspect` feature is not enabled +/// - The PSBT bytes are invalid +/// - The network name is unknown +#[wasm_bindgen(js_name = parsePsbtToJson)] +pub fn parse_psbt_to_json(psbt_bytes: &[u8], coin_name: &str) -> Result { + #[cfg(feature = "inspect")] + { + let network = parse_network(coin_name)?; + let node = crate::inspect::parse_psbt_bytes_with_network(psbt_bytes, network) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&node).map_err(|e| JsError::new(&e.to_string())) + } + + #[cfg(not(feature = "inspect"))] + { + let _ = (psbt_bytes, coin_name); + Err(JsError::new(FEATURE_NOT_ENABLED_ERROR)) + } +} + +/// Parse a transaction and return a JSON representation of its structure. +/// +/// # Arguments +/// * `tx_bytes` - The raw transaction bytes +/// * `coin_name` - The network coin name (e.g., "btc", "ltc", "bch") +/// +/// # Returns +/// A JSON string representing the parsed transaction structure +/// +/// # Errors +/// Returns an error if: +/// - The `inspect` feature is not enabled +/// - The transaction bytes are invalid +/// - The network name is unknown +#[wasm_bindgen(js_name = parseTxToJson)] +pub fn parse_tx_to_json(tx_bytes: &[u8], coin_name: &str) -> Result { + #[cfg(feature = "inspect")] + { + let network = parse_network(coin_name)?; + let node = crate::inspect::parse_tx_bytes_with_network(tx_bytes, network) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&node).map_err(|e| JsError::new(&e.to_string())) + } + + #[cfg(not(feature = "inspect"))] + { + let _ = (tx_bytes, coin_name); + Err(JsError::new(FEATURE_NOT_ENABLED_ERROR)) + } +} + +/// Parse a PSBT at the raw byte level and return a JSON representation. +/// +/// Unlike `parsePsbtToJson`, this function exposes the raw key-value pair +/// structure as defined in BIP-174, showing: +/// - Raw key type IDs and their human-readable names +/// - Proprietary keys with their structured format +/// - Unknown/unrecognized keys that standard parsers might skip +/// +/// # Arguments +/// * `psbt_bytes` - The raw PSBT bytes +/// * `coin_name` - The network coin name (e.g., "btc", "ltc", "zec") +/// +/// # Returns +/// A JSON string representing the raw PSBT key-value structure +/// +/// # Errors +/// Returns an error if: +/// - The `inspect` feature is not enabled +/// - The PSBT bytes are invalid +/// - The network name is unknown +#[wasm_bindgen(js_name = parsePsbtRawToJson)] +pub fn parse_psbt_raw_to_json(psbt_bytes: &[u8], coin_name: &str) -> Result { + #[cfg(feature = "inspect")] + { + let network = parse_network(coin_name)?; + let node = crate::inspect::parse_psbt_bytes_raw_with_network(psbt_bytes, network) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&node).map_err(|e| JsError::new(&e.to_string())) + } + + #[cfg(not(feature = "inspect"))] + { + let _ = (psbt_bytes, coin_name); + Err(JsError::new(FEATURE_NOT_ENABLED_ERROR)) + } +} + +/// Check if the inspect feature is enabled. +/// +/// # Returns +/// `true` if the feature is enabled, `false` otherwise +#[wasm_bindgen(js_name = isInspectEnabled)] +pub fn is_inspect_enabled() -> bool { + cfg!(feature = "inspect") +} diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 1d6edac6e4a..5aeadfd39ae 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -6,6 +6,7 @@ mod descriptor; mod ecpair; mod fixed_script_wallet; mod inscriptions; +mod inspect; mod message; mod miniscript; mod psbt;