From 7ac35dad1f02aeea98794a4d7dc44c27e6fad7ef Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 16 Mar 2026 13:20:08 +0100 Subject: [PATCH 1/2] feat(wasm-utxo): add legacy tx to PSBT conversion and introspection Add bidirectional conversion between half-signed legacy transactions and PSBTs. Implement transaction introspection API for accessing inputs, outputs, and metadata across all transaction types. - Add `fromHalfSignedLegacyTransaction()` to convert legacy txs to PSBT - Extract partial sigs from scriptSig/witness in `unsign_legacy_input()` - Expose input/output accessors on Transaction/ZcashTransaction/DashTransaction - Add `getInputs()`, `getOutputs()`, `getOutputsWithAddress()` methods - Include version, lockTime, input/output counts - Export `HydrationUnspent` type for legacy conversion - Add comprehensive round-trip tests for p2sh, p2shP2wsh, p2wsh Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 34 +++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 1 + packages/wasm-utxo/js/index.ts | 27 +- packages/wasm-utxo/js/transaction.ts | 125 ++++++-- .../bitgo_psbt/legacy_txformat.rs | 106 ++++++- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 287 ++++++++++++++++++ .../bitgo_psbt/psbt_wallet_input.rs | 8 + .../wasm-utxo/src/wasm/dash_transaction.rs | 32 ++ .../src/wasm/fixed_script_wallet/mod.rs | 61 ++++ packages/wasm-utxo/src/wasm/transaction.rs | 140 +++++++++ .../wasm-utxo/src/wasm/try_into_js_value.rs | 43 +++ 11 files changed, 843 insertions(+), 21 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 326a4754..1ef602e6 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -126,6 +126,12 @@ export type ParseOutputsOptions = { payGoPubkeys?: ECPairArg[]; }; +export type HydrationUnspent = { + chain: number; + index: number; + value: bigint; +}; + export class BitGoPsbt implements IPsbtWithAddress { protected constructor(protected _wasm: WasmBitGoPsbt) {} @@ -185,6 +191,34 @@ export class BitGoPsbt implements IPsbtWithAddress { return new BitGoPsbt(wasm); } + /** + * Convert a half-signed legacy transaction to a psbt-lite. + * + * Extracts partial signatures from scriptSig/witness and creates a PSBT + * with proper wallet metadata (bip32Derivation, scripts, witnessUtxo). + * Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). + * + * @param txBytes - The serialized half-signed legacy transaction + * @param network - Network name + * @param walletKeys - The wallet's root keys + * @param unspents - Chain, index, and value for each input + */ + static fromHalfSignedLegacyTransaction( + txBytes: Uint8Array, + network: NetworkName, + walletKeys: WalletKeysArg, + unspents: HydrationUnspent[], + ): BitGoPsbt { + const keys = RootWalletKeys.from(walletKeys); + const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction( + txBytes, + network, + keys.wasm, + unspents, + ); + return new BitGoPsbt(wasm); + } + /** * Add an input to the PSBT * diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 766a2064..0d430f02 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -31,6 +31,7 @@ export { type AddWalletOutputOptions, type ParseTransactionOptions, type ParseOutputsOptions, + type HydrationUnspent, } from "./BitGoPsbt.js"; // Zcash-specific PSBT subclass diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 17b2f227..e018c8d7 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -86,10 +86,35 @@ declare module "./wasm/wasm_utxo.js" { interface PsbtOutputDataWithAddress extends PsbtOutputData { address: string; } + + /** Outpoint referencing a previous transaction output */ + interface TxOutPoint { + txid: string; + vout: number; + } + + /** Raw transaction input data returned by Transaction.getInputs() */ + interface TxInputData { + previousOutput: TxOutPoint; + sequence: number; + scriptSig: Uint8Array; + witness: Uint8Array[]; + } + + /** Raw transaction output data returned by Transaction.getOutputs() */ + interface TxOutputData { + script: Uint8Array; + value: bigint; + } + + /** Transaction output data with resolved address */ + interface TxOutputDataWithAddress extends TxOutputData { + address: string; + } } export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo.js"; export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo.js"; export { Psbt } from "./descriptorWallet/Psbt.js"; -export { DashTransaction, Transaction, ZcashTransaction } from "./transaction.js"; +export { DashTransaction, Transaction, ZcashTransaction, type ITransaction } from "./transaction.js"; export { hasPsbtMagic, type IPsbt, type IPsbtWithAddress } from "./psbt.js"; diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index 435d54aa..f7fbcf7e 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -1,4 +1,12 @@ -import { WasmDashTransaction, WasmTransaction, WasmZcashTransaction } from "./wasm/wasm_utxo.js"; +import { + WasmDashTransaction, + WasmTransaction, + WasmZcashTransaction, + type TxInputData, + type TxOutputData, + type TxOutputDataWithAddress, +} from "./wasm/wasm_utxo.js"; +import type { CoinName } from "./coinName.js"; /** * Common interface for all transaction types @@ -6,6 +14,13 @@ import { WasmDashTransaction, WasmTransaction, WasmZcashTransaction } from "./wa export interface ITransaction { toBytes(): Uint8Array; getId(): string; + inputCount(): number; + outputCount(): number; + get version(): number; + get lockTime(): number; + getInputs(): TxInputData[]; + getOutputs(): TxOutputData[]; + getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[]; } /** @@ -27,9 +42,7 @@ export class Transaction implements ITransaction { return new Transaction(WasmTransaction.from_bytes(bytes)); } - /** - * @internal Create from WASM instance directly (avoids re-parsing bytes) - */ + /** @internal Create from WASM instance directly (avoids re-parsing bytes) */ static fromWasm(wasm: WasmTransaction): Transaction { return new Transaction(wasm); } @@ -84,9 +97,35 @@ export class Transaction implements ITransaction { return this._wasm.get_vsize(); } - /** - * @internal - */ + inputCount(): number { + return this._wasm.input_count(); + } + + outputCount(): number { + return this._wasm.output_count(); + } + + get version(): number { + return this._wasm.version(); + } + + get lockTime(): number { + return this._wasm.lock_time(); + } + + getInputs(): TxInputData[] { + return this._wasm.get_inputs() as TxInputData[]; + } + + getOutputs(): TxOutputData[] { + return this._wasm.get_outputs() as TxOutputData[]; + } + + getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] { + return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[]; + } + + /** @internal */ get wasm(): WasmTransaction { return this._wasm; } @@ -104,9 +143,7 @@ export class ZcashTransaction implements ITransaction { return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes)); } - /** - * @internal Create from WASM instance directly (avoids re-parsing bytes) - */ + /** @internal Create from WASM instance directly (avoids re-parsing bytes) */ static fromWasm(wasm: WasmZcashTransaction): ZcashTransaction { return new ZcashTransaction(wasm); } @@ -127,9 +164,35 @@ export class ZcashTransaction implements ITransaction { return this._wasm.get_txid(); } - /** - * @internal - */ + inputCount(): number { + return this._wasm.input_count(); + } + + outputCount(): number { + return this._wasm.output_count(); + } + + get version(): number { + return this._wasm.version(); + } + + get lockTime(): number { + return this._wasm.lock_time(); + } + + getInputs(): TxInputData[] { + return this._wasm.get_inputs() as TxInputData[]; + } + + getOutputs(): TxOutputData[] { + return this._wasm.get_outputs() as TxOutputData[]; + } + + getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] { + return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[]; + } + + /** @internal */ get wasm(): WasmZcashTransaction { return this._wasm; } @@ -147,9 +210,7 @@ export class DashTransaction implements ITransaction { return new DashTransaction(WasmDashTransaction.from_bytes(bytes)); } - /** - * @internal Create from WASM instance directly (avoids re-parsing bytes) - */ + /** @internal Create from WASM instance directly (avoids re-parsing bytes) */ static fromWasm(wasm: WasmDashTransaction): DashTransaction { return new DashTransaction(wasm); } @@ -170,9 +231,35 @@ export class DashTransaction implements ITransaction { return this._wasm.get_txid(); } - /** - * @internal - */ + inputCount(): number { + return this._wasm.input_count(); + } + + outputCount(): number { + return this._wasm.output_count(); + } + + get version(): number { + return this._wasm.version(); + } + + get lockTime(): number { + return this._wasm.lock_time(); + } + + getInputs(): TxInputData[] { + return this._wasm.get_inputs() as TxInputData[]; + } + + getOutputs(): TxOutputData[] { + return this._wasm.get_outputs() as TxOutputData[]; + } + + getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[] { + return this._wasm.get_outputs_with_address(coin) as TxOutputDataWithAddress[]; + } + + /** @internal */ get wasm(): WasmDashTransaction { return this._wasm; } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs index ac1372e6..a3780416 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs @@ -7,9 +7,10 @@ use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0; use miniscript::bitcoin::blockdata::script::Builder; +use miniscript::bitcoin::ecdsa::Signature as EcdsaSig; use miniscript::bitcoin::psbt::Psbt; use miniscript::bitcoin::script::PushBytesBuf; -use miniscript::bitcoin::{Transaction, Witness}; +use miniscript::bitcoin::{CompressedPublicKey, ScriptBuf, Transaction, TxIn, Witness}; /// Build a half-signed transaction in legacy format from a PSBT. /// @@ -147,3 +148,106 @@ pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result { Ok(tx) } + +/// A partial signature extracted from a legacy half-signed input. +pub struct LegacyPartialSig { + pub pubkey: CompressedPublicKey, + pub sig: EcdsaSig, +} + +/// Determines whether a legacy input uses segwit (witness data) and whether it +/// has a p2sh wrapper (scriptSig pushing a redeem script). +/// +/// Returns `(is_p2sh, is_segwit, multisig_script)`. +fn classify_legacy_input(tx_in: &TxIn) -> Result<(bool, bool, ScriptBuf), String> { + let has_witness = !tx_in.witness.is_empty(); + let has_script_sig = !tx_in.script_sig.is_empty(); + + if has_witness { + // Segwit: witness contains [empty, sig0?, sig1?, sig2?, witnessScript] + let witness_items: Vec<&[u8]> = tx_in.witness.iter().collect(); + if witness_items.len() < 5 { + return Err(format!( + "Expected at least 5 witness items, got {}", + witness_items.len() + )); + } + let multisig_script = ScriptBuf::from(witness_items.last().unwrap().to_vec()); + let is_p2sh = has_script_sig; // p2shP2wsh has scriptSig, p2wsh does not + Ok((is_p2sh, true, multisig_script)) + } else if has_script_sig { + // p2sh only: scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript] + // Parse the scriptSig instructions to extract the redeemScript (last push) + let instructions: Vec<_> = tx_in + .script_sig + .instructions() + .collect::, _>>() + .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; + if instructions.len() < 5 { + return Err(format!( + "Expected at least 5 scriptSig items, got {}", + instructions.len() + )); + } + let last = instructions.last().unwrap(); + let multisig_bytes = match last { + miniscript::bitcoin::script::Instruction::PushBytes(bytes) => bytes.as_bytes(), + _ => return Err("Last scriptSig item is not a push".to_string()), + }; + Ok((true, false, ScriptBuf::from(multisig_bytes.to_vec()))) + } else { + Err("Input has neither witness nor scriptSig".to_string()) + } +} + +/// Extract a partial signature from a legacy half-signed input. +/// +/// This is the inverse of the signature placement in `build_half_signed_legacy_tx`. +/// It parses the scriptSig/witness to find the single signature and its position +/// in the 2-of-3 multisig, then returns the corresponding pubkey and signature. +pub fn unsign_legacy_input(tx_in: &TxIn) -> Result { + let (_, is_segwit, multisig_script) = classify_legacy_input(tx_in)?; + + let pubkeys = parse_multisig_script_2_of_3(&multisig_script)?; + + // Extract the 3 signature slots (index 1..=3, skipping the leading OP_0/empty) + let sig_slots: Vec> = if is_segwit { + let items: Vec<&[u8]> = tx_in.witness.iter().collect(); + // witness = [empty, sig0?, sig1?, sig2?, witnessScript] + items[1..=3].iter().map(|s| s.to_vec()).collect() + } else { + // scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript] + let instructions: Vec<_> = tx_in + .script_sig + .instructions() + .collect::, _>>() + .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; + // instructions[0] = OP_0, [1..=3] = sigs, [4] = redeemScript + instructions[1..=3] + .iter() + .map(|inst| match inst { + miniscript::bitcoin::script::Instruction::PushBytes(bytes) => { + bytes.as_bytes().to_vec() + } + miniscript::bitcoin::script::Instruction::Op(_) => vec![], + }) + .collect() + }; + + // Find the non-empty signature slot + let mut found_sig = None; + for (i, slot) in sig_slots.iter().enumerate() { + if !slot.is_empty() { + if found_sig.is_some() { + return Err("Expected exactly 1 signature, found multiple".to_string()); + } + let sig = EcdsaSig::from_slice(slot) + .map_err(|e| format!("Failed to parse signature at position {}: {}", i, e))?; + let pubkey = CompressedPublicKey::from_slice(&pubkeys[i].to_bytes()) + .map_err(|e| format!("Failed to convert pubkey: {}", e))?; + found_sig = Some(LegacyPartialSig { pubkey, sig }); + } + } + + found_sig.ok_or_else(|| "No signature found in input".to_string()) +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 3ab80c22..3941bc99 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -498,6 +498,86 @@ impl BitGoPsbt { } } + /// Convert a half-signed legacy transaction to a psbt-lite. + /// + /// This is the inverse of `get_half_signed_legacy_format()`. It parses the + /// legacy transaction, extracts partial signatures from scriptSig/witness, + /// creates a PSBT with proper wallet metadata (bip32Derivation, scripts, + /// witnessUtxo), and inserts the extracted signatures. + /// + /// Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). + pub fn from_half_signed_legacy_transaction( + tx_bytes: &[u8], + network: Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + unspents: &[psbt_wallet_input::ScriptIdWithValue], + ) -> Result { + use miniscript::bitcoin::consensus::Decodable; + use miniscript::bitcoin::{PublicKey, Transaction}; + + let tx = Transaction::consensus_decode(&mut &tx_bytes[..]) + .map_err(|e| format!("Failed to decode transaction: {}", e))?; + + if tx.input.len() != unspents.len() { + return Err(format!( + "Input count mismatch: tx has {} inputs, got {} unspents", + tx.input.len(), + unspents.len() + )); + } + + let version = tx.version.0; + let lock_time = tx.lock_time.to_consensus_u32(); + + let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time)); + + // Extract signatures before adding inputs (we need the raw tx_in data) + let partial_sigs: Vec = tx + .input + .iter() + .enumerate() + .map(|(i, tx_in)| { + legacy_txformat::unsign_legacy_input(tx_in) + .map_err(|e| format!("Input {}: {}", i, e)) + }) + .collect::, _>>()?; + + // Add wallet inputs (populates bip32Derivation, scripts, witnessUtxo) + for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { + let script_id = psbt_wallet_input::ScriptId { + chain: unspent.chain, + index: unspent.index, + }; + psbt.add_wallet_input( + tx_in.previous_output.txid, + tx_in.previous_output.vout, + unspent.value, + wallet_keys, + script_id, + psbt_wallet_input::WalletInputOptions { + sign_path: None, + sequence: Some(tx_in.sequence.0), + prev_tx: None, // psbt-lite: no nonWitnessUtxo + }, + ) + .map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?; + + // Insert the extracted partial signature + let sig = &partial_sigs[i]; + let pubkey = PublicKey::from(sig.pubkey); + psbt.psbt_mut().inputs[i] + .partial_sigs + .insert(pubkey, sig.sig); + } + + // Add outputs (plain script+value, no wallet metadata) + for tx_out in &tx.output { + psbt.add_output(tx_out.script_pubkey.clone(), tx_out.value.to_sat()); + } + + Ok(psbt) + } + fn new_internal( network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, @@ -4063,6 +4143,213 @@ mod tests { ignore: [BitcoinCash, Ecash, BitcoinGold, Dogecoin, Zcash] ); + /// Round-trip test: PSBT -> legacy half-signed -> PSBT + fn test_round_trip_legacy_for_script_type( + network: Network, + format: fixtures::TxFormat, + script_type: fixtures::ScriptType, + ) -> Result<(), String> { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + + let is_p2ms = matches!( + script_type, + fixtures::ScriptType::P2sh + | fixtures::ScriptType::P2shP2wsh + | fixtures::ScriptType::P2wsh + ); + if !is_p2ms { + return Ok(()); + } + + let output_script_support = network.output_script_support(); + if !script_type.is_supported_by(&output_script_support) { + return Ok(()); + } + + let fixture = fixtures::load_psbt_fixture_with_format_and_namespace( + network.to_utxolib_name(), + fixtures::SignatureState::Halfsigned, + format, + fixtures::FixtureNamespace::UtxolibCompat, + ) + .map_err(|e| format!("Failed to load fixture: {}", e))?; + + let bitgo_psbt = fixture + .to_bitgo_psbt(network) + .map_err(|e| format!("Failed to parse PSBT: {}", e))?; + + let wallet_keys = fixture + .get_wallet_xprvs() + .map_err(|e| format!("Failed to get wallet keys: {}", e))? + .to_root_wallet_keys(); + + let psbt = bitgo_psbt.psbt(); + + // Check all inputs are p2ms with exactly 1 signature + let suitable = psbt.inputs.iter().all(|input| { + use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; + let ms = input + .witness_script + .as_ref() + .or(input.redeem_script.as_ref()); + let is_2of3 = ms + .map(|s| parse_multisig_script_2_of_3(s).is_ok()) + .unwrap_or(false); + is_2of3 && input.partial_sigs.len() == 1 + }); + if !suitable { + return Ok(()); + } + + // Step 1: Extract to legacy + let legacy_bytes = bitgo_psbt + .extract_half_signed_legacy_tx() + .map_err(|e| format!("extract_half_signed_legacy_tx failed: {}", e))?; + + // Step 2: Build unspents from bip32 derivation paths in the PSBT + // The derivation path is m// + let unspents: Vec = psbt + .inputs + .iter() + .enumerate() + .map(|(i, input)| { + let (_, path) = input + .bip32_derivation + .values() + .next() + .ok_or_else(|| format!("Input {} has no bip32 derivation", i))?; + let components: Vec<_> = path.into_iter().collect(); + if components.len() < 2 { + return Err(format!("Input {} derivation path too short", i)); + } + let chain = u32::from(*components[components.len() - 2]); + let index = u32::from(*components[components.len() - 1]); + let value = input + .witness_utxo + .as_ref() + .ok_or_else(|| format!("Input {} has no witnessUtxo", i))? + .value + .to_sat(); + Ok(ScriptIdWithValue { + chain, + index, + value, + }) + }) + .collect::, String>>()?; + + // Step 3: Convert back to PSBT + let reconverted = BitGoPsbt::from_half_signed_legacy_transaction( + &legacy_bytes, + network, + &wallet_keys, + &unspents, + ) + .map_err(|e| format!("from_half_signed_legacy_transaction failed: {}", e))?; + + // Verify: same number of inputs/outputs + let orig_psbt = bitgo_psbt.psbt(); + let new_psbt = reconverted.psbt(); + assert_eq!(orig_psbt.inputs.len(), new_psbt.inputs.len()); + assert_eq!( + orig_psbt.unsigned_tx.output.len(), + new_psbt.unsigned_tx.output.len() + ); + + // Verify: partial_sigs preserved + for (i, (orig_input, new_input)) in orig_psbt + .inputs + .iter() + .zip(new_psbt.inputs.iter()) + .enumerate() + { + assert_eq!( + orig_input.partial_sigs.len(), + new_input.partial_sigs.len(), + "Input {} partial_sigs count mismatch", + i + ); + for (pubkey, orig_sig) in &orig_input.partial_sigs { + let new_sig = new_input + .partial_sigs + .get(pubkey) + .unwrap_or_else(|| panic!("Input {} missing sig for pubkey", i)); + assert_eq!( + orig_sig.to_vec(), + new_sig.to_vec(), + "Input {} signature mismatch", + i + ); + } + } + + // Verify: unsigned tx matches (same txid) + let orig_txid = orig_psbt.unsigned_tx.compute_txid(); + let new_txid = new_psbt.unsigned_tx.compute_txid(); + assert_eq!(orig_txid, new_txid, "txid mismatch"); + + // Verify: psbt-lite (witnessUtxo present, no nonWitnessUtxo) + for (i, input) in new_psbt.inputs.iter().enumerate() { + assert!( + input.witness_utxo.is_some(), + "Input {} missing witnessUtxo", + i + ); + assert!( + input.non_witness_utxo.is_none(), + "Input {} has nonWitnessUtxo (should be psbt-lite)", + i + ); + } + + Ok(()) + } + + crate::test_psbt_fixtures!( + test_round_trip_legacy_p2sh, + network, + format, + { + test_round_trip_legacy_for_script_type( + network, + format, + fixtures::ScriptType::P2sh, + ) + .unwrap(); + }, + ignore: [Zcash] + ); + + crate::test_psbt_fixtures!( + test_round_trip_legacy_p2shp2wsh, + network, + format, + { + test_round_trip_legacy_for_script_type( + network, + format, + fixtures::ScriptType::P2shP2wsh, + ) + .unwrap(); + }, + ignore: [BitcoinCash, Ecash, BitcoinGold, Dogecoin, Zcash] + ); + + crate::test_psbt_fixtures!( + test_round_trip_legacy_p2wsh, + network, + format, + { + test_round_trip_legacy_for_script_type( + network, + format, + fixtures::ScriptType::P2wsh, + ) + .unwrap(); + }, + ignore: [BitcoinCash, Ecash, BitcoinGold, Dogecoin, Zcash] + ); + #[test] fn test_add_paygo_attestation() { use crate::test_utils::fixtures; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index bb1d7319..68ecfc6a 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -665,6 +665,14 @@ pub struct ScriptId { pub index: u32, } +/// ScriptId with value — used by `from_half_signed_legacy_transaction` +#[derive(Debug, Clone, Copy)] +pub struct ScriptIdWithValue { + pub chain: u32, + pub index: u32, + pub value: u64, +} + /// Identifies a key in the wallet triple (user, backup, bitgo) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignerKey { diff --git a/packages/wasm-utxo/src/wasm/dash_transaction.rs b/packages/wasm-utxo/src/wasm/dash_transaction.rs index 71f2cfb6..7be20c27 100644 --- a/packages/wasm-utxo/src/wasm/dash_transaction.rs +++ b/packages/wasm-utxo/src/wasm/dash_transaction.rs @@ -1,4 +1,6 @@ use crate::error::WasmUtxoError; +use crate::wasm::transaction::{tx_inputs_from, tx_outputs_from, tx_outputs_with_address_from}; +use crate::wasm::try_into_js_value::TryIntoJsValue; use wasm_bindgen::prelude::*; /// Dash transaction wrapper that supports Dash special transactions (EVO) by preserving extra payload. @@ -53,4 +55,34 @@ impl WasmDashTransaction { let txid = Txid::from_raw_hash(hash); Ok(txid.to_string()) } + + pub fn input_count(&self) -> usize { + self.parts.transaction.input.len() + } + + pub fn output_count(&self) -> usize { + self.parts.transaction.output.len() + } + + pub fn version(&self) -> i32 { + self.parts.transaction.version.0 + } + + pub fn lock_time(&self) -> u32 { + self.parts.transaction.lock_time.to_consensus_u32() + } + + pub fn get_inputs(&self) -> Result { + tx_inputs_from(&self.parts.transaction).try_to_js_value() + } + + pub fn get_outputs(&self) -> Result { + tx_outputs_from(&self.parts.transaction).try_to_js_value() + } + + pub fn get_outputs_with_address(&self, coin: &str) -> Result { + let network = crate::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + tx_outputs_with_address_from(&self.parts.transaction, network)?.try_to_js_value() + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index f58124d4..de46a06a 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -386,6 +386,67 @@ impl BitGoPsbt { }) } + /// Convert a half-signed legacy transaction to a psbt-lite. + /// + /// # Arguments + /// * `tx_bytes` - The serialized half-signed legacy transaction + /// * `network` - Network name (utxolib or coin name) + /// * `wallet_keys` - The wallet's root keys + /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input + pub fn from_half_signed_legacy_transaction( + tx_bytes: &[u8], + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + // Parse the unspents array from JsValue + let arr = js_sys::Array::from(&unspents); + let mut parsed_unspents = Vec::with_capacity(arr.length() as usize); + for i in 0..arr.length() { + let item = arr.get(i); + let chain = js_sys::Reflect::get(&item, &"chain".into()) + .map_err(|_| WasmUtxoError::new("Missing 'chain' field on unspent"))? + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))? + as u32; + let index = js_sys::Reflect::get(&item, &"index".into()) + .map_err(|_| WasmUtxoError::new("Missing 'index' field on unspent"))? + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? + as u32; + let value_js = js_sys::Reflect::get(&item, &"value".into()) + .map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?; + let value = js_sys::BigInt::from(value_js) + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'value' must be a bigint"))? + as u64; + parsed_unspents.push(ScriptIdWithValue { + chain, + index, + value, + }); + } + + let psbt = + crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_half_signed_legacy_transaction( + tx_bytes, + network, + wallet_keys, + &parsed_unspents, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + /// Add an input to the PSBT /// /// # Arguments diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index 2e752d76..9791b945 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -1,8 +1,88 @@ +use crate::address::networks::{from_output_script_with_network_and_format, AddressFormat}; use crate::error::WasmUtxoError; +use crate::wasm::try_into_js_value::TryIntoJsValue; use miniscript::bitcoin::consensus::{Decodable, Encodable}; use miniscript::bitcoin::Transaction; use wasm_bindgen::prelude::*; +// ============================================================================ +// Transaction Introspection Types +// ============================================================================ + +#[derive(Debug, Clone)] +pub struct TxOutPoint { + pub txid: String, + pub vout: u32, +} + +#[derive(Debug, Clone)] +pub struct TxInputData { + pub previous_output: TxOutPoint, + pub sequence: u32, + pub script_sig: Vec, + pub witness: Vec>, +} + +#[derive(Debug, Clone)] +pub struct TxOutputData { + pub script: Vec, + pub value: u64, +} + +#[derive(Debug, Clone)] +pub struct TxOutputDataWithAddress { + pub script: Vec, + pub value: u64, + pub address: String, +} + +pub(crate) fn tx_inputs_from(tx: &Transaction) -> Vec { + tx.input + .iter() + .map(|inp| TxInputData { + previous_output: TxOutPoint { + txid: inp.previous_output.txid.to_string(), + vout: inp.previous_output.vout, + }, + sequence: inp.sequence.0, + script_sig: inp.script_sig.to_bytes(), + witness: inp.witness.iter().map(|w| w.to_vec()).collect(), + }) + .collect() +} + +pub(crate) fn tx_outputs_from(tx: &Transaction) -> Vec { + tx.output + .iter() + .map(|out| TxOutputData { + script: out.script_pubkey.to_bytes(), + value: out.value.to_sat(), + }) + .collect() +} + +pub(crate) fn tx_outputs_with_address_from( + tx: &Transaction, + network: crate::Network, +) -> Result, WasmUtxoError> { + tx.output + .iter() + .map(|out| { + let address = from_output_script_with_network_and_format( + &out.script_pubkey, + network, + AddressFormat::Default, + ) + .map_err(|e| WasmUtxoError::new(&e.to_string()))?; + Ok(TxOutputDataWithAddress { + script: out.script_pubkey.to_bytes(), + value: out.value.to_sat(), + address, + }) + }) + .collect() +} + /// A Bitcoin-like transaction (for all networks except Zcash) /// /// This class provides basic transaction parsing and serialization for testing @@ -162,6 +242,36 @@ impl WasmTransaction { pub fn get_txid(&self) -> String { self.tx.compute_txid().to_string() } + + pub fn input_count(&self) -> usize { + self.tx.input.len() + } + + pub fn output_count(&self) -> usize { + self.tx.output.len() + } + + pub fn version(&self) -> i32 { + self.tx.version.0 + } + + pub fn lock_time(&self) -> u32 { + self.tx.lock_time.to_consensus_u32() + } + + pub fn get_inputs(&self) -> Result { + tx_inputs_from(&self.tx).try_to_js_value() + } + + pub fn get_outputs(&self) -> Result { + tx_outputs_from(&self.tx).try_to_js_value() + } + + pub fn get_outputs_with_address(&self, coin: &str) -> Result { + let network = crate::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + tx_outputs_with_address_from(&self.tx, network)?.try_to_js_value() + } } /// A Zcash transaction with network-specific fields @@ -231,4 +341,34 @@ impl WasmZcashTransaction { let txid = Txid::from_raw_hash(hash); Ok(txid.to_string()) } + + pub fn input_count(&self) -> usize { + self.parts.transaction.input.len() + } + + pub fn output_count(&self) -> usize { + self.parts.transaction.output.len() + } + + pub fn version(&self) -> i32 { + self.parts.transaction.version.0 + } + + pub fn lock_time(&self) -> u32 { + self.parts.transaction.lock_time.to_consensus_u32() + } + + pub fn get_inputs(&self) -> Result { + tx_inputs_from(&self.parts.transaction).try_to_js_value() + } + + pub fn get_outputs(&self) -> Result { + tx_outputs_from(&self.parts.transaction).try_to_js_value() + } + + pub fn get_outputs_with_address(&self, coin: &str) -> Result { + let network = crate::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + tx_outputs_with_address_from(&self.parts.transaction, network)?.try_to_js_value() + } } 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 28c0232e..dab5d55b 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -415,6 +415,49 @@ impl TryIntoJsValue for crate::inscriptions::InscriptionRevealData { // PSBT Introspection Types TryIntoJsValue implementations // ============================================================================ +// ============================================================================ +// Transaction Introspection Types TryIntoJsValue implementations +// ============================================================================ + +impl TryIntoJsValue for crate::wasm::transaction::TxOutPoint { + fn try_to_js_value(&self) -> Result { + js_obj!( + "txid" => self.txid.clone(), + "vout" => self.vout + ) + } +} + +impl TryIntoJsValue for crate::wasm::transaction::TxInputData { + fn try_to_js_value(&self) -> Result { + js_obj!( + "previousOutput" => self.previous_output.clone(), + "sequence" => self.sequence, + "scriptSig" => self.script_sig.clone(), + "witness" => self.witness.clone() + ) + } +} + +impl TryIntoJsValue for crate::wasm::transaction::TxOutputData { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value + ) + } +} + +impl TryIntoJsValue for crate::wasm::transaction::TxOutputDataWithAddress { + fn try_to_js_value(&self) -> Result { + js_obj!( + "script" => self.script.clone(), + "value" => self.value, + "address" => self.address.clone() + ) + } +} + impl TryIntoJsValue for crate::wasm::psbt::Bip32Derivation { fn try_to_js_value(&self) -> Result { js_obj!( From 49179a2ca9d16807d3e7777afe46410d408d0e93 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 16 Mar 2026 14:33:38 +0100 Subject: [PATCH 2/2] refactor(wasm-utxo)!: extract ITransactionCommon interface BREAKING CHANGE: version and lockTime are now methods instead of getters. Update code from `tx.version` to `tx.version()` and `tx.lockTime` to `tx.lockTime()`. Extract shared transaction and PSBT properties into ITransactionCommon interface to reduce duplication between ITransaction and IPsbt. Convert version and lockTime from getters to methods for consistency with other interface methods. Issue: BTC-2976 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 8 ++++++- packages/wasm-utxo/js/psbt.ts | 9 ++------ packages/wasm-utxo/js/transaction.ts | 34 +++++++++++++++------------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index e018c8d7..b793e4b9 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -116,5 +116,11 @@ declare module "./wasm/wasm_utxo.js" { export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo.js"; export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo.js"; export { Psbt } from "./descriptorWallet/Psbt.js"; -export { DashTransaction, Transaction, ZcashTransaction, type ITransaction } from "./transaction.js"; +export { + DashTransaction, + Transaction, + ZcashTransaction, + type ITransaction, + type ITransactionCommon, +} from "./transaction.js"; export { hasPsbtMagic, type IPsbt, type IPsbtWithAddress } from "./psbt.js"; diff --git a/packages/wasm-utxo/js/psbt.ts b/packages/wasm-utxo/js/psbt.ts index 6039d977..8a3f1c3a 100644 --- a/packages/wasm-utxo/js/psbt.ts +++ b/packages/wasm-utxo/js/psbt.ts @@ -1,15 +1,10 @@ import type { PsbtInputData, PsbtOutputData, PsbtOutputDataWithAddress } from "./wasm/wasm_utxo.js"; import type { BIP32 } from "./bip32.js"; +import type { ITransactionCommon } from "./transaction.js"; /** Common interface for PSBT types */ -export interface IPsbt { - inputCount(): number; - outputCount(): number; - getInputs(): PsbtInputData[]; - getOutputs(): PsbtOutputData[]; +export interface IPsbt extends ITransactionCommon { getGlobalXpubs(): BIP32[]; - version(): number; - lockTime(): number; unsignedTxId(): string; addInputAtIndex( index: number, diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index f7fbcf7e..7cab71b8 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -8,18 +8,20 @@ import { } from "./wasm/wasm_utxo.js"; import type { CoinName } from "./coinName.js"; -/** - * Common interface for all transaction types - */ -export interface ITransaction { - toBytes(): Uint8Array; - getId(): string; +/** Common read-only interface shared by transactions and PSBTs */ +export interface ITransactionCommon { inputCount(): number; outputCount(): number; - get version(): number; - get lockTime(): number; - getInputs(): TxInputData[]; - getOutputs(): TxOutputData[]; + version(): number; + lockTime(): number; + getInputs(): TInput[]; + getOutputs(): TOutput[]; +} + +/** Common interface for all transaction types */ +export interface ITransaction extends ITransactionCommon { + toBytes(): Uint8Array; + getId(): string; getOutputsWithAddress(coin: CoinName): TxOutputDataWithAddress[]; } @@ -105,11 +107,11 @@ export class Transaction implements ITransaction { return this._wasm.output_count(); } - get version(): number { + version(): number { return this._wasm.version(); } - get lockTime(): number { + lockTime(): number { return this._wasm.lock_time(); } @@ -172,11 +174,11 @@ export class ZcashTransaction implements ITransaction { return this._wasm.output_count(); } - get version(): number { + version(): number { return this._wasm.version(); } - get lockTime(): number { + lockTime(): number { return this._wasm.lock_time(); } @@ -239,11 +241,11 @@ export class DashTransaction implements ITransaction { return this._wasm.output_count(); } - get version(): number { + version(): number { return this._wasm.version(); } - get lockTime(): number { + lockTime(): number { return this._wasm.lock_time(); }