diff --git a/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts b/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts index cd54e99743a..3c81a1d5ac9 100644 --- a/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts +++ b/packages/wasm-utxo/js/descriptorWallet/psbt/findDescriptors.ts @@ -85,14 +85,14 @@ function findDescriptorForDerivationIndex( return undefined; } -function getDerivationIndexFromPath(path: string): number { +function getDerivationIndexFromPath(path: string): number | undefined { const indexStr = path.split("/").pop(); if (!indexStr) { - throw new Error(`Invalid derivation path ${path}`); + return undefined; } - const index = parseInt(indexStr, 10); - if (index.toString() !== indexStr) { - throw new Error(`Invalid derivation path ${path}`); + const index = Number(indexStr); + if (!Number.isSafeInteger(index) || index < 0 || index.toString() !== indexStr) { + return undefined; } return index; } @@ -108,7 +108,13 @@ function findDescriptorForAnyDerivationPath( derivationPaths: string[], descriptorMap: DescriptorMap, ): DescriptorWithIndex | undefined { - const derivationIndexSet = new Set(derivationPaths.map((p) => getDerivationIndexFromPath(p))); + const derivationIndexSet = new Set(); + for (const path of derivationPaths) { + const index = getDerivationIndexFromPath(path); + if (index !== undefined) { + derivationIndexSet.add(index); + } + } for (const index of [...derivationIndexSet]) { const desc = findDescriptorForDerivationIndex(script, index, descriptorMap); if (desc) { @@ -122,14 +128,12 @@ function findDescriptorForAnyDerivationPath( type WithBip32Derivation = { bip32Derivation?: { path: string }[] }; type WithTapBip32Derivation = { tapBip32Derivation?: { path: string }[] }; -function getDerivationPaths(v: WithBip32Derivation | WithTapBip32Derivation): string[] | undefined { - if ("bip32Derivation" in v && v.bip32Derivation && v.bip32Derivation.length > 0) { - return v.bip32Derivation.map((v) => v.path); - } - if ("tapBip32Derivation" in v && v.tapBip32Derivation && v.tapBip32Derivation.length > 0) { - return v.tapBip32Derivation.map((v) => v.path).filter((v) => v !== "" && v !== "m"); - } - return undefined; +function getDerivationPaths(v: WithBip32Derivation & WithTapBip32Derivation): string[] | undefined { + const paths = [ + ...(v.bip32Derivation ?? []).map((derivation) => derivation.path), + ...(v.tapBip32Derivation ?? []).map((derivation) => derivation.path), + ].filter((path) => path !== "" && path !== "m"); + return paths.length > 0 ? paths : undefined; } /** diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 092253b82a6..893cbaf6bc5 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -108,6 +108,24 @@ declare module "./wasm/wasm_utxo.js" { tapBip32Derivation: PsbtBip32Derivation[]; } + /** A serialized input-map record with a standard PSBT key type. */ + interface PsbtKnownInputKeyValue { + type: "known"; + key: string; + keyData: Uint8Array; + value: Uint8Array; + } + + /** A serialized input-map record whose key type is unrecognized. */ + interface PsbtUnknownInputKeyValue { + type: "unknown"; + keyType: bigint; + keyData: Uint8Array; + value: Uint8Array; + } + + type PsbtInputKeyValue = PsbtKnownInputKeyValue | PsbtUnknownInputKeyValue; + /** Raw PSBT output data returned by getOutputs() */ interface PsbtOutputData { script: Uint8Array; @@ -172,3 +190,4 @@ export { type ITransactionCommon, } from "./transaction.js"; export { hasPsbtMagic, type IPsbt, type IPsbtWithAddress } from "./psbt.js"; +export type { PsbtInputKeyValue } from "./wasm/wasm_utxo.js"; diff --git a/packages/wasm-utxo/js/psbt.ts b/packages/wasm-utxo/js/psbt.ts index dc6fd35727d..6d4c810598d 100644 --- a/packages/wasm-utxo/js/psbt.ts +++ b/packages/wasm-utxo/js/psbt.ts @@ -1,4 +1,9 @@ -import type { PsbtInputData, PsbtOutputData, PsbtOutputDataWithAddress } from "./wasm/wasm_utxo.js"; +import type { + PsbtInputData, + PsbtInputKeyValue, + PsbtOutputData, + PsbtOutputDataWithAddress, +} from "./wasm/wasm_utxo.js"; import type { BIP32 } from "./bip32.js"; import type { ITransactionCommon } from "./transaction.js"; import type { PsbtKvKey } from "./fixedScriptWallet/BitGoKeySubtype.js"; @@ -6,6 +11,7 @@ import type { PsbtKvKey } from "./fixedScriptWallet/BitGoKeySubtype.js"; /** Common interface for PSBT types */ export interface IPsbt extends ITransactionCommon { getGlobalXpubs(): BIP32[]; + getInputKeyValues(index: number): PsbtInputKeyValue[]; unsignedTxId(): string; addInputAtIndex( index: number, diff --git a/packages/wasm-utxo/js/psbtBase.ts b/packages/wasm-utxo/js/psbtBase.ts index d4e58bf1c62..0bd67b54325 100644 --- a/packages/wasm-utxo/js/psbtBase.ts +++ b/packages/wasm-utxo/js/psbtBase.ts @@ -1,4 +1,9 @@ -import type { PsbtInputData, PsbtOutputData, WasmBIP32 } from "./wasm/wasm_utxo.js"; +import type { + PsbtInputData, + PsbtInputKeyValue, + PsbtOutputData, + WasmBIP32, +} from "./wasm/wasm_utxo.js"; import { BIP32 } from "./bip32.js"; import type { PsbtKvKey } from "./fixedScriptWallet/BitGoKeySubtype.js"; @@ -10,6 +15,7 @@ interface WasmPsbtBase { unsigned_tx_id(): string; serialize(): Uint8Array; get_inputs(): unknown; + get_input_key_values(index: number): unknown; get_outputs(): unknown; get_global_xpubs(): unknown; remove_input(index: number): void; @@ -53,6 +59,9 @@ export abstract class PsbtBase { getInputs(): PsbtInputData[] { return this._wasm.get_inputs() as PsbtInputData[]; } + getInputKeyValues(index: number): PsbtInputKeyValue[] { + return this._wasm.get_input_key_values(index) as PsbtInputKeyValue[]; + } getOutputs(): PsbtOutputData[] { return this._wasm.get_outputs() as PsbtOutputData[]; } diff --git a/packages/wasm-utxo/src/inspect/psbt_raw.rs b/packages/wasm-utxo/src/inspect/psbt_raw.rs index 867901b8c7e..101a0a080c4 100644 --- a/packages/wasm-utxo/src/inspect/psbt_raw.rs +++ b/packages/wasm-utxo/src/inspect/psbt_raw.rs @@ -27,8 +27,11 @@ /// - [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 crate::bitcoin::consensus::Decodable; -use crate::bitcoin::psbt::raw::{Key, Pair}; -use crate::bitcoin::{Transaction, VarInt}; +use crate::bitcoin::Transaction; +use crate::psbt_ops::{ + decode_compact_size, decode_compact_size_u64, decode_psbt_key_value_map, + known_psbt_input_key_type, PsbtKeyValue, +}; use crate::zcash::transaction::decode_zcash_transaction_parts; pub use super::node::{Node, Primitive}; @@ -46,8 +49,14 @@ fn is_printable_ascii(bytes: &[u8]) -> bool { bytes.iter().all(|&b| (0x20..=0x7E).contains(&b)) } +fn key_type_primitive(value: u64) -> Primitive { + u8::try_from(value) + .map(Primitive::U8) + .unwrap_or(Primitive::U64(value)) +} + /// Parse proprietary key structure (0xFC type keys) -fn parse_proprietary_key(key_data: &[u8]) -> Result<(Vec, u8, Vec), String> { +fn parse_proprietary_key(key_data: &[u8]) -> Result<(Vec, u64, Vec), String> { if key_data.is_empty() { return Err("Empty proprietary key data".to_string()); } @@ -55,10 +64,9 @@ fn parse_proprietary_key(key_data: &[u8]) -> Result<(Vec, u8, Vec), Stri let mut pos = 0; // Decode prefix length (varint) - let (prefix_len, varint_size) = decode_varint(key_data, pos)?; + let (prefix_len, varint_size) = decode_compact_size(&key_data[pos..])?; pos += varint_size; - let prefix_len = prefix_len as usize; if pos + prefix_len > key_data.len() { return Err("Not enough bytes for proprietary prefix".to_string()); } @@ -67,12 +75,9 @@ fn parse_proprietary_key(key_data: &[u8]) -> Result<(Vec, u8, Vec), Stri let prefix = key_data[pos..pos + prefix_len].to_vec(); pos += prefix_len; - // Extract subtype (1 byte) - if pos >= key_data.len() { - return Err("Not enough bytes for proprietary subtype".to_string()); - } - let subtype = key_data[pos]; - pos += 1; + // Extract CompactSize subtype. + let (subtype, subtype_size) = decode_compact_size_u64(&key_data[pos..])?; + pos += subtype_size; // Remaining bytes are additional key data let remaining_key = key_data[pos..].to_vec(); @@ -80,25 +85,21 @@ fn parse_proprietary_key(key_data: &[u8]) -> Result<(Vec, u8, Vec), Stri Ok((prefix, subtype, remaining_key)) } -/// Parse a raw PSBT key into a node -fn key_to_node(key: &Key, context: PsbtMapContext) -> Node { +/// Parse a raw PSBT key into a node. +fn key_to_node(key_value: &PsbtKeyValue, context: PsbtMapContext) -> Node { let mut key_node = Node::new("key", Primitive::None); - // First byte is the key type - if !key.key.is_empty() { - key_node.add_child(Node::new("type_id", Primitive::U8(key.type_value))); - key_node.add_child(Node::new( - "type_name", - Primitive::String(key_type_name(key.type_value, context)), - )); - } + key_node.add_child(Node::new("type_id", key_type_primitive(key_value.key_type))); + key_node.add_child(Node::new( + "type_name", + Primitive::String(key_type_name(key_value.key_type, context)), + )); - // Rest is the key data - if key.key.len() > 1 { - let key_data = &key.key[1..]; + if !key_value.key_data.is_empty() { + let key_data = &key_value.key_data; // Special handling for proprietary keys (0xFC) - if key.type_value == 0xFC { + if key_value.key_type == 0xFC { match parse_proprietary_key(key_data) { Ok((prefix, subtype, remaining_key)) => { // Add prefix - show as ASCII string if printable @@ -112,7 +113,7 @@ fn key_to_node(key: &Key, context: PsbtMapContext) -> Node { } // Add subtype - key_node.add_child(Node::new("subtype", Primitive::U8(subtype))); + key_node.add_child(Node::new("subtype", key_type_primitive(subtype))); // Add remaining key data if any if !remaining_key.is_empty() { @@ -133,16 +134,16 @@ fn key_to_node(key: &Key, context: PsbtMapContext) -> Node { key_node } -/// Parse a raw PSBT key-value pair into a node -fn pair_to_node(pair: &Pair, index: usize, context: PsbtMapContext) -> Node { +/// Parse a raw PSBT key-value pair into a node. +fn pair_to_node(pair: &PsbtKeyValue, index: usize, context: PsbtMapContext) -> Node { let mut pair_node = Node::new(format!("pair_{}", index), Primitive::None); - pair_node.add_child(key_to_node(&pair.key, context)); + pair_node.add_child(key_to_node(pair, context)); pair_node.add_child(Node::new("value", Primitive::Buffer(pair.value.clone()))); pair_node } /// Get human-readable name for PSBT key type based on context -fn key_type_name(type_id: u8, context: PsbtMapContext) -> String { +fn key_type_name(type_id: u64, context: PsbtMapContext) -> String { match context { PsbtMapContext::Global => match type_id { 0x00 => "PSBT_GLOBAL_UNSIGNED_TX".to_string(), @@ -152,42 +153,16 @@ fn key_type_name(type_id: u8, context: PsbtMapContext) -> String { 0x04 => "PSBT_GLOBAL_INPUT_COUNT".to_string(), 0x05 => "PSBT_GLOBAL_OUTPUT_COUNT".to_string(), 0x06 => "PSBT_GLOBAL_TX_MODIFIABLE".to_string(), - 0x07 => "PSBT_GLOBAL_VERSION".to_string(), + 0x07 => "PSBT_GLOBAL_SP_ECDH_SHARE".to_string(), + 0x08 => "PSBT_GLOBAL_SP_DLEQ".to_string(), + 0x09 => "PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE".to_string(), + 0xfb => "PSBT_GLOBAL_VERSION".to_string(), 0xFC => "PSBT_GLOBAL_PROPRIETARY".to_string(), - _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), - }, - PsbtMapContext::Input => match type_id { - 0x00 => "PSBT_IN_NON_WITNESS_UTXO".to_string(), - 0x01 => "PSBT_IN_WITNESS_UTXO".to_string(), - 0x02 => "PSBT_IN_PARTIAL_SIG".to_string(), - 0x03 => "PSBT_IN_SIGHASH_TYPE".to_string(), - 0x04 => "PSBT_IN_REDEEM_SCRIPT".to_string(), - 0x05 => "PSBT_IN_WITNESS_SCRIPT".to_string(), - 0x06 => "PSBT_IN_BIP32_DERIVATION".to_string(), - 0x07 => "PSBT_IN_FINAL_SCRIPTSIG".to_string(), - 0x08 => "PSBT_IN_FINAL_SCRIPTWITNESS".to_string(), - 0x09 => "PSBT_IN_POR_COMMITMENT".to_string(), - 0x0a => "PSBT_IN_RIPEMD160".to_string(), - 0x0b => "PSBT_IN_SHA256".to_string(), - 0x0c => "PSBT_IN_HASH160".to_string(), - 0x0d => "PSBT_IN_HASH256".to_string(), - 0x0e => "PSBT_IN_PREVIOUS_TXID".to_string(), - 0x0f => "PSBT_IN_OUTPUT_INDEX".to_string(), - 0x10 => "PSBT_IN_SEQUENCE".to_string(), - 0x11 => "PSBT_IN_REQUIRED_TIME_LOCKTIME".to_string(), - 0x12 => "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME".to_string(), - 0x13 => "PSBT_IN_TAP_KEY_SIG".to_string(), - 0x14 => "PSBT_IN_TAP_SCRIPT_SIG".to_string(), - 0x15 => "PSBT_IN_TAP_LEAF_SCRIPT".to_string(), - 0x16 => "PSBT_IN_TAP_BIP32_DERIVATION".to_string(), - 0x17 => "PSBT_IN_TAP_INTERNAL_KEY".to_string(), - 0x18 => "PSBT_IN_TAP_MERKLE_ROOT".to_string(), - 0x19 => "PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS".to_string(), - 0x1a => "PSBT_IN_MUSIG2_PUB_NONCE".to_string(), - 0x1b => "PSBT_IN_MUSIG2_PARTIAL_SIG".to_string(), - 0xFC => "PSBT_IN_PROPRIETARY".to_string(), - _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), + _ => format!("UNKNOWN_TYPE_0x{type_id:X}"), }, + PsbtMapContext::Input => known_psbt_input_key_type(type_id) + .map(str::to_string) + .unwrap_or_else(|| format!("UNKNOWN_TYPE_0x{type_id:X}")), PsbtMapContext::Output => match type_id { 0x00 => "PSBT_OUT_REDEEM_SCRIPT".to_string(), 0x01 => "PSBT_OUT_WITNESS_SCRIPT".to_string(), @@ -197,97 +172,25 @@ fn key_type_name(type_id: u8, context: PsbtMapContext) -> String { 0x05 => "PSBT_OUT_TAP_INTERNAL_KEY".to_string(), 0x06 => "PSBT_OUT_TAP_TREE".to_string(), 0x07 => "PSBT_OUT_TAP_BIP32_DERIVATION".to_string(), + 0x08 => "PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS".to_string(), + 0x09 => "PSBT_OUT_SP_V0_INFO".to_string(), + 0x0a => "PSBT_OUT_SP_V0_LABEL".to_string(), + 0x35 => "PSBT_OUT_DNSSEC_PROOF".to_string(), 0xFC => "PSBT_OUT_PROPRIETARY".to_string(), - _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), + _ => format!("UNKNOWN_TYPE_0x{type_id:X}"), }, } } -/// Decode a varint from bytes using bitcoin crate, returns (value, bytes_consumed) -fn decode_varint(bytes: &[u8], pos: usize) -> Result<(u64, usize), String> { - if pos >= bytes.len() { - return Err("Not enough bytes for varint".to_string()); - } - - let mut cursor = &bytes[pos..]; - let varint = VarInt::consensus_decode(&mut cursor) - .map_err(|e| format!("Failed to decode varint: {}", e))?; - - // Calculate bytes consumed by comparing slice positions - let bytes_consumed = bytes.len() - pos - cursor.len(); - - Ok((varint.0, bytes_consumed)) -} - -/// Manually decode a key-value pair from bytes -/// -/// Note: The bitcoin crate has `Pair::decode()` and `Key::decode()` methods, but they are -/// marked as `pub(crate)` and not exposed in the public API. We must implement our own -/// decoder to parse raw PSBT bytes at this low level. We do reuse the bitcoin crate's -/// `VarInt` decoder where possible. -fn decode_pair(bytes: &[u8], pos: usize) -> Result<(Pair, usize), String> { - let mut current_pos = pos; - - // Decode key length (varint) - let (key_len, varint_size) = decode_varint(bytes, current_pos)?; - current_pos += varint_size; - - if key_len == 0 { - return Err("Zero-length key (map separator)".to_string()); - } - - // Key is: type_value (1 byte) + key_data - if current_pos >= bytes.len() { - return Err("Not enough bytes for key type".to_string()); - } - - let type_value = bytes[current_pos]; - current_pos += 1; - - let key_data_len = (key_len - 1) as usize; - if current_pos + key_data_len > bytes.len() { - return Err(format!( - "Not enough bytes for key data: need {}, have {}", - key_data_len, - bytes.len() - current_pos - )); - } - - let mut key_bytes = vec![type_value]; - key_bytes.extend_from_slice(&bytes[current_pos..current_pos + key_data_len]); - current_pos += key_data_len; - - let key = Key { - type_value, - key: key_bytes, - }; - - // Decode value length (varint) - let (value_len, varint_size) = decode_varint(bytes, current_pos)?; - current_pos += varint_size; - - let value_len = value_len as usize; - if current_pos + value_len > bytes.len() { - return Err(format!( - "Not enough bytes for value: need {}, have {}", - value_len, - bytes.len() - current_pos - )); - } - - let value = bytes[current_pos..current_pos + value_len].to_vec(); - current_pos += value_len; - - let pair = Pair { key, value }; - Ok((pair, current_pos - pos)) -} - /// Extract transaction input/output counts from global map /// Supports both Bitcoin and Zcash transaction formats -fn extract_tx_counts(global_pairs: &[Pair], is_zcash: bool) -> Result<(usize, usize), String> { +fn extract_tx_counts( + global_pairs: &[PsbtKeyValue], + is_zcash: bool, +) -> Result<(usize, usize), String> { // Find the unsigned transaction (type 0x00) for pair in global_pairs { - if pair.key.type_value == 0x00 { + if pair.key_type == 0x00 { // Try Zcash parser first if requested if is_zcash { if let Ok(parts) = decode_zcash_transaction_parts(&pair.value) { @@ -313,38 +216,13 @@ fn decode_map( start_pos: usize, map_name: &str, context: PsbtMapContext, -) -> Result<(Node, Vec, usize), String> { +) -> Result<(Node, Vec, usize), String> { let mut map_node = Node::new(map_name, Primitive::None); - let mut pairs = Vec::new(); - let mut pos = start_pos; - - loop { - // Check if we hit the separator (0x00) - if pos >= bytes.len() { - break; - } - - if bytes[pos] == 0x00 { - pos += 1; // Skip the separator - break; - } - - // Try to decode a pair - match decode_pair(bytes, pos) { - Ok((pair, consumed)) => { - pairs.push(pair); - pos += consumed; - } - Err(e) => { - // Check if this is a zero-length key (separator) - if e.contains("Zero-length") { - pos += 1; // Skip the 0x00 - break; - } - return Err(format!("Failed to decode pair at position {}: {}", pos, e)); - } - } - } + let map_bytes = bytes + .get(start_pos..) + .ok_or_else(|| format!("PSBT map starts out of bounds at position {start_pos}"))?; + let (pairs, consumed) = decode_psbt_key_value_map(map_bytes) + .map_err(|e| format!("Failed to decode map at position {start_pos}: {e}"))?; // Add pair count first let pair_count = pairs.len(); @@ -355,7 +233,7 @@ fn decode_map( map_node.add_child(pair_to_node(pair, idx, context)); } - Ok((map_node, pairs, pos)) + Ok((map_node, pairs, start_pos + consumed)) } /// Parse PSBT showing raw key-value structure from bytes @@ -459,6 +337,10 @@ mod tests { key_type_name(0xFC, PsbtMapContext::Global), "PSBT_GLOBAL_PROPRIETARY" ); + assert_eq!( + key_type_name(0xfb, PsbtMapContext::Global), + "PSBT_GLOBAL_VERSION" + ); assert!(key_type_name(0xFF, PsbtMapContext::Global).starts_with("UNKNOWN_TYPE")); // Test input context @@ -470,6 +352,10 @@ mod tests { key_type_name(0x01, PsbtMapContext::Input), "PSBT_IN_WITNESS_UTXO" ); + assert_eq!( + key_type_name(0x1c, PsbtMapContext::Input), + "PSBT_IN_MUSIG2_PARTIAL_SIG" + ); // Test output context assert_eq!( @@ -480,19 +366,50 @@ mod tests { key_type_name(0x03, PsbtMapContext::Output), "PSBT_OUT_AMOUNT" ); + assert_eq!( + key_type_name(0x09, PsbtMapContext::Output), + "PSBT_OUT_SP_V0_INFO" + ); } #[test] fn test_key_to_node() { - let key = Key { - type_value: 0x01, - key: vec![0x01, 0x02, 0x03], + let key_value = PsbtKeyValue { + key_type: 0x01, + key_data: vec![0x02, 0x03], + value: vec![], }; - let node = key_to_node(&key, PsbtMapContext::Global); + let node = key_to_node(&key_value, PsbtMapContext::Global); assert_eq!(node.label, "key"); assert!(!node.children.is_empty()); } + #[test] + fn test_decode_map_with_compact_size_key_type() { + let bytes = [0x04, 0xfd, 0x34, 0x12, 0xaa, 0x02, 0xbb, 0xcc, 0x00]; + + let (node, pairs, consumed) = + decode_map(&bytes, 0, "input", PsbtMapContext::Input).unwrap(); + + assert_eq!(consumed, bytes.len()); + assert_eq!(pairs[0].key_type, 0x1234); + assert_eq!(pairs[0].key_data, vec![0xaa]); + assert!(matches!( + node.children[1].children[0].children[0].value, + Primitive::U64(0x1234) + )); + } + + #[test] + fn test_parse_proprietary_key_with_compact_size_subtype() { + let (prefix, subtype, key_data) = + parse_proprietary_key(&[0x01, b'x', 0xfd, 0x34, 0x12, 0xaa]).unwrap(); + + assert_eq!(prefix, vec![b'x']); + assert_eq!(subtype, 0x1234); + assert_eq!(key_data, vec![0xaa]); + } + #[test] fn test_magic_bytes() { let magic = b"psbt\xff"; diff --git a/packages/wasm-utxo/src/psbt_ops.rs b/packages/wasm-utxo/src/psbt_ops.rs index dc8bb238da5..835aed73e21 100644 --- a/packages/wasm-utxo/src/psbt_ops.rs +++ b/packages/wasm-utxo/src/psbt_ops.rs @@ -1,5 +1,154 @@ +use miniscript::bitcoin::consensus::encode::VarInt; +use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::{psbt, psbt::raw, Psbt, TxIn, TxOut}; +/// A raw PSBT key-value record. +/// +/// `key_type` identifies the standard or proprietary record type and `key_data` +/// carries the remainder of the key. Keeping this representation raw ensures +/// callers can inspect every known and future PSBT key without duplicating the +/// typed field layout maintained by rust-bitcoin. +#[derive(Debug, Clone)] +pub(crate) struct PsbtKeyValue { + pub key_type: u64, + pub key_data: Vec, + pub value: Vec, +} + +/// Return the standard name for a known PSBT input key type. +pub(crate) fn known_psbt_input_key_type(key_type: u64) -> Option<&'static str> { + match key_type { + 0x00 => Some("PSBT_IN_NON_WITNESS_UTXO"), + 0x01 => Some("PSBT_IN_WITNESS_UTXO"), + 0x02 => Some("PSBT_IN_PARTIAL_SIG"), + 0x03 => Some("PSBT_IN_SIGHASH_TYPE"), + 0x04 => Some("PSBT_IN_REDEEM_SCRIPT"), + 0x05 => Some("PSBT_IN_WITNESS_SCRIPT"), + 0x06 => Some("PSBT_IN_BIP32_DERIVATION"), + 0x07 => Some("PSBT_IN_FINAL_SCRIPTSIG"), + 0x08 => Some("PSBT_IN_FINAL_SCRIPTWITNESS"), + 0x09 => Some("PSBT_IN_POR_COMMITMENT"), + 0x0a => Some("PSBT_IN_RIPEMD160"), + 0x0b => Some("PSBT_IN_SHA256"), + 0x0c => Some("PSBT_IN_HASH160"), + 0x0d => Some("PSBT_IN_HASH256"), + 0x0e => Some("PSBT_IN_PREVIOUS_TXID"), + 0x0f => Some("PSBT_IN_OUTPUT_INDEX"), + 0x10 => Some("PSBT_IN_SEQUENCE"), + 0x11 => Some("PSBT_IN_REQUIRED_TIME_LOCKTIME"), + 0x12 => Some("PSBT_IN_REQUIRED_HEIGHT_LOCKTIME"), + 0x13 => Some("PSBT_IN_TAP_KEY_SIG"), + 0x14 => Some("PSBT_IN_TAP_SCRIPT_SIG"), + 0x15 => Some("PSBT_IN_TAP_LEAF_SCRIPT"), + 0x16 => Some("PSBT_IN_TAP_BIP32_DERIVATION"), + 0x17 => Some("PSBT_IN_TAP_INTERNAL_KEY"), + 0x18 => Some("PSBT_IN_TAP_MERKLE_ROOT"), + 0x1a => Some("PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"), + 0x1b => Some("PSBT_IN_MUSIG2_PUB_NONCE"), + 0x1c => Some("PSBT_IN_MUSIG2_PARTIAL_SIG"), + 0x1d => Some("PSBT_IN_SP_ECDH_SHARE"), + 0x1e => Some("PSBT_IN_SP_DLEQ"), + 0x1f => Some("PSBT_IN_SP_SPEND_BIP32_DERIVATION"), + 0x20 => Some("PSBT_IN_SP_TWEAK"), + 0xfc => Some("PSBT_IN_PROPRIETARY"), + _ => None, + } +} + +/// Decode one canonical Bitcoin CompactSize integer and report its byte length. +pub(crate) fn decode_compact_size_u64(bytes: &[u8]) -> Result<(u64, usize), String> { + let mut reader = bytes; + let value = VarInt::consensus_decode(&mut reader) + .map_err(|e| format!("failed to read compact size: {e}"))? + .0; + Ok((value, bytes.len() - reader.len())) +} + +/// Decode one canonical Bitcoin CompactSize length and report its byte length. +pub(crate) fn decode_compact_size(bytes: &[u8]) -> Result<(usize, usize), String> { + let (value, size) = decode_compact_size_u64(bytes)?; + let value = usize::try_from(value) + .map_err(|_| format!("compact size {value} exceeds platform limits"))?; + Ok((value, size)) +} + +/// Decode one BIP-174 key-value map, including its terminating empty key. +/// +/// rust-bitcoin keeps raw `Pair` decoding crate-private, so this is the +/// feature-independent equivalent used by both the inspection and WASM APIs. +pub(crate) fn decode_psbt_key_value_map( + bytes: &[u8], +) -> Result<(Vec, usize), String> { + let mut key_values = Vec::new(); + let mut offset = 0; + + loop { + let (key_length, key_length_size) = decode_compact_size(&bytes[offset..])?; + offset += key_length_size; + if key_length == 0 { + return Ok((key_values, offset)); + } + + let key_end = offset + .checked_add(key_length) + .ok_or_else(|| "PSBT key length overflows platform limits".to_string())?; + let key = bytes + .get(offset..key_end) + .ok_or_else(|| format!("PSBT key exceeds map length at offset {offset}"))?; + offset = key_end; + + let (key_type, key_type_size) = decode_compact_size_u64(key)?; + + let (value_length, value_length_size) = decode_compact_size(&bytes[offset..])?; + offset += value_length_size; + let value_end = offset + .checked_add(value_length) + .ok_or_else(|| "PSBT value length overflows platform limits".to_string())?; + let value = bytes + .get(offset..value_end) + .ok_or_else(|| format!("PSBT value exceeds map length at offset {offset}"))?; + offset = value_end; + + key_values.push(PsbtKeyValue { + key_type, + key_data: key[key_type_size..].to_vec(), + value: value.to_vec(), + }); + } +} + +/// Returns every serialized key-value record for a PSBT input. +/// +/// rust-bitcoin stores standard PSBT keys in typed struct fields and only +/// preserves unrecognized keys in `unknown`. Inspecting the serialized input +/// map exposes both sets uniformly, including metadata such as PSBT_IN_SHA256. +pub(crate) fn get_input_key_values(psbt: &Psbt, index: usize) -> Result, String> { + let input_count = psbt.inputs.len(); + if index >= input_count { + return Err(format!( + "input index {index} out of bounds (have {input_count} inputs)" + )); + } + + let serialized = psbt.serialize(); + if !serialized.starts_with(b"psbt\xff") { + return Err("serialized PSBT has an invalid magic prefix".to_string()); + } + + let mut offset = 5; + let (_, consumed) = decode_psbt_key_value_map(&serialized[offset..])?; + offset += consumed; + for current_index in 0..=index { + let (key_values, consumed) = decode_psbt_key_value_map(&serialized[offset..])?; + offset += consumed; + if current_index == index { + return Ok(key_values); + } + } + + unreachable!("input index bounds were checked before parsing") +} + /// Shared accessor trait for types that wrap a `Psbt`. /// /// Provides default implementations for common introspection methods so that @@ -297,3 +446,72 @@ pub fn insert_output( psbt.outputs.insert(index, psbt_output); Ok(index) } + +#[cfg(test)] +mod tests { + use super::{decode_compact_size, decode_psbt_key_value_map, known_psbt_input_key_type}; + + #[test] + fn decodes_compact_size_key_types() { + let bytes = [0x04, 0xfd, 0x34, 0x12, 0xaa, 0x02, 0xbb, 0xcc, 0x00]; + + let (key_values, consumed) = decode_psbt_key_value_map(&bytes).unwrap(); + + assert_eq!(consumed, bytes.len()); + assert_eq!(key_values.len(), 1); + assert_eq!(key_values[0].key_type, 0x1234); + assert_eq!(key_values[0].key_data, vec![0xaa]); + assert_eq!(key_values[0].value, vec![0xbb, 0xcc]); + } + + #[test] + fn rejects_truncated_compact_sizes() { + assert!(decode_compact_size(&[0xfd, 0x34]).is_err()); + } + + #[test] + fn recognizes_registered_psbt_input_key_types() { + let known_key_types = [ + (0x00, "PSBT_IN_NON_WITNESS_UTXO"), + (0x01, "PSBT_IN_WITNESS_UTXO"), + (0x02, "PSBT_IN_PARTIAL_SIG"), + (0x03, "PSBT_IN_SIGHASH_TYPE"), + (0x04, "PSBT_IN_REDEEM_SCRIPT"), + (0x05, "PSBT_IN_WITNESS_SCRIPT"), + (0x06, "PSBT_IN_BIP32_DERIVATION"), + (0x07, "PSBT_IN_FINAL_SCRIPTSIG"), + (0x08, "PSBT_IN_FINAL_SCRIPTWITNESS"), + (0x09, "PSBT_IN_POR_COMMITMENT"), + (0x0a, "PSBT_IN_RIPEMD160"), + (0x0b, "PSBT_IN_SHA256"), + (0x0c, "PSBT_IN_HASH160"), + (0x0d, "PSBT_IN_HASH256"), + (0x0e, "PSBT_IN_PREVIOUS_TXID"), + (0x0f, "PSBT_IN_OUTPUT_INDEX"), + (0x10, "PSBT_IN_SEQUENCE"), + (0x11, "PSBT_IN_REQUIRED_TIME_LOCKTIME"), + (0x12, "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME"), + (0x13, "PSBT_IN_TAP_KEY_SIG"), + (0x14, "PSBT_IN_TAP_SCRIPT_SIG"), + (0x15, "PSBT_IN_TAP_LEAF_SCRIPT"), + (0x16, "PSBT_IN_TAP_BIP32_DERIVATION"), + (0x17, "PSBT_IN_TAP_INTERNAL_KEY"), + (0x18, "PSBT_IN_TAP_MERKLE_ROOT"), + (0x1a, "PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS"), + (0x1b, "PSBT_IN_MUSIG2_PUB_NONCE"), + (0x1c, "PSBT_IN_MUSIG2_PARTIAL_SIG"), + (0x1d, "PSBT_IN_SP_ECDH_SHARE"), + (0x1e, "PSBT_IN_SP_DLEQ"), + (0x1f, "PSBT_IN_SP_SPEND_BIP32_DERIVATION"), + (0x20, "PSBT_IN_SP_TWEAK"), + (0xfc, "PSBT_IN_PROPRIETARY"), + ]; + + for (key_type, expected_name) in known_key_types { + assert_eq!(known_psbt_input_key_type(key_type), Some(expected_name)); + } + for key_type in [0x19, 0x21, 0xfd, 0x1234] { + assert_eq!(known_psbt_input_key_type(key_type), None); + } + } +} diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 514c8c86dd4..b368230b4d7 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -45,6 +45,34 @@ pub struct PsbtInputData { pub tap_bip32_derivation: Vec, } +/// A PSBT input key classified from its BIP-174 key type. +#[derive(Debug, Clone)] +pub enum PsbtInputKey { + Known(&'static str), + Unknown(u64), +} + +/// A serialized PSBT input key-value record with a Rust-defined key classification. +#[derive(Debug, Clone)] +pub struct PsbtInputKeyValue { + pub key: PsbtInputKey, + pub key_data: Vec, + pub value: Vec, +} + +impl From for PsbtInputKeyValue { + fn from(key_value: crate::psbt_ops::PsbtKeyValue) -> Self { + let key = crate::psbt_ops::known_psbt_input_key_type(key_value.key_type) + .map(PsbtInputKey::Known) + .unwrap_or(PsbtInputKey::Unknown(key_value.key_type)); + PsbtInputKeyValue { + key, + key_data: key_value.key_data, + value: key_value.value, + } + } +} + impl From<&psbt::Input> for PsbtInputData { fn from(input: &psbt::Input) -> Self { let witness_utxo = input.witness_utxo.as_ref().map(|utxo| WitnessUtxo { @@ -152,6 +180,20 @@ pub fn get_inputs_from_psbt(psbt: &Psbt) -> Result { inputs.try_to_js_value() } +/// Get every serialized key-value record for one PSBT input. +pub fn get_input_key_values_from_psbt( + psbt: &Psbt, + input_index: usize, +) -> Result { + let key_values: Vec = + crate::psbt_ops::get_input_key_values(psbt, input_index) + .map_err(|e| WasmUtxoError::new(&e))? + .into_iter() + .map(PsbtInputKeyValue::from) + .collect(); + key_values.try_to_js_value() +} + /// Get all PSBT outputs as an array of PsbtOutputData pub fn get_outputs_from_psbt(psbt: &Psbt) -> Result { let outputs: Vec = psbt @@ -844,6 +886,12 @@ macro_rules! impl_wasm_psbt_ops { ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { self.wasm_get_inputs() } + pub fn get_input_key_values( + &self, + index: usize, + ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { + self.wasm_get_input_key_values(index) + } pub fn get_outputs( &self, ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { @@ -952,6 +1000,12 @@ macro_rules! impl_wasm_psbt_ops { ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { self.$field.wasm_get_inputs() } + pub fn get_input_key_values( + &self, + index: usize, + ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { + self.$field.wasm_get_input_key_values(index) + } pub fn get_outputs( &self, ) -> Result<::wasm_bindgen::JsValue, $crate::error::WasmUtxoError> { diff --git a/packages/wasm-utxo/src/wasm/psbt_ops.rs b/packages/wasm-utxo/src/wasm/psbt_ops.rs index 4a4fbabbb5c..3a084476878 100644 --- a/packages/wasm-utxo/src/wasm/psbt_ops.rs +++ b/packages/wasm-utxo/src/wasm/psbt_ops.rs @@ -38,6 +38,10 @@ pub(crate) trait WasmPsbtOps: PsbtAccess { crate::wasm::psbt::get_inputs_from_psbt(self.psbt()) } + fn wasm_get_input_key_values(&self, index: usize) -> Result { + crate::wasm::psbt::get_input_key_values_from_psbt(self.psbt(), index) + } + fn wasm_get_outputs(&self) -> Result { crate::wasm::psbt::get_outputs_from_psbt(self.psbt()) } diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index 96fe1788953..fa91e80caf1 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -519,6 +519,25 @@ impl TryIntoJsValue for crate::wasm::psbt::PsbtInputData { } } +impl TryIntoJsValue for crate::wasm::psbt::PsbtInputKeyValue { + fn try_to_js_value(&self) -> Result { + match &self.key { + crate::wasm::psbt::PsbtInputKey::Known(key) => js_obj!( + "type" => "known".to_string(), + "key" => (*key).to_string(), + "keyData" => self.key_data, + "value" => self.value, + ), + crate::wasm::psbt::PsbtInputKey::Unknown(key_type) => js_obj!( + "type" => "unknown".to_string(), + "keyType" => *key_type, + "keyData" => self.key_data, + "value" => self.value, + ), + } + } +} + impl TryIntoJsValue for crate::wasm::psbt::PsbtOutputData { fn try_to_js_value(&self) -> Result { js_obj!( diff --git a/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts b/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts index 09e79fd6f95..092c50f8c2f 100644 --- a/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts +++ b/packages/wasm-utxo/test/descriptorWallet/psbt/findDescriptors.ts @@ -53,6 +53,52 @@ describe("descriptorWallet/psbt/findDescriptors", () => { assert.strictEqual(result.index, 5); }); + it("should ignore root derivation paths from external keys", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedScript = descriptor.atDerivationIndex(5).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script: derivedScript, value: 100000n }, + bip32Derivation: [{ path: "" }, { path: "m" }, { path: "m/0/5" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 5); + }); + + it("should ignore unusable external derivation paths", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedScript = descriptor.atDerivationIndex(5).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script: derivedScript, value: 100000n }, + bip32Derivation: [{ path: "m/86'/0'/0'/0/5'" }, { path: "m/0/5" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 5); + }); + + it("should ignore root derivation paths when no wallet derivation is present", () => { + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + + const input: PsbtInput = { + witnessUtxo: { script: descriptor.atDerivationIndex(5).scriptPubkey(), value: 100000n }, + bip32Derivation: [{ path: "" }, { path: "m" }], + }; + + assert.strictEqual(findDescriptorForInput(input, descriptorMap), undefined); + }); + it("should find derivable descriptor using tapBip32Derivation", () => { const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); const derivedScript = descriptor.atDerivationIndex(10).scriptPubkey(); @@ -88,6 +134,23 @@ describe("descriptorWallet/psbt/findDescriptors", () => { assert.strictEqual(result.index, 7); }); + it("should ignore root taproot derivation paths from external keys", () => { + const descriptor = Descriptor.fromStringDetectType(derivableDescriptor); + const derivedScript = descriptor.atDerivationIndex(10).scriptPubkey(); + + const descriptorMap = toDescriptorMap([{ name: "derivable", value: derivableDescriptor }]); + + const input: PsbtInput = { + witnessUtxo: { script: derivedScript, value: 100000n }, + tapBip32Derivation: [{ path: "" }, { path: "m" }, { path: "m/0/10" }], + }; + + const result = findDescriptorForInput(input, descriptorMap); + + assert.ok(result); + assert.strictEqual(result.index, 10); + }); + it("should return undefined when no matching descriptor", () => { const descriptorMap = toDescriptorMap([{ name: "wpkh", value: wpkhDescriptor }]); diff --git a/packages/wasm-utxo/test/pox5.ts b/packages/wasm-utxo/test/pox5.ts index 3f7b514b682..b14bf2d05a2 100644 --- a/packages/wasm-utxo/test/pox5.ts +++ b/packages/wasm-utxo/test/pox5.ts @@ -229,6 +229,7 @@ describe("PoX-5 Bitcoin Staking lockup script", function () { // the requested input rather than requiring every input to be complete. assert.throws(() => psbt.finalizeInput(0), /satisfy|preimage|finalize/i); psbt.addSha256Preimage(0, principalPreimage); + psbt.finalizeInput(0); assert.deepStrictEqual(psbt.getPartialSignatures(0), []); diff --git a/packages/wasm-utxo/test/psbtKeyValues.ts b/packages/wasm-utxo/test/psbtKeyValues.ts new file mode 100644 index 00000000000..6591c81512d --- /dev/null +++ b/packages/wasm-utxo/test/psbtKeyValues.ts @@ -0,0 +1,69 @@ +import * as assert from "assert"; +import * as crypto from "crypto"; + +import { fixedScriptWallet, Psbt } from "../js/index.js"; + +const KEY_TYPE_SHA256 = "PSBT_IN_SHA256"; +const UNKNOWN_KEY_TYPE = 0x50n; +const EXTENDED_UNKNOWN_KEY_TYPE = 0x1234n; +const EXTENDED_KEY_TYPE_SUFFIX = new Uint8Array([0x34, 0x12, 0xaa]); + +function createPsbt(): Psbt { + const psbt = Psbt.create(2, 0); + psbt.addInput("01".repeat(32), 0, 100_000n, new Uint8Array(34)); + psbt.addOutput(new Uint8Array([0x6a]), 0n); + return psbt; +} + +function assertInputKeyValues( + keyValues: ReturnType, + preimage: Uint8Array, +): void { + const sha256Preimage = keyValues.find( + (keyValue) => + keyValue.type === "known" && + keyValue.key === KEY_TYPE_SHA256 && + Buffer.from(keyValue.keyData).equals(crypto.createHash("sha256").update(preimage).digest()), + ); + assert.ok(sha256Preimage, "PSBT input must expose the SHA256 preimage record"); + assert.deepStrictEqual(sha256Preimage.value, preimage); + + const unknownKeyValue = keyValues.find( + (keyValue) => keyValue.type === "unknown" && keyValue.keyType === UNKNOWN_KEY_TYPE, + ); + assert.ok(unknownKeyValue, "PSBT input must expose unknown key-value records"); + assert.deepStrictEqual(unknownKeyValue.keyData, new Uint8Array([0x01, 0x02])); + assert.deepStrictEqual(unknownKeyValue.value, new Uint8Array([0x03, 0x04])); + + const extendedUnknownKeyValue = keyValues.find( + (keyValue) => keyValue.type === "unknown" && keyValue.keyType === EXTENDED_UNKNOWN_KEY_TYPE, + ); + assert.ok(extendedUnknownKeyValue, "PSBT input must preserve CompactSize key types"); + assert.deepStrictEqual(extendedUnknownKeyValue.keyData, new Uint8Array([0xaa])); + assert.deepStrictEqual(extendedUnknownKeyValue.value, new Uint8Array([0x05, 0x06])); +} + +describe("PSBT input key values", function () { + it("classifies known and unknown records after deserialization", function () { + const preimage = new Uint8Array(32).fill(0x42); + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + psbt.setInputKV( + 0, + { type: "unknown", keyType: Number(UNKNOWN_KEY_TYPE), data: new Uint8Array([0x01, 0x02]) }, + new Uint8Array([0x03, 0x04]), + ); + psbt.setInputKV( + 0, + { type: "unknown", keyType: 0xfd, data: EXTENDED_KEY_TYPE_SUFFIX }, + new Uint8Array([0x05, 0x06]), + ); + + const serialized = psbt.serialize(); + assertInputKeyValues(Psbt.deserialize(serialized).getInputKeyValues(0), preimage); + assertInputKeyValues( + fixedScriptWallet.BitGoPsbt.fromBytes(serialized, "bitcoin").getInputKeyValues(0), + preimage, + ); + }); +});