diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 326a475..1ef602e 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 766a206..0d430f0 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 17b2f22..b793e4b 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -86,10 +86,41 @@ 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, + 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 6039d97..8a3f1c3 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 435d54a..7cab71b 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -1,11 +1,28 @@ -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 - */ -export interface ITransaction { +/** Common read-only interface shared by transactions and PSBTs */ +export interface ITransactionCommon { + inputCount(): number; + outputCount(): number; + 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[]; } /** @@ -27,9 +44,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 +99,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(); + } + + version(): number { + return this._wasm.version(); + } + + 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 +145,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 +166,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(); + } + + version(): number { + return this._wasm.version(); + } + + 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 +212,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 +233,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(); + } + + version(): number { + return this._wasm.version(); + } + + 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 ac1372e..a378041 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 3ab80c2..3941bc9 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 bb1d731..68ecfc6 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 71f2cfb..7be20c2 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 f58124d..de46a06 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 2e752d7..9791b94 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 28c0232..dab5d55 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!(