From b3591271753ed4cbdf475cdedb36b9290652a082 Mon Sep 17 00:00:00 2001 From: bitgobot Date: Tue, 29 Sep 2026 17:06:43 +0000 Subject: [PATCH] fix(wasm-utxo): authenticate legacy PSBT prevout UTXO data Legacy P2SH inputs on Bitcoin, Litecoin, Dogecoin, Dash and Pearl do not commit the input amount in their sighash, so a fabricated non_witness_utxo (or a lying witness_utxo) supplied by a compromised UTXO feed could inflate the displayed input value, pass the absurd-fee guard and hide a large miner fee behind fully valid signatures. - Check non_witness_utxo.compute_txid() against the prevout txid and cross-check witness_utxo against the referenced prevout output in get_output_script_and_value_for_network (Dash raw bytes and Zcash transparent txids follow network-specific rules and are validated separately). - Require the full previous transaction for legacy P2SH and p2shP2pk inputs on non-value-committing networks in add_wallet_input_to_psbt, add_replay_protection_input_to_psbt, add_input_at_index, BitGoPsbt::deserialize, hydration (HydrationUnspent gains an optional prevTx field) and the descriptor input builder. - Enforce the same validation before signing, parsing and fee-based extraction: sign, sign_with_privkey, sign_all_with_xpriv, musig2 context creation, parse_transaction_with_wallet_keys and the extract_tx* family. - Dash PSBT deserialization now verifies the preserved raw prevout bytes against the prevout txid. - Update AcidTest and fixed-script tests to build synthetic previous transactions whose computed txids match their outpoints, and add Rust and TypeScript regressions for txid mismatch, witness/non- witness disagreement, forged nested-segwit metadata and missing prevTx rejection. Ticket: WCN-1907 Session-Id: f3c6e0a8-c064-4f47-8c83-f52c665fe185 Task-Id: 078b961f-836b-4c49-a8ce-c08e0aacfc38 Requested-By: David Kaplan --- packages/wasm-utxo/cli/src/psbt/add_input.rs | 1 + .../js/fixedScriptWallet/BitGoPsbt.ts | 26 +- .../wasm-utxo/js/fixedScriptWallet/prevTx.ts | 17 +- packages/wasm-utxo/js/testutils/AcidTest.ts | 81 +++- packages/wasm-utxo/src/dash/transaction.rs | 7 +- .../bitgo_psbt/dash_psbt.rs | 100 ++++- .../bitgo_psbt/fixed_script_input.rs | 13 +- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 408 +++++++++++++++--- .../bitgo_psbt/psbt_wallet_input.rs | 373 +++++++++++++++- .../bitgo_psbt/zcash_psbt.rs | 9 +- packages/wasm-utxo/src/transparent_signing.rs | 35 +- packages/wasm-utxo/src/wasm/psbt.rs | 1 + .../wasm-utxo/src/wasm/try_from_js_value.rs | 51 ++- packages/wasm-utxo/test/benchmark/signing.ts | 21 +- .../test/fixedScript/dogecoinLOLAmount.ts | 13 +- .../test/fixedScript/fromNetworkFormat.ts | 66 ++- .../fixedScript/getUnsignedTransaction.ts | 27 +- .../fixedScript/halfSignedLegacyFormat.ts | 55 ++- .../fixedScript/legacyUtxoAuthentication.ts | 108 +++++ .../test/fixedScript/psbtReconstruction.ts | 9 + .../test/fixedScript/singleInputSigning.ts | 15 +- 21 files changed, 1252 insertions(+), 184 deletions(-) create mode 100644 packages/wasm-utxo/test/fixedScript/legacyUtxoAuthentication.ts diff --git a/packages/wasm-utxo/cli/src/psbt/add_input.rs b/packages/wasm-utxo/cli/src/psbt/add_input.rs index 8116069c041..ffedbb8ab89 100644 --- a/packages/wasm-utxo/cli/src/psbt/add_input.rs +++ b/packages/wasm-utxo/cli/src/psbt/add_input.rs @@ -60,6 +60,7 @@ pub fn handle_add_input_command( &descriptor, sequence, non_witness_utxo, + network, ) .map_err(|e| anyhow!(e)) .with_context(|| format!("failed to add input {index}"))?; diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 887a3169d03..7248cdc81fe 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -132,8 +132,19 @@ export type ParseOutputsOptions = { }; export type HydrationUnspent = - | { chain: number; index: number; value: bigint } // wallet input - | { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input + | { + chain: number; + index: number; + value: bigint; + /** Full previous transaction; required for legacy P2SH on non-value-committing coins. */ + prevTx?: Uint8Array; + } + | { + pubkey: Uint8Array; + value: bigint; + /** Full previous transaction; required for replay protection on non-value-committing coins. */ + prevTx?: Uint8Array; + }; export class BitGoPsbt extends PsbtBase @@ -206,8 +217,9 @@ export class BitGoPsbt * (exactly 1 sig per wallet input) is moving to the caller. * * 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). + * with proper wallet metadata (bip32Derivation, scripts, and authenticated + * UTXO data). Legacy P2SH inputs require `prevTx` on non-value-committing + * networks. Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). * * Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH). * Zcash is NOT supported; use ZcashBitGoPsbt.fromNetworkFormat instead. @@ -215,7 +227,8 @@ export class BitGoPsbt * @param txBytesOrTx - Transaction bytes or decoded transaction instance (Bitcoin-like or Dash) * @param network - Network name * @param walletKeys - The wallet's root keys - * @param unspents - Chain, index, and value for each input + * @param unspents - Input metadata; include `prevTx` for legacy P2SH on + * networks whose sighash does not commit the input amount * @param _options - Reserved for future use and signature compatibility with subclasses * @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromNetworkFormat instead) */ @@ -268,7 +281,8 @@ export class BitGoPsbt * @param txBytesOrTx - Transaction bytes or decoded Transaction/DashTransaction * @param network - Network name * @param walletKeys - The wallet's root keys - * @param unspents - Chain, index, and value for each input + * @param unspents - Input metadata; include `prevTx` for legacy P2SH on + * networks whose sighash does not commit the input amount */ static fromNetworkFormat( txBytesOrTx: Uint8Array | Transaction | DashTransaction, diff --git a/packages/wasm-utxo/js/fixedScriptWallet/prevTx.ts b/packages/wasm-utxo/js/fixedScriptWallet/prevTx.ts index c45a1cce512..c775f1b93f8 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/prevTx.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/prevTx.ts @@ -1,7 +1,7 @@ /** * Previous-transaction inclusion policy for fixed-script wallet PSBT inputs. * - * Decides whether a p2sh input requires the full previous transaction + * Decides whether a legacy p2sh input requires the full previous transaction * (PSBT_IN_NON_WITNESS_UTXO) or can be signed from witness_utxo-only. * * This is a pure-JS module (no WASM initialization) so callers can evaluate @@ -10,11 +10,10 @@ import { type CoinName, getMainnet } from "../coinName.js"; /** - * Whether a p2sh input requires the full previous transaction - * (PSBT_IN_NON_WITNESS_UTXO). Callers are expected to have already - * confirmed the input is p2sh (non-segwit) and that the tx format - * includes prevTx (e.g. "psbt", not "psbt-lite"); this predicate only - * answers the coin-level question. + * Whether a legacy p2sh input requires the full previous transaction + * (PSBT_IN_NON_WITNESS_UTXO). Callers can use this coin-level predicate + * to decide whether to fetch prevTx; the library also validates that a + * supplied prevTx matches the input outpoint and spent output. * * Returns false for value-committing coins whose sighash commits the * input amount, making `non_witness_utxo` (full prevTx) cryptographically @@ -22,14 +21,10 @@ import { type CoinName, getMainnet } from "../coinName.js"; * scriptPubKey) suffices: * * - Zcash (`zec`/`tzec`): ZIP-243 transparent sighash commits the amount. - * Including prevTx also crashes wasm-utxo, whose consensus::deserialize - * rejects Zcash overwintered transactions. * - BCH family (`bch`/`bcha`/`bsv`/`btg` + testnets): replay-protected * BIP-143 sighash (SIGHASH_FORKID, the default for the whole family) * commits the 8-byte value as preimage item #6. eCash is `bcha`/`tbcha`. - * For the BCH family, skipping prevTx is an optimization (no DB fetch) - * plus defense-in-depth, with the same fee-validation risk that the - * existing `psbt-lite` path already accepts for all coins. + * For these networks, witness_utxo is sufficient for legacy signing. * * Testnets are normalized via `getMainnet` before the switch. True * otherwise. diff --git a/packages/wasm-utxo/js/testutils/AcidTest.ts b/packages/wasm-utxo/js/testutils/AcidTest.ts index a4233f96794..01a7260719f 100644 --- a/packages/wasm-utxo/js/testutils/AcidTest.ts +++ b/packages/wasm-utxo/js/testutils/AcidTest.ts @@ -18,6 +18,7 @@ import { } from "../fixedScriptWallet/index.js"; import type { CoinName } from "../coinName.js"; import { coinNames, isMainnet } from "../coinName.js"; +import { requiresPrevTxForP2sh } from "../fixedScriptWallet/prevTx.js"; import { getDefaultWalletKeys, getWalletKeysForSeed, getKeyTriple } from "./keys.js"; import type { Triple } from "../triple.js"; @@ -27,6 +28,21 @@ export type SignStage = (typeof signStages)[number]; export const txFormats = ["psbt", "psbt-lite"] as const; export type TxFormat = (typeof txFormats)[number]; +/** Build a synthetic previous transaction and its matching txid. */ +export function createSyntheticPrevTx( + script: Uint8Array, + value: bigint, + vout = 0, +): { txid: string; prevTx: Uint8Array } { + const tx = Transaction.create(); + tx.addInput("0".repeat(64), 0xffffffff); + for (let i = 0; i < vout; i++) { + tx.addOutput(new Uint8Array(0), 0n); + } + tx.addOutput(script, value); + return { txid: tx.getId(), prevTx: tx.toBytes() }; +} + /** * Utility type to make union variants mutually exclusive. * For each variant T in the union, adds `?: never` for all keys from other variants. @@ -270,21 +286,32 @@ export class AcidTest { lockTime: 0, }); - // Build a fake previous transaction for non_witness_utxo (psbt format) - const usePrevTx = this.txFormat === "psbt" && !isZcash; + // Build synthetic previous transactions whose computed txids match their outpoints. + const usePrevTxForPsbt = this.txFormat === "psbt" && !isZcash; const buildPrevTx = ( vout: number, script: Uint8Array, value: bigint, - ): Uint8Array | undefined => { - if (!usePrevTx) return undefined; - const tx = Transaction.create(); - tx.addInput("0".repeat(64), 0xffffffff); - for (let i = 0; i < vout; i++) { - tx.addOutput(new Uint8Array(0), 0n); + ): { txid: string; bytes: Uint8Array } => { + const prevTx = createSyntheticPrevTx(script, value, vout); + return { txid: prevTx.txid, bytes: prevTx.prevTx }; + }; + + const getPrevTx = ( + real: { txid: string; vout: number; prevTx?: Uint8Array } | undefined, + vout: number, + script: Uint8Array, + value: bigint, + isLegacyP2sh: boolean, + ): { txid: string; bytes: Uint8Array } | undefined => { + if (real?.prevTx) return { txid: real.txid, bytes: real.prevTx }; + if ( + usePrevTxForPsbt || + (isLegacyP2sh && requiresPrevTxForP2sh(this.coin)) + ) { + return buildPrevTx(vout, script, value); } - tx.addOutput(script, value); - return tx.toBytes(); + return undefined; }; if (options?.outpoints && options.outpoints.length !== this.inputs.length) { @@ -297,12 +324,6 @@ export class AcidTest { this.inputs.forEach((input, index) => { const walletKeys = input.walletKeys ?? this.rootWalletKeys; const real = options?.outpoints?.[index]; - const outpoint = { - txid: real?.txid ?? "0".repeat(64), - vout: real?.vout ?? index, - value: input.value, - }; - // scriptId variant: caller provides explicit chain + index if (input.scriptId) { const script = outputScript( @@ -311,10 +332,20 @@ export class AcidTest { input.scriptId.index, this.coin, ); + const prevTx = getPrevTx( + real, + real?.vout ?? index, + script, + input.value, + ChainCode.is(input.scriptId.chain) && + ChainCode.scriptType(input.scriptId.chain) === "p2sh", + ); psbt.addWalletInput( { - ...outpoint, - prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64), + vout: real?.vout ?? index, + value: input.value, + prevTx: prevTx?.bytes, }, walletKeys, { scriptId: input.scriptId, signPath: { signer: "user", cosigner: "bitgo" } }, @@ -327,10 +358,13 @@ export class AcidTest { if (scriptType === "p2shP2pk") { const ecpair = ECPair.fromPublicKey(this.getReplayProtectionKey().publicKey); const script = p2shP2pkOutputScript(ecpair.publicKey); + const prevTx = getPrevTx(real, real?.vout ?? index, script, input.value, true); psbt.addReplayProtectionInput( { - ...outpoint, - prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64), + vout: real?.vout ?? index, + value: input.value, + prevTx: prevTx?.bytes, }, ecpair, ); @@ -346,11 +380,14 @@ export class AcidTest { ? { signer: "user", cosigner: "backup" } : { signer: "user", cosigner: "bitgo" }; const script = outputScript(walletKeys, scriptId.chain, scriptId.index, this.coin); + const prevTx = getPrevTx(real, real?.vout ?? index, script, input.value, scriptType === "p2sh"); psbt.addWalletInput( { - ...outpoint, - prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64), + vout: real?.vout ?? index, + value: input.value, + prevTx: prevTx?.bytes, }, walletKeys, { scriptId, signPath }, diff --git a/packages/wasm-utxo/src/dash/transaction.rs b/packages/wasm-utxo/src/dash/transaction.rs index b70177d8835..a529efbe4e1 100644 --- a/packages/wasm-utxo/src/dash/transaction.rs +++ b/packages/wasm-utxo/src/dash/transaction.rs @@ -6,7 +6,7 @@ //! - if type != 0: varint payload_size + payload bytes use miniscript::bitcoin::consensus::{Decodable, Encodable}; -use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt}; +use miniscript::bitcoin::{hashes::{sha256d, Hash}, Transaction, TxIn, TxOut, Txid, VarInt}; /// Parsed Dash transaction fields needed for round-tripping. #[derive(Debug, Clone)] @@ -75,6 +75,11 @@ pub fn decode_dash_transaction_parts(bytes: &[u8]) -> Result Txid { + Txid::from_raw_hash(sha256d::Hash::hash(bytes)) +} + /// Encode a Dash transaction back to bytes, including tx_type and extra payload. pub fn encode_dash_transaction_parts(parts: &DashTransactionParts) -> Result, String> { let mut bytes = Vec::new(); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/dash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/dash_psbt.rs index c5e345f0009..0b8acc2a3e2 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/dash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/dash_psbt.rs @@ -296,12 +296,16 @@ impl DashBitGoPsbt { )); } - Ok(DashBitGoPsbt { + let dash_psbt = DashBitGoPsbt { psbt, network, unsigned_tx_bytes, non_witness_utxo_bytes_by_input, - }) + }; + dash_psbt + .validate_prevouts(false) + .map_err(super::DeserializeError::Network)?; + Ok(dash_psbt) } pub fn deserialize( @@ -311,6 +315,70 @@ impl DashBitGoPsbt { Self::decode_with_dash_tx(bytes, network) } + pub(crate) fn validate_prevouts(&self, require_all_utxos: bool) -> Result<(), String> { + use super::psbt_wallet_input::get_output_script_and_value_for_network; + + if self.psbt.unsigned_tx.input.len() != self.psbt.inputs.len() { + return Err(format!( + "Invalid PSBT: transaction has {} inputs but PSBT has {} input maps", + self.psbt.unsigned_tx.input.len(), + self.psbt.inputs.len() + )); + } + + for (index, (tx_input, psbt_input)) in self + .psbt + .unsigned_tx + .input + .iter() + .zip(self.psbt.inputs.iter()) + .enumerate() + { + if let Some(raw_prev_tx) = self + .non_witness_utxo_bytes_by_input + .get(index) + .and_then(Option::as_deref) + { + let parts = crate::dash::transaction::decode_dash_transaction_parts(raw_prev_tx) + .map_err(|error| format!("Input {}: {}", index, error))?; + let actual_txid = crate::dash::transaction::compute_dash_txid(raw_prev_tx); + if actual_txid != tx_input.previous_output.txid { + return Err(format!( + "Input {}: non_witness_utxo txid {} does not match prevout txid {}", + index, actual_txid, tx_input.previous_output.txid + )); + } + if psbt_input.non_witness_utxo.as_ref() != Some(&parts.transaction) { + return Err(format!( + "Input {}: decoded non_witness_utxo differs from preserved Dash transaction", + index + )); + } + } else if let Some(prev_tx) = &psbt_input.non_witness_utxo { + let actual_txid = prev_tx.compute_txid(); + if actual_txid != tx_input.previous_output.txid { + return Err(format!( + "Input {}: non_witness_utxo txid {} does not match prevout txid {}", + index, actual_txid, tx_input.previous_output.txid + )); + } + } + + match get_output_script_and_value_for_network( + psbt_input, + tx_input.previous_output, + self.network, + ) { + Ok(_) => {} + Err(super::psbt_wallet_input::OutputScriptError::NoUtxoFields) + if !require_all_utxos => {} + Err(error) => return Err(format!("Input {}: {}", index, error)), + } + } + + Ok(()) + } + /// Serialize the Dash PSBT back to bytes, preserving original Dash transaction bytes. pub fn serialize(&self) -> Result, super::DeserializeError> { let bitcoin_psbt_bytes = self.psbt.serialize(); @@ -508,8 +576,9 @@ impl DashBitGoPsbt { mod tests { use super::*; use miniscript::bitcoin::consensus::Encodable; + use miniscript::bitcoin::hashes::{sha256d, Hash}; use miniscript::bitcoin::{ - absolute::LockTime, transaction::Version, OutPoint, ScriptBuf, TxIn, TxOut, + absolute::LockTime, transaction::Version, OutPoint, ScriptBuf, TxIn, TxOut, Txid, }; use serde::Deserialize; @@ -653,7 +722,10 @@ mod tests { version: Version(2), lock_time: LockTime::from_consensus(0), input: vec![TxIn { - previous_output: OutPoint::null(), + previous_output: OutPoint { + txid: crate::dash::transaction::compute_dash_txid(&dash_prev_tx_bytes), + vout: 0, + }, script_sig: ScriptBuf::new(), sequence: miniscript::bitcoin::transaction::Sequence(0xFFFF_FFFE), witness: miniscript::bitcoin::Witness::default(), @@ -664,8 +736,9 @@ mod tests { }], }; - let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).expect("psbt from unsigned tx"); - psbt.inputs[0].non_witness_utxo = Some(bitcoin_prev_tx); + let mut psbt = + Psbt::from_unsigned_tx(unsigned_tx.clone()).expect("psbt from unsigned tx"); + psbt.inputs[0].non_witness_utxo = Some(bitcoin_prev_tx.clone()); let bitcoin_psbt_bytes = psbt.serialize(); let patched_psbt_bytes = @@ -679,5 +752,20 @@ mod tests { let serialized = dash_psbt.serialize().expect("serialize"); let extracted = extract_first_input_non_witness_utxo(&serialized); assert_eq!(extracted, dash_prev_tx_bytes); + + let mut mismatched_unsigned_tx = unsigned_tx; + mismatched_unsigned_tx.input[0].previous_output.txid = + Txid::from_raw_hash(sha256d::Hash::hash(b"wrong Dash prevout")); + let mut mismatched_psbt = + Psbt::from_unsigned_tx(mismatched_unsigned_tx).expect("valid unsigned tx"); + mismatched_psbt.inputs[0].non_witness_utxo = Some(bitcoin_prev_tx); + let mismatched_bytes = replace_first_input_non_witness_utxo( + &mismatched_psbt.serialize(), + &dash_prev_tx_bytes, + ); + assert!( + DashBitGoPsbt::deserialize(&mismatched_bytes, crate::Network::Dash).is_err(), + "Dash PSBT must reject a source transaction whose txid differs from the prevout" + ); } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs index dc9464decec..ebb0d419321 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs @@ -207,12 +207,13 @@ impl FixedScriptInput { } => { use miniscript::bitcoin::sighash::SighashCacheZcashExt; let prevout = psbt.unsigned_tx.input[index].previous_output; - let value = - crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::get_output_script_and_value( - &psbt.inputs[index], prevout, - ) - .map(|(_, v)| v) - .unwrap_or(miniscript::bitcoin::Amount::ZERO); + let value = crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::get_output_script_and_value_for_network( + &psbt.inputs[index], + prevout, + crate::Network::Zcash, + ) + .map(|(_, value)| value) + .map_err(|error| format!("Input {}: invalid UTXO data: {}", index, error))?; let script = psbt.inputs[index] .witness_script .as_ref() 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 a47b1489ec4..a5da25c4025 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 @@ -119,12 +119,33 @@ pub use psbt_wallet_output::ParsedOutput; pub enum HydrationUnspentInput { /// A regular wallet input with derivation chain, index, and value. Wallet(ScriptIdWithValue), + /// A wallet input with its full previous transaction for legacy P2SH validation. + WalletWithPrevTx { + unspent: ScriptIdWithValue, + prev_tx: Vec, + }, /// A P2SH-P2PK replay protection input. The caller provides the expected pubkey so it can be /// validated against the redeemScript embedded in the legacy transaction. ReplayProtection { pubkey: miniscript::bitcoin::CompressedPublicKey, value: u64, }, + /// A replay protection input with its full previous transaction for legacy validation. + ReplayProtectionWithPrevTx { + pubkey: miniscript::bitcoin::CompressedPublicKey, + value: u64, + prev_tx: Vec, + }, +} + +impl HydrationUnspentInput { + fn prev_tx(&self) -> Option<&[u8]> { + match self { + Self::WalletWithPrevTx { prev_tx, .. } + | Self::ReplayProtectionWithPrevTx { prev_tx, .. } => Some(prev_tx), + Self::Wallet(_) | Self::ReplayProtection { .. } => None, + } + } } /// Parsed transaction with wallet information @@ -145,6 +166,8 @@ pub enum ParseTransactionError { index: usize, error: psbt_wallet_input::ParseInputError, }, + /// Input UTXO metadata failed authentication + InputUtxoValidation(String), /// Input value overflow when adding to total InputValueOverflow { index: usize }, /// Failed to parse output @@ -168,6 +191,9 @@ impl std::fmt::Display for ParseTransactionError { ParseTransactionError::Input { index, error } => { write!(f, "Input {}: {}", index, error) } + ParseTransactionError::InputUtxoValidation(error) => { + write!(f, "Input UTXO validation failed: {}", error) + } ParseTransactionError::InputValueOverflow { index } => { write!(f, "Input {}: value overflow", index) } @@ -365,6 +391,23 @@ pub enum ExtractFeePolicy { Limited(FeeRate), } +fn decode_prev_tx( + tx_bytes: &[u8], + network: Network, +) -> Result<(miniscript::bitcoin::Transaction, Txid), String> { + if network.mainnet() == Network::Dash { + let parts = crate::dash::transaction::decode_dash_transaction_parts(tx_bytes)?; + let txid = crate::dash::transaction::compute_dash_txid(tx_bytes); + Ok((parts.transaction, txid)) + } else { + let tx: miniscript::bitcoin::Transaction = + miniscript::bitcoin::consensus::deserialize(tx_bytes) + .map_err(|e| format!("Failed to deserialize previous transaction: {}", e))?; + let txid = tx.compute_txid(); + Ok((tx, txid)) + } +} + /// Extract a `Transaction` from a rust-bitcoin `Psbt` applying an /// [`ExtractFeePolicy`]. Shared by the `BitcoinLike` and `Dash` branches, /// which both hold an inner `Psbt`. @@ -391,6 +434,8 @@ impl BitGoPsbt { // Zcash uses overwintered transaction format which is not compatible // with standard Bitcoin transaction deserialization let zcash_psbt = ZcashBitGoPsbt::deserialize(psbt_bytes, network)?; + psbt_wallet_input::validate_psbt_utxo_fields(&zcash_psbt.psbt, network) + .map_err(DeserializeError::Network)?; Ok(BitGoPsbt::Zcash(zcash_psbt, network)) } @@ -420,10 +465,56 @@ impl BitGoPsbt { | Network::LitecoinTestnet | Network::Pearl | Network::PearlTestnet - | Network::PearlRegtest => Ok(BitGoPsbt::BitcoinLike( - Psbt::deserialize(psbt_bytes)?, - network, - )), + | Network::PearlRegtest => { + let psbt = Psbt::deserialize(psbt_bytes)?; + psbt_wallet_input::validate_psbt_utxo_fields(&psbt, network) + .map_err(DeserializeError::Network)?; + Ok(BitGoPsbt::BitcoinLike(psbt, network)) + }, + } + } + + fn validate_input_utxos(&self) -> Result<(), String> { + match self { + BitGoPsbt::BitcoinLike(psbt, network) => { + psbt_wallet_input::validate_psbt_utxos(psbt, *network) + } + BitGoPsbt::Dash(dash_psbt, _network) => dash_psbt.validate_prevouts(true), + BitGoPsbt::Zcash(zcash_psbt, network) => { + psbt_wallet_input::validate_psbt_utxos(&zcash_psbt.psbt, *network) + } + } + } + + fn insert_dash_prev_tx_bytes(&mut self, index: usize, prev_tx: Option>) { + if let BitGoPsbt::Dash(dash_psbt, _) = self { + let input_count = dash_psbt.psbt.inputs.len(); + let prev_txs = &mut dash_psbt.non_witness_utxo_bytes_by_input; + prev_txs.resize_with(index, || None); + prev_txs.insert(index, prev_tx); + prev_txs.truncate(input_count); + prev_txs.resize_with(input_count, || None); + } + } + + fn preserve_dash_prev_txs_from_hydration(&mut self, unspents: &[HydrationUnspentInput]) { + if let BitGoPsbt::Dash(dash_psbt, _) = self { + dash_psbt.non_witness_utxo_bytes_by_input = unspents + .iter() + .enumerate() + .map(|(index, unspent)| { + if dash_psbt + .psbt + .inputs + .get(index) + .is_some_and(|input| input.non_witness_utxo.is_some()) + { + unspent.prev_tx().map(ToOwned::to_owned) + } else { + None + } + }) + .collect(); } } @@ -622,7 +713,14 @@ impl BitGoPsbt { for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { match unspent { - HydrationUnspentInput::Wallet(sv) => { + HydrationUnspentInput::Wallet(sv) + | HydrationUnspentInput::WalletWithPrevTx { unspent: sv, .. } => { + let prev_tx = match unspent { + HydrationUnspentInput::WalletWithPrevTx { prev_tx, .. } => { + Some(prev_tx.as_slice()) + } + _ => None, + }; let script_id = ScriptId { chain: sv.chain, index: sv.index, @@ -639,7 +737,7 @@ impl BitGoPsbt { psbt_wallet_input::WalletInputOptions { sign_path: None, sequence: Some(tx_in.sequence.0), - prev_tx: None, + prev_tx, }, ) .map_err(|e| format!("Input {}: {}", i, e))?; @@ -647,7 +745,18 @@ impl BitGoPsbt { HydrationUnspentInput::ReplayProtection { pubkey: expected_pubkey, value, + } + | HydrationUnspentInput::ReplayProtectionWithPrevTx { + pubkey: expected_pubkey, + value, + .. } => { + let prev_tx = match unspent { + HydrationUnspentInput::ReplayProtectionWithPrevTx { prev_tx, .. } => { + Some(prev_tx.as_slice()) + } + _ => None, + }; let parsed = FixedScriptInput::from_txin(tx_in) .map_err(|e| format!("Input {}: {}", i, e))?; let pubkey = match &parsed { @@ -675,7 +784,7 @@ impl BitGoPsbt { *value, ReplayProtectionOptions { sequence: Some(tx_in.sequence.0), - prev_tx: None, + prev_tx, sighash_type: None, }, ) @@ -753,6 +862,7 @@ impl BitGoPsbt { Some(tx.lock_time.to_consensus_u32()), ); Self::hydrate_psbt(psbt.psbt_mut(), network, wallet_keys, tx, unspents)?; + psbt.preserve_dash_prev_txs_from_hydration(unspents); Ok(psbt) } @@ -795,6 +905,10 @@ impl BitGoPsbt { ) -> Result { use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; + let network = self.network(); + let prev_tx_bytes = prev_tx + .as_ref() + .map(miniscript::bitcoin::consensus::serialize); let tx_in = TxIn { previous_output: OutPoint { txid, vout }, script_sig: miniscript::bitcoin::ScriptBuf::new(), @@ -810,7 +924,33 @@ impl BitGoPsbt { ..Default::default() }; - crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input) + if network.mainnet() == Network::Dash { + if let Some(prev_tx) = &psbt_input.non_witness_utxo { + let actual = prev_tx.compute_txid(); + if actual != tx_in.previous_output.txid { + return Err(format!( + "non_witness_utxo txid {} does not match prevout txid {}", + actual, tx_in.previous_output.txid + )); + } + } + } + psbt_wallet_input::get_output_script_and_value_for_network( + &psbt_input, + tx_in.previous_output, + network, + ) + .map_err(|error| error.to_string())?; + + crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input)?; + let stored_prev_tx = self + .psbt() + .inputs + .get(index) + .filter(|input| input.non_witness_utxo.is_some()) + .and(prev_tx_bytes); + self.insert_dash_prev_tx_bytes(index, stored_prev_tx); + Ok(index) } pub fn add_input( @@ -821,10 +961,9 @@ impl BitGoPsbt { script: miniscript::bitcoin::ScriptBuf, sequence: Option, prev_tx: Option, - ) -> usize { + ) -> Result { let index = self.psbt().inputs.len(); self.add_input_at_index(index, txid, vout, value, script, sequence, prev_tx) - .expect("insert at len should never fail") } /// Add a replay protection input (p2shP2pk) to the PSBT @@ -853,11 +992,8 @@ impl BitGoPsbt { options: ReplayProtectionOptions, ) -> Result<(), String> { use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk; - use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::psbt::{Input, PsbtSighashType}; - use miniscript::bitcoin::{ - transaction::Sequence, Amount, OutPoint, Transaction, TxIn, TxOut, - }; + use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; let script = ScriptP2shP2pk::new(pubkey); let output_script = script.output_script(); @@ -886,10 +1022,41 @@ impl BitGoPsbt { ..Default::default() }; - if let Some(tx_bytes) = options.prev_tx { - let tx = Transaction::consensus_decode(&mut &tx_bytes[..]) - .expect("Failed to decode prev_tx"); - psbt_input.non_witness_utxo = Some(tx); + let prev_tx = options + .prev_tx + .map(|tx_bytes| { + let (tx, actual_txid) = decode_prev_tx(tx_bytes, network)?; + if actual_txid != txid { + return Err(format!( + "non_witness_utxo txid {} does not match prevout txid {}", + actual_txid, txid + )); + } + let output = tx + .output + .get(vout as usize) + .ok_or_else(|| format!("Previous transaction output {} is out of bounds", vout))?; + if output.script_pubkey != output_script + || output.value != Amount::from_sat(value) + { + return Err( + "previous transaction output does not match the supplied script and value" + .to_string(), + ); + } + Ok(tx) + }) + .transpose()?; + + if network.requires_prev_tx_for_legacy_input() && prev_tx.is_none() { + return Err(format!( + "non_witness_utxo is required for replay protection inputs on network {}", + network + )); + } + + if let Some(prev_tx) = prev_tx { + psbt_input.non_witness_utxo = Some(prev_tx); } else { psbt_input.witness_utxo = Some(TxOut { value: Amount::from_sat(value), @@ -897,6 +1064,13 @@ impl BitGoPsbt { }); } + psbt_wallet_input::get_output_script_and_value_for_network( + &psbt_input, + tx_in.previous_output, + network, + ) + .map_err(|error| error.to_string())?; + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input).map(|_| ()) } @@ -910,6 +1084,7 @@ impl BitGoPsbt { options: ReplayProtectionOptions, ) -> Result { let network = self.network(); + let prev_tx_bytes = options.prev_tx.map(ToOwned::to_owned); Self::add_replay_protection_input_to_psbt( self.psbt_mut(), index, @@ -920,6 +1095,13 @@ impl BitGoPsbt { value, options, )?; + let stored_prev_tx = self + .psbt() + .inputs + .get(index) + .filter(|input| input.non_witness_utxo.is_some()) + .and(prev_tx_bytes); + self.insert_dash_prev_tx_bytes(index, stored_prev_tx); Ok(index) } @@ -930,10 +1112,9 @@ impl BitGoPsbt { vout: u32, value: u64, options: ReplayProtectionOptions, - ) -> usize { + ) -> Result { let index = self.psbt().inputs.len(); self.add_replay_protection_input_at_index(index, pubkey, txid, vout, value, options) - .expect("insert at len should never fail") } /// Add an output to the PSBT @@ -1054,12 +1235,48 @@ impl BitGoPsbt { let mut psbt_input = Input::default(); let is_segwit = chain_enum.script_type != OutputScriptType::P2sh; + let prev_tx = options + .prev_tx + .map(|tx_bytes| { + let (tx, actual_txid) = decode_prev_tx(tx_bytes, network)?; + if actual_txid != txid { + return Err(format!( + "non_witness_utxo txid {} does not match prevout txid {}", + actual_txid, txid + )); + } + let output = tx + .output + .get(vout as usize) + .ok_or_else(|| format!("Previous transaction output {} is out of bounds", vout))?; + if output.script_pubkey.as_script() != output_script.as_script() + || output.value != Amount::from_sat(value) + { + return Err( + "previous transaction output does not match the supplied script and value" + .to_string(), + ); + } + Ok(tx) + }) + .transpose()?; - if let (false, Some(tx_bytes)) = (is_segwit, options.prev_tx) { - psbt_input.non_witness_utxo = Some( - miniscript::bitcoin::consensus::deserialize(tx_bytes) - .map_err(|e| format!("Failed to deserialize previous transaction: {}", e))?, - ); + if !is_segwit && network.requires_prev_tx_for_legacy_input() && prev_tx.is_none() { + return Err(format!( + "non_witness_utxo is required for legacy P2SH inputs on network {}", + network + )); + } + + if !is_segwit { + if let Some(prev_tx) = prev_tx { + psbt_input.non_witness_utxo = Some(prev_tx); + } else { + psbt_input.witness_utxo = Some(TxOut { + value: Amount::from_sat(value), + script_pubkey: output_script.clone(), + }); + } } else { psbt_input.witness_utxo = Some(TxOut { value: Amount::from_sat(value), @@ -1173,6 +1390,7 @@ impl BitGoPsbt { options: WalletInputOptions, ) -> Result { let network = self.network(); + let prev_tx_bytes = options.prev_tx.map(ToOwned::to_owned); Self::add_wallet_input_to_psbt( self.psbt_mut(), index, @@ -1184,6 +1402,13 @@ impl BitGoPsbt { script_id, options, )?; + let stored_prev_tx = self + .psbt() + .inputs + .get(index) + .filter(|input| input.non_witness_utxo.is_some()) + .and(prev_tx_bytes); + self.insert_dash_prev_tx_bytes(index, stored_prev_tx); Ok(index) } @@ -1500,6 +1725,7 @@ impl BitGoPsbt { pub fn extract_tx_with_fee_policy(self, policy: ExtractFeePolicy) -> Result, String> { use miniscript::bitcoin::consensus::serialize; + self.validate_input_utxos()?; match self { BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt .extract_tx_with_fee_policy(policy) @@ -1530,6 +1756,7 @@ impl BitGoPsbt { self, policy: ExtractFeePolicy, ) -> Result { + self.validate_input_utxos()?; match self { BitGoPsbt::BitcoinLike(psbt, _) => extract_inner_with_fee_policy(psbt, policy), _ => Err("extract_bitcoin_tx only supported for BitcoinLike networks".to_string()), @@ -1556,6 +1783,7 @@ impl BitGoPsbt { policy: ExtractFeePolicy, ) -> Result { use miniscript::bitcoin::consensus::serialize; + self.validate_input_utxos()?; match self { BitGoPsbt::Dash(dash_psbt, _) => { let tx = extract_inner_with_fee_policy(dash_psbt.psbt, policy)?; @@ -1912,6 +2140,7 @@ impl BitGoPsbt { if matches!(self, BitGoPsbt::Zcash(_, _)) { return Err("MuSig2 not supported for Zcash".to_string()); } + self.validate_input_utxos()?; let psbt = self.psbt_mut(); if input_index >= psbt.inputs.len() { @@ -2046,20 +2275,19 @@ impl BitGoPsbt { self.ensure_not_ironwood_v6()?; use miniscript::bitcoin::PublicKey; - // Get network before mutable borrow let network = self.network(); let is_testnet = network.is_testnet(); - let psbt = self.psbt_mut(); - - // Check bounds - if input_index >= psbt.inputs.len() { + if input_index >= self.psbt().inputs.len() { return Err(format!( "Input index {} out of bounds (total inputs: {})", input_index, - psbt.inputs.len() + self.psbt().inputs.len() )); } + self.validate_input_utxos()?; + + let psbt = self.psbt_mut(); // Check if this is a MuSig2 input if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { @@ -2449,6 +2677,16 @@ impl BitGoPsbt { C: secp256k1::Signing + secp256k1::Verification, K: miniscript::bitcoin::psbt::GetKey, { + if self.validate_input_utxos().is_err() { + return Err(( + Default::default(), + std::collections::BTreeMap::from_iter([( + 0, + miniscript::bitcoin::psbt::SignError::UnknownOutputType, + )]), + )); + } + match self { BitGoPsbt::BitcoinLike(ref mut psbt, network) => { // Check if this network uses SIGHASH_FORKID @@ -2545,6 +2783,7 @@ impl BitGoPsbt { xpriv: &miniscript::bitcoin::bip32::Xpriv, ) -> Result { self.ensure_not_ironwood_v6()?; + self.validate_input_utxos()?; let secp = secp256k1::Secp256k1::new(); // Sign all inputs - miniscript handles this efficiently @@ -2611,6 +2850,7 @@ impl BitGoPsbt { xpriv: &miniscript::bitcoin::bip32::Xpriv, ) -> Result<(), String> { self.ensure_not_ironwood_v6()?; + self.validate_input_utxos()?; let psbt = self.psbt(); if input_index >= psbt.inputs.len() { return Err(format!( @@ -2942,9 +3182,11 @@ impl BitGoPsbt { // Get input value for sighash computation let input = &psbt.inputs[input_index]; let prevout = psbt.unsigned_tx.input[input_index].previous_output; - let value = psbt_wallet_input::get_output_script_and_value(input, prevout) - .map(|(_, v)| v) - .unwrap_or(miniscript::bitcoin::Amount::ZERO); + let value = psbt_wallet_input::get_output_script_and_value_for_network( + input, prevout, network, + ) + .map(|(_, value)| value) + .map_err(|error| format!("Failed to get input UTXO: {}", error))?; let fork_id = network.sighash_fork_id(); @@ -3027,9 +3269,13 @@ impl BitGoPsbt { // Get input value for sighash computation let input = &psbt.inputs[input_index]; let prevout = psbt.unsigned_tx.input[input_index].previous_output; - let value = psbt_wallet_input::get_output_script_and_value(input, prevout) - .map(|(_, v)| v) - .unwrap_or(miniscript::bitcoin::Amount::ZERO); + let value = psbt_wallet_input::get_output_script_and_value_for_network( + input, + prevout, + Network::Zcash, + ) + .map(|(_, value)| value) + .map_err(|error| format!("Failed to get input UTXO: {}", error))?; // Compute ZIP-243 sighash let mut cache = SighashCache::new(&psbt.unsigned_tx); @@ -3097,8 +3343,10 @@ impl BitGoPsbt { let prevout = psbt.unsigned_tx.input[input_index].previous_output; // Get output script and value from input - let (output_script, value) = psbt_wallet_input::get_output_script_and_value(input, prevout) - .map_err(|e| format!("Failed to get output script: {}", e))?; + let (output_script, value) = psbt_wallet_input::get_output_script_and_value_for_network( + input, prevout, network, + ) + .map_err(|e| format!("Failed to get output script: {}", e))?; // Verify this is a replay protection input if !replay_protection.is_replay_protection_input(output_script) { @@ -3558,6 +3806,8 @@ impl BitGoPsbt { replay_protection: &crate::fixed_script_wallet::ReplayProtection, paygo_pubkeys: &[secp256k1::PublicKey], ) -> Result { + self.validate_input_utxos() + .map_err(ParseTransactionError::InputUtxoValidation)?; let psbt = self.psbt(); // Parse inputs and outputs @@ -3676,9 +3926,10 @@ pub fn to_wallet_keys( let wallet_keys = RootWalletKeys::new(permuted); let all_match = wallet_inputs.iter().all(|(tx_input, psbt_input)| { - let output_script = psbt_wallet_input::get_output_script_and_value( + let output_script = psbt_wallet_input::get_output_script_and_value_for_network( psbt_input, tx_input.previous_output, + network, ); match output_script { Ok((script, _value)) => crate::fixed_script_wallet::WalletOutputScript::from_psbt( @@ -4811,17 +5062,42 @@ mod tests { } let chain = u32::from(*components[components.len() - 2]); let index = u32::from(*components[components.len() - 1]); + let prevout = psbt.unsigned_tx.input[i].previous_output; + let prev_tx = match &bitgo_psbt { + BitGoPsbt::Dash(dash_psbt, _) => dash_psbt + .non_witness_utxo_bytes_by_input + .get(i) + .cloned() + .flatten(), + _ => input + .non_witness_utxo + .as_ref() + .map(miniscript::bitcoin::consensus::serialize), + }; let value = input .witness_utxo .as_ref() - .ok_or_else(|| format!("Input {} has no witnessUtxo", i))? - .value - .to_sat(); - Ok(HydrationUnspentInput::Wallet(ScriptIdWithValue { + .map(|output| output.value.to_sat()) + .or_else(|| { + input + .non_witness_utxo + .as_ref() + .and_then(|tx| tx.output.get(prevout.vout as usize)) + .map(|output| output.value.to_sat()) + }) + .ok_or_else(|| format!("Input {} has no UTXO value", i))?; + let unspent = ScriptIdWithValue { chain, index, value, - })) + }; + Ok(match prev_tx { + Some(prev_tx) => HydrationUnspentInput::WalletWithPrevTx { + unspent, + prev_tx, + }, + None => HydrationUnspentInput::Wallet(unspent), + }) }) .collect::, String>>()?; @@ -5487,7 +5763,8 @@ mod tests { sighash_type: orig_psbt_input.sighash_type, prev_tx: prev_tx.as_deref(), }, - ); + ) + .expect("add replay protection input"); } } @@ -5720,7 +5997,13 @@ mod tests { use miniscript::bitcoin::hashes::{sha256, Hash}; use miniscript::bitcoin::psbt::Psbt as BitcoinPsbt; use miniscript::bitcoin::secp256k1::Secp256k1; - use miniscript::bitcoin::{Network as BitcoinNetwork, Txid}; + use crate::fixed_script_wallet::wallet_scripts::{chain_index_path, OutputScriptType, WalletScripts}; + use miniscript::bitcoin::absolute::LockTime; + use miniscript::bitcoin::transaction::{Sequence, Version}; + use miniscript::bitcoin::{ + Amount, Network as BitcoinNetwork, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, + Witness, + }; use std::str::FromStr; let wallet_keys = @@ -5731,17 +6014,42 @@ mod tests { // Large output amount (1e19) should fit in u64 and round-trip. let value: u64 = 10_000_000_000_000_000_000; - let txid = Txid::all_zeros(); + let output_script = WalletScripts::from_wallet_keys( + &wallet_keys, + OutputScriptType::P2sh, + &chain_index_path(0, 0), + &Network::Dogecoin.output_script_support(), + ) + .expect("build source output script") + .output_script(); + let prev_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(value), + script_pubkey: output_script, + }], + }; + let prev_tx_bytes = serialize(&prev_tx); let vout = 0u32; let script_id = ScriptId { chain: 0, index: 0 }; psbt.add_wallet_input( - txid, + prev_tx.compute_txid(), vout, value, &wallet_keys, script_id, - WalletInputOptions::default(), + WalletInputOptions { + prev_tx: Some(&prev_tx_bytes), + ..Default::default() + }, ) .expect("add_wallet_input"); 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 3bc9f6b6600..71e4bcd1491 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 @@ -1,7 +1,7 @@ use miniscript::bitcoin::bip32::DerivationPath; use miniscript::bitcoin::psbt::{Input, Psbt}; use miniscript::bitcoin::secp256k1::{self, PublicKey}; -use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey}; +use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, Txid, XOnlyPublicKey}; use crate::address::is_p2mr; use crate::bitcoin::bip32::KeySource; @@ -404,6 +404,9 @@ pub fn get_derivation_paths(input: &Input) -> Vec<&DerivationPath> { #[derive(Debug, strum::IntoStaticStr)] pub enum OutputScriptError { OutputIndexOutOfBounds { vout: u32 }, + NonWitnessUtxoTxidMismatch { expected: Txid, actual: Txid }, + WitnessUtxoMismatch, + MissingNonWitnessUtxoForLegacyInput, NoUtxoFields, } @@ -413,6 +416,19 @@ impl std::fmt::Display for OutputScriptError { OutputScriptError::OutputIndexOutOfBounds { vout } => { write!(f, "Output index {} out of bounds", vout) } + OutputScriptError::NonWitnessUtxoTxidMismatch { expected, actual } => write!( + f, + "non_witness_utxo txid {} does not match prevout txid {}", + actual, expected + ), + OutputScriptError::WitnessUtxoMismatch => write!( + f, + "witness_utxo does not match the referenced non_witness_utxo output" + ), + OutputScriptError::MissingNonWitnessUtxoForLegacyInput => write!( + f, + "non_witness_utxo is required for legacy P2SH inputs on this network" + ), OutputScriptError::NoUtxoFields => { write!(f, "Neither witness_utxo nor non_witness_utxo is set") } @@ -564,9 +580,12 @@ impl ParsedInput { replay_protection: &ReplayProtection, network: Network, ) -> Result { - let (output_script, value) = - get_output_script_and_value(psbt_input, tx_input.previous_output) - .map_err(ParseInputError::Utxo)?; + let (output_script, value) = get_output_script_and_value_for_network( + psbt_input, + tx_input.previous_output, + network, + ) + .map_err(ParseInputError::Utxo)?; let is_replay_protection = replay_protection.is_replay_protection_input(output_script); @@ -667,31 +686,155 @@ impl crate::error::WasmErrorCode for ParseInputError { } } -/// Get both output script and value from a PSBT input +/// Get both output script and value from a PSBT input, authenticating any full prev tx. pub fn get_output_script_and_value( input: &Input, prevout: OutPoint, ) -> Result<(&ScriptBuf, miniscript::bitcoin::Amount), OutputScriptError> { - match (&input.witness_utxo, &input.non_witness_utxo) { - // Prefer witness_utxo when both are set (common in some wallet implementations) - (Some(witness_utxo), _) => Ok((&witness_utxo.script_pubkey, witness_utxo.value)), - (None, Some(non_witness_utxo)) => { - let output = non_witness_utxo + get_output_script_and_value_inner(input, prevout, true) +} + +fn is_p2sh_witness_spend(input: &Input, output_script: &ScriptBuf) -> bool { + let matches_output = |redeem_script: &ScriptBuf| { + (redeem_script.is_p2wpkh() || redeem_script.is_p2wsh()) + && redeem_script.to_p2sh() == *output_script + }; + + if input + .redeem_script + .as_ref() + .is_some_and(matches_output) + { + return true; + } + + let Some(final_script_sig) = input.final_script_sig.as_ref() else { + return false; + }; + let mut instructions = final_script_sig.instructions(); + let Some(Ok(miniscript::bitcoin::script::Instruction::PushBytes(redeem_bytes))) = + instructions.next() + else { + return false; + }; + if instructions.next().is_some() { + return false; + } + + let redeem_script = ScriptBuf::from_bytes(redeem_bytes.as_bytes().to_vec()); + matches_output(&redeem_script) +} + +/// Get an input's spent output using its network's transaction ID rules. +/// Dash special transactions and Zcash transparent transactions use txid formats +/// that are not represented by `bitcoin::Transaction::compute_txid`. +pub fn get_output_script_and_value_for_network( + input: &Input, + prevout: OutPoint, + network: Network, +) -> Result<(&ScriptBuf, miniscript::bitcoin::Amount), OutputScriptError> { + let validates_txid = !matches!(network.mainnet(), Network::Dash | Network::Zcash); + let (script, value) = get_output_script_and_value_inner(input, prevout, validates_txid)?; + + if network.requires_prev_tx_for_legacy_input() + && script.is_p2sh() + && !is_p2sh_witness_spend(input, script) + && input.non_witness_utxo.is_none() + { + return Err(OutputScriptError::MissingNonWitnessUtxoForLegacyInput); + } + + Ok((script, value)) +} + +fn get_output_script_and_value_inner( + input: &Input, + prevout: OutPoint, + validates_txid: bool, +) -> Result<(&ScriptBuf, miniscript::bitcoin::Amount), OutputScriptError> { + let non_witness_output = if let Some(non_witness_utxo) = &input.non_witness_utxo { + if validates_txid { + let actual = non_witness_utxo.compute_txid(); + if actual != prevout.txid { + return Err(OutputScriptError::NonWitnessUtxoTxidMismatch { + expected: prevout.txid, + actual, + }); + } + } + Some( + non_witness_utxo .output .get(prevout.vout as usize) - .ok_or(OutputScriptError::OutputIndexOutOfBounds { vout: prevout.vout })?; - Ok((&output.script_pubkey, output.value)) + .ok_or(OutputScriptError::OutputIndexOutOfBounds { vout: prevout.vout })?, + ) + } else { + None + }; + + match (&input.witness_utxo, non_witness_output) { + (Some(witness_utxo), Some(non_witness_output)) => { + if witness_utxo != non_witness_output { + return Err(OutputScriptError::WitnessUtxoMismatch); + } + Ok((&non_witness_output.script_pubkey, non_witness_output.value)) } + (Some(witness_utxo), None) => Ok((&witness_utxo.script_pubkey, witness_utxo.value)), + (None, Some(output)) => Ok((&output.script_pubkey, output.value)), (None, None) => Err(OutputScriptError::NoUtxoFields), } } +/// Validate PSBT input UTXO fields for the given network, allowing missing UTXOs. +pub fn validate_psbt_utxo_fields(psbt: &Psbt, network: Network) -> Result<(), String> { + validate_psbt_utxos_inner(psbt, network, false) +} + +/// Validate all PSBT input UTXOs for operations that need every prevout. +pub fn validate_psbt_utxos(psbt: &Psbt, network: Network) -> Result<(), String> { + validate_psbt_utxos_inner(psbt, network, true) +} + +fn validate_psbt_utxos_inner( + psbt: &Psbt, + network: Network, + require_all_utxos: bool, +) -> Result<(), String> { + if psbt.unsigned_tx.input.len() != psbt.inputs.len() { + return Err(format!( + "Invalid PSBT: transaction has {} inputs but PSBT has {} input maps", + psbt.unsigned_tx.input.len(), + psbt.inputs.len() + )); + } + + for (index, (tx_input, psbt_input)) in psbt + .unsigned_tx + .input + .iter() + .zip(psbt.inputs.iter()) + .enumerate() + { + match get_output_script_and_value_for_network( + psbt_input, + tx_input.previous_output, + network, + ) { + Ok(_) => {} + Err(OutputScriptError::NoUtxoFields) if !require_all_utxos => {} + Err(error) => return Err(format!("Input {}: {}", index, error)), + } + } + + Ok(()) +} + fn get_output_script_from_input( input: &Input, prevout: OutPoint, ) -> Result<&ScriptBuf, OutputScriptError> { - // Delegate to get_output_script_and_value and return just the script - get_output_script_and_value(input, prevout).map(|(script, _value)| script) + // Classification has no network context; still cross-check both UTXO fields. + get_output_script_and_value_inner(input, prevout, false).map(|(script, _value)| script) } /// Check that the output-script shape is consistent with a candidate type. @@ -904,8 +1047,9 @@ pub fn validate_psbt_wallet_inputs( let mut validation_errors = Vec::new(); for (input_index, (prevout, input)) in prevouts.iter().zip(psbt.inputs.iter()).enumerate() { - let output_script = match get_output_script_from_input(input, *prevout) { - Ok(script) => script, + let output_script = match get_output_script_and_value_for_network(input, *prevout, network) + { + Ok((script, _value)) => script, Err(e) => { validation_errors.push(InputValidationError { input_index, @@ -1347,3 +1491,200 @@ mod infer_tests { ); } } + +#[cfg(test)] +mod prevout_authentication_tests { + use super::*; + use miniscript::bitcoin::{ + absolute::LockTime, + hashes::{sha256d, Hash}, + transaction::{Sequence, Version}, + Amount, OutPoint, Transaction, TxIn, TxOut, Witness, + }; + + fn p2sh_script() -> ScriptBuf { + ScriptBuf::from_bytes(vec![0x51]).to_p2sh() + } + + fn previous_transaction(script_pubkey: ScriptBuf, value: u64) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(value), + script_pubkey, + }], + } + } + + fn prevout(tx: &Transaction) -> OutPoint { + OutPoint { + txid: tx.compute_txid(), + vout: 0, + } + } + + #[test] + fn non_witness_transaction_must_match_prevout_txid() { + let tx = previous_transaction(p2sh_script(), 10_000); + let expected = Txid::from_raw_hash(sha256d::Hash::hash(b"different previous tx")); + assert_ne!(tx.compute_txid(), expected); + let input = Input { + non_witness_utxo: Some(tx), + ..Default::default() + }; + + assert!(matches!( + get_output_script_and_value( + &input, + OutPoint { + txid: expected, + vout: 0, + }, + ), + Err(OutputScriptError::NonWitnessUtxoTxidMismatch { .. }) + )); + } + + #[test] + fn witness_and_non_witness_utxos_must_agree() { + let tx = previous_transaction(p2sh_script(), 10_000); + let input = Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(9_000), + script_pubkey: tx.output[0].script_pubkey.clone(), + }), + non_witness_utxo: Some(tx.clone()), + ..Default::default() + }; + + assert!(matches!( + get_output_script_and_value(&input, prevout(&tx)), + Err(OutputScriptError::WitnessUtxoMismatch) + )); + } + + #[test] + fn legacy_p2sh_requires_non_witness_utxo_on_noncommitting_networks() { + let input = Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: p2sh_script(), + }), + ..Default::default() + }; + let prevout = OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }; + + assert!(matches!( + get_output_script_and_value_for_network(&input, prevout, Network::Bitcoin), + Err(OutputScriptError::MissingNonWitnessUtxoForLegacyInput) + )); + assert!(get_output_script_and_value_for_network(&input, prevout, Network::BitcoinCash).is_ok()); + } + + #[test] + fn nested_segwit_p2sh_can_use_witness_utxo() { + let witness_script = ScriptBuf::from_bytes(vec![0x51]); + let redeem_script = witness_script.to_p2wsh(); + let input = Input { + witness_script: Some(witness_script), + redeem_script: Some(redeem_script.clone()), + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: redeem_script.to_p2sh(), + }), + ..Default::default() + }; + let prevout = OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }; + + assert!(get_output_script_and_value_for_network(&input, prevout, Network::Bitcoin).is_ok()); + } + + #[test] + fn finalized_nested_segwit_can_use_witness_utxo_after_metadata_is_removed() { + let redeem_script = ScriptBuf::from_bytes(vec![0x51]).to_p2wsh(); + let final_script_sig = ScriptBuf::builder() + .push_slice(redeem_script.as_bytes()) + .into_script(); + let input = Input { + final_script_sig: Some(final_script_sig), + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: redeem_script.to_p2sh(), + }), + ..Default::default() + }; + let prevout = OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }; + + assert!(get_output_script_and_value_for_network(&input, prevout, Network::Bitcoin).is_ok()); + } + + #[test] + fn unrelated_witness_script_does_not_bypass_legacy_prev_tx_requirement() { + let redeem_script = ScriptBuf::from_bytes(vec![0x51]); + let input = Input { + redeem_script: Some(redeem_script.clone()), + witness_script: Some(ScriptBuf::from_bytes(vec![0x51])), + witness_utxo: Some(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: redeem_script.to_p2sh(), + }), + ..Default::default() + }; + let prevout = OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }; + + assert!(matches!( + get_output_script_and_value_for_network(&input, prevout, Network::Bitcoin), + Err(OutputScriptError::MissingNonWitnessUtxoForLegacyInput) + )); + } + + #[test] + fn psbt_deserialization_rejects_mismatched_non_witness_txid() { + use super::super::BitGoPsbt; + + let source = previous_transaction(p2sh_script(), 10_000); + let wrong_txid = Txid::from_raw_hash(sha256d::Hash::hash(b"different prev tx")); + let unsigned_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: wrong_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::new(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).expect("valid unsigned tx"); + psbt.inputs[0].non_witness_utxo = Some(source); + + let error = BitGoPsbt::deserialize(&psbt.serialize(), Network::Bitcoin) + .expect_err("mismatched source transaction must not deserialize"); + assert!(error.to_string().contains("does not match prevout txid")); + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index d6197f40b5f..5f42d4d5254 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -1344,9 +1344,12 @@ impl ZcashBitGoPsbt { .get(i) .ok_or_else(|| format!("input {i}: no matching tx input"))? .previous_output; - let (script, value) = - super::psbt_wallet_input::get_output_script_and_value(input, prevout) - .map_err(|e| format!("input {i}: missing UTXO value/script: {e}"))?; + let (script, value) = super::psbt_wallet_input::get_output_script_and_value_for_network( + input, + prevout, + self.network, + ) + .map_err(|e| format!("input {i}: missing UTXO value/script: {e}"))?; amounts.push(value.to_sat() as i64); scripts.push(script.clone()); } diff --git a/packages/wasm-utxo/src/transparent_signing.rs b/packages/wasm-utxo/src/transparent_signing.rs index ade9ad04917..551b6581317 100644 --- a/packages/wasm-utxo/src/transparent_signing.rs +++ b/packages/wasm-utxo/src/transparent_signing.rs @@ -104,12 +104,9 @@ pub fn script_pubkey_from_descriptor(descriptor: &str) -> Result)` or /// `wpkh()`; not limited to P2PKH. /// -/// `non_witness_utxo`, when given, is validated against `witness_utxo` and the descriptor via -/// the checked `PsbtExt::update_input_with_descriptor` (BIP174-safe). When omitted, falls back -/// to `PsbtInputExt::update_with_descriptor_unchecked` — safe only for value-committing sighash -/// networks where `non_witness_utxo` is cryptographically pointless (see -/// [`Network::requires_prev_tx_for_legacy_input`](crate::Network::requires_prev_tx_for_legacy_input)). -/// Callers must gate on that predicate themselves before omitting `non_witness_utxo`. +/// `non_witness_utxo`, when given, must match the input txid and spent output. Legacy P2SH inputs +/// require it on networks whose sighash does not commit the input amount. `network` selects that +/// policy. Descriptor metadata is then validated with `PsbtExt::update_input_with_descriptor`. #[allow(clippy::too_many_arguments)] pub fn add_input_with_descriptor( psbt: &mut miniscript::bitcoin::Psbt, @@ -121,6 +118,7 @@ pub fn add_input_with_descriptor( descriptor: &str, sequence: u32, non_witness_utxo: Option, + network: crate::Network, ) -> Result<(), String> { let desc = Descriptor::::from_str(descriptor).map_err(|e| e.to_string())?; @@ -132,7 +130,7 @@ pub fn add_input_with_descriptor( witness: miniscript::bitcoin::Witness::default(), }; let has_non_witness_utxo = non_witness_utxo.is_some(); - let psbt_input = psbt::Input { + let mut psbt_input = psbt::Input { witness_utxo: Some(TxOut { value: Amount::from_sat(value), script_pubkey, @@ -140,16 +138,31 @@ pub fn add_input_with_descriptor( non_witness_utxo, ..Default::default() }; + if !has_non_witness_utxo { + psbt_input + .update_with_descriptor_unchecked(&desc) + .map_err(|e| e.to_string())?; + } + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::get_output_script_and_value_for_network( + &psbt_input, + tx_in.previous_output, + network, + ) + .map_err(|e| e.to_string())?; + if has_non_witness_utxo && network.mainnet() == crate::Network::Dash { + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::get_output_script_and_value( + &psbt_input, + tx_in.previous_output, + ) + .map_err(|e| e.to_string())?; + } crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input)?; if has_non_witness_utxo { psbt.update_input_with_descriptor(index, &desc) .map_err(|e| e.to_string()) } else { - psbt.inputs[index] - .update_with_descriptor_unchecked(&desc) - .map(|_| ()) - .map_err(|e| e.to_string()) + Ok(()) } } diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index b368230b4d7..64cf101d7ff 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1223,6 +1223,7 @@ mod tests { &format!("pkh({})", pubkey), 0xFFFFFFFE, None, + crate::Network::Zcash, ) .unwrap(); crate::psbt_ops::insert_output( diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index 95b997c9d5a..f30b1e7e767 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -250,6 +250,13 @@ impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspent .map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?; let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) .map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?; + let prev_tx_val = + js_sys::Reflect::get(item, &"prevTx".into()).unwrap_or(JsValue::UNDEFINED); + let prev_tx = if prev_tx_val.is_undefined() { + None + } else { + Some(js_sys::Uint8Array::new(&prev_tx_val).to_vec()) + }; // Check if 'chain' is present; if missing → ReplayProtection, else → Wallet let chain_val = js_sys::Reflect::get(item, &"chain".into()).unwrap_or(JsValue::UNDEFINED); @@ -263,12 +270,21 @@ impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspent .map_err(|_| { WasmUtxoError::new("'pubkey' is not a valid compressed public key (33 bytes)") })?; - Ok( - crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::ReplayProtection { - pubkey, - value, - }, - ) + Ok(match prev_tx { + Some(prev_tx) => { + crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::ReplayProtectionWithPrevTx { + pubkey, + value, + prev_tx, + } + } + None => { + crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::ReplayProtection { + pubkey, + value, + } + } + }) } else { // Wallet input: requires 'chain' and 'index' fields let chain = chain_val @@ -280,15 +296,20 @@ impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspent .as_f64() .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? as u32; - Ok( - crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::Wallet( - ScriptIdWithValue { - chain, - index, - value, - }, - ), - ) + let unspent = ScriptIdWithValue { + chain, + index, + value, + }; + Ok(match prev_tx { + Some(prev_tx) => { + crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::WalletWithPrevTx { + unspent, + prev_tx, + } + } + None => crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::Wallet(unspent), + }) } } } diff --git a/packages/wasm-utxo/test/benchmark/signing.ts b/packages/wasm-utxo/test/benchmark/signing.ts index 455eeefd2e0..59f6eadbfb9 100644 --- a/packages/wasm-utxo/test/benchmark/signing.ts +++ b/packages/wasm-utxo/test/benchmark/signing.ts @@ -18,7 +18,13 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { BIP32 } from "../../js/bip32.js"; -import { BitGoPsbt, RootWalletKeys, type NetworkName } from "../../js/fixedScriptWallet/index.js"; +import { + BitGoPsbt, + RootWalletKeys, + outputScript, + type NetworkName, +} from "../../js/fixedScriptWallet/index.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import type { BIP32Interface } from "../../js/bip32.js"; import type { SignPath } from "../../js/fixedScriptWallet/BitGoPsbt.js"; @@ -91,15 +97,18 @@ function createPsbtWithInputs( // Add inputs for (let i = 0; i < inputCount; i++) { - // Create a unique txid for each input (32 bytes hex = 64 chars) const txidBytes = Buffer.alloc(32); txidBytes.writeUInt32BE(i, 0); - const txid = txidBytes.toString("hex"); - + const value = 100000n; + const prevTx = + scriptType.name === "p2sh" + ? createSyntheticPrevTx(outputScript(walletKeys, scriptType.chain, i, network), value) + : undefined; const inputOptions = { - txid, + txid: prevTx?.txid ?? txidBytes.toString("hex"), vout: 0, - value: BigInt(100000), // 0.001 BTC per input + value, // 0.001 BTC per input + prevTx: prevTx?.prevTx, sequence: 0xfffffffe, }; diff --git a/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts b/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts index baae79c0c7b..ed8aafe3b47 100644 --- a/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts +++ b/packages/wasm-utxo/test/fixedScript/dogecoinLOLAmount.ts @@ -2,6 +2,8 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; import { BIP32, fixedScriptWallet } from "../../js/index.js"; import type { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; +import { outputScript } from "../../js/fixedScriptWallet/address.js"; function getWalletKeysForSeed(seed: string): RootWalletKeys { const triple = utxolib.testutil.getKeyTriple(seed); @@ -18,7 +20,7 @@ function getWalletKeysForSeed(seed: string): RootWalletKeys { describe("Dogecoin large output limit amount (LOL amounts) (1-in/1-out)", function () { it("should sign, finalize, and extract tx with 1e19 output value", function () { - const networkName = "dogecoin"; + const networkName = "dogecoin" as const; const seed = "doge_1e19"; const walletKeys = getWalletKeysForSeed(seed); @@ -28,9 +30,14 @@ describe("Dogecoin large output limit amount (LOL amounts) (1-in/1-out)", functi }); const value = 10_000_000_000_000_000_000n; // 1e19 - const txid = "00".repeat(32); + const script = outputScript(walletKeys, 0, 0, networkName); + const prevTx = createSyntheticPrevTx(script, value); - psbt.addWalletInput({ txid, vout: 0, value }, walletKeys, { scriptId: { chain: 0, index: 0 } }); + psbt.addWalletInput( + { ...prevTx, vout: 0, value }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ); psbt.addWalletOutput(walletKeys, { chain: 0, index: 0, value }); const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { diff --git a/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts b/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts index eb43000b53e..83b54d2d483 100644 --- a/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts +++ b/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts @@ -2,7 +2,13 @@ import { describe, it } from "mocha"; import * as assert from "assert"; import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; -import { supportsScriptType } from "../../js/fixedScriptWallet/index.js"; +import { + outputScript, + p2shP2pkOutputScript, + requiresPrevTxForP2sh, + supportsScriptType, +} from "../../js/fixedScriptWallet/index.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; import { ECPair } from "../../js/ecpair.js"; import { ZcashTransaction } from "../../js/transaction.js"; @@ -58,17 +64,23 @@ function createSignedP2msPsbt( supportedTypes.forEach((scriptType, index) => { const chain = ChainCode.value(scriptType, "external"); const value = valueOverride ?? BigInt(10000 + index * 10000); + const script = outputScript(rootWalletKeys, chain, index, coinName); + const prevTx = + scriptType === "p2sh" && requiresPrevTxForP2sh(coinName) + ? createSyntheticPrevTx(script, value) + : undefined; psbt.addWalletInput( { - txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, + txid: prevTx?.txid ?? `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, vout: 0, value, sequence: 0xfffffffd, + prevTx: prevTx?.prevTx, }, rootWalletKeys, { scriptId: { chain, index } }, ); - unspents.push({ chain, index, value }); + unspents.push({ chain, index, value, ...(prevTx ? { prevTx: prevTx.prevTx } : {}) }); }); psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); @@ -149,13 +161,29 @@ describe("BitGoPsbt.fromNetworkFormat", function () { const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey)); const psbt = BitGoPsbt.createEmpty(coin, rootWalletKeys, { version: 2, lockTime: 0 }); + const walletScript = outputScript(rootWalletKeys, 0, 0, coin); + const walletPrevTx = createSyntheticPrevTx(walletScript, 10000n); + const replayScript = p2shP2pkOutputScript(ecpair.publicKey); + const replayPrevTx = createSyntheticPrevTx(replayScript, 1000n); psbt.addWalletInput( - { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + { + txid: walletPrevTx.txid, + vout: 0, + value: 10000n, + sequence: 0xfffffffd, + prevTx: walletPrevTx.prevTx, + }, rootWalletKeys, { scriptId: { chain: 0, index: 0 } }, ); psbt.addReplayProtectionInput( - { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + { + txid: replayPrevTx.txid, + vout: 0, + value: 1000n, + sequence: 0xfffffffd, + prevTx: replayPrevTx.prevTx, + }, ecpair, ); psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); @@ -164,8 +192,8 @@ describe("BitGoPsbt.fromNetworkFormat", function () { const txBytes = psbt.getHalfSignedLegacyFormat(); const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, + { chain: 0, index: 0, value: 10000n, prevTx: walletPrevTx.prevTx }, + { pubkey: ecpair.publicKey, value: 1000n, prevTx: replayPrevTx.prevTx }, ]; const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); @@ -179,13 +207,29 @@ describe("BitGoPsbt.fromNetworkFormat", function () { const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey)); const psbt = BitGoPsbt.createEmpty(coin, rootWalletKeys, { version: 2, lockTime: 0 }); + const walletScript = outputScript(rootWalletKeys, 0, 0, coin); + const walletPrevTx = createSyntheticPrevTx(walletScript, 10000n); + const replayScript = p2shP2pkOutputScript(ecpair.publicKey); + const replayPrevTx = createSyntheticPrevTx(replayScript, 1000n); psbt.addWalletInput( - { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + { + txid: walletPrevTx.txid, + vout: 0, + value: 10000n, + sequence: 0xfffffffd, + prevTx: walletPrevTx.prevTx, + }, rootWalletKeys, { scriptId: { chain: 0, index: 0 } }, ); psbt.addReplayProtectionInput( - { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + { + txid: replayPrevTx.txid, + vout: 0, + value: 1000n, + sequence: 0xfffffffd, + prevTx: replayPrevTx.prevTx, + }, ecpair, ); psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); @@ -196,8 +240,8 @@ describe("BitGoPsbt.fromNetworkFormat", function () { const txBytes = psbt.extractTransaction().toBytes(); const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, + { chain: 0, index: 0, value: 10000n, prevTx: walletPrevTx.prevTx }, + { pubkey: ecpair.publicKey, value: 1000n, prevTx: replayPrevTx.prevTx }, ]; const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); diff --git a/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts b/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts index 808255ea90f..02ad040ee6a 100644 --- a/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts @@ -7,6 +7,8 @@ import * as utxolib from "@bitgo/utxo-lib"; import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { outputScript, p2shP2pkOutputScript, requiresPrevTxForP2sh } from "../../js/fixedScriptWallet/index.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; import { ECPair } from "../../js/ecpair.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; import { getCoinNameForNetwork } from "../networks.js"; @@ -42,11 +44,18 @@ function createUnsignedP2msPsbt(network: utxolib.Network): BitGoPsbt { supportedTypes.forEach((scriptType, index) => { const scriptId = { chain: ChainCode.value(scriptType, "external"), index }; + const value = BigInt(10000 + index * 10000); + const script = outputScript(rootWalletKeys, scriptId.chain, index, coinName); + const prevTx = + scriptType === "p2sh" && requiresPrevTxForP2sh(coinName) + ? createSyntheticPrevTx(script, value) + : undefined; psbt.addWalletInput( { - txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, + txid: prevTx?.txid ?? `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, vout: 0, - value: BigInt(10000 + index * 10000), + value, + prevTx: prevTx?.prevTx, sequence: 0xfffffffd, }, rootWalletKeys, @@ -93,8 +102,14 @@ describe("getUnsignedTransaction", function () { const [userXprv] = getKeyTriple("default"); const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + const prevTx = createSyntheticPrevTx(outputScript(rootWalletKeys, 0, 0, "btc"), 10000n); psbt.addWalletInput( - { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + { + ...prevTx, + vout: 0, + value: 10000n, + sequence: 0xfffffffd, + }, rootWalletKeys, { scriptId: { chain: 0, index: 0 } }, ); @@ -140,13 +155,15 @@ describe("getUnsignedTransaction", function () { const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + const walletPrevTx = createSyntheticPrevTx(outputScript(rootWalletKeys, 0, 0, "btc"), 10000n); + const replayPrevTx = createSyntheticPrevTx(p2shP2pkOutputScript(ecpair.publicKey), 1000n); psbt.addWalletInput( - { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + { ...walletPrevTx, vout: 0, value: 10000n, sequence: 0xfffffffd }, rootWalletKeys, { scriptId: { chain: 0, index: 0 } }, ); psbt.addReplayProtectionInput( - { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + { ...replayPrevTx, vout: 0, value: 1000n, sequence: 0xfffffffd }, ecpair, ); psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); diff --git a/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts index 6b7c1c55e4f..11220f00d2d 100644 --- a/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts +++ b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts @@ -7,6 +7,8 @@ import * as utxolib from "@bitgo/utxo-lib"; import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { outputScript, p2shP2pkOutputScript, requiresPrevTxForP2sh } from "../../js/fixedScriptWallet/index.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; import { ECPair } from "../../js/ecpair.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; import { getCoinNameForNetwork } from "../networks.js"; @@ -53,11 +55,18 @@ function createHalfSignedP2msPsbt(network: utxolib.Network): BitGoPsbt { // Add inputs for each supported p2ms type supportedTypes.forEach((scriptType, index) => { const scriptId = { chain: ChainCode.value(scriptType, "external"), index }; + const value = BigInt(10000 + index * 10000); + const script = outputScript(rootWalletKeys, scriptId.chain, index, coinName); + const prevTx = + scriptType === "p2sh" && requiresPrevTxForP2sh(coinName) + ? createSyntheticPrevTx(script, value) + : undefined; psbt.addWalletInput( { - txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, + txid: prevTx?.txid ?? `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, vout: 0, - value: BigInt(10000 + index * 10000), + value, + prevTx: prevTx?.prevTx, sequence: 0xfffffffd, }, rootWalletKeys, @@ -113,11 +122,14 @@ describe("getHalfSignedLegacyFormat", function () { }); // Add a p2sh input + const value = 10000n; + const prevTx = createSyntheticPrevTx(outputScript(rootWalletKeys, 0, 0, "btc"), value); psbt.addWalletInput( { - txid: "00".repeat(32), + txid: prevTx.txid, vout: 0, - value: BigInt(10000), + value, + prevTx: prevTx.prevTx, sequence: 0xfffffffd, }, rootWalletKeys, @@ -223,11 +235,17 @@ describe("getHalfSignedLegacyFormat", function () { lockTime: 0, }); + const walletValue = 10000n; + const walletPrevTx = createSyntheticPrevTx( + outputScript(rootWalletKeys, 0, 0, "btc"), + walletValue, + ); psbt.addWalletInput( { - txid: "00".repeat(32), + txid: walletPrevTx.txid, vout: 0, - value: BigInt(10000), + value: walletValue, + prevTx: walletPrevTx.prevTx, sequence: 0xfffffffd, }, rootWalletKeys, @@ -257,13 +275,34 @@ describe("getHalfSignedLegacyFormat", function () { const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + const walletValue = 10000n; + const walletPrevTx = createSyntheticPrevTx( + outputScript(rootWalletKeys, 0, 0, "btc"), + walletValue, + ); + const replayPrevTx = createSyntheticPrevTx( + p2shP2pkOutputScript(ecpair.publicKey), + 1000n, + ); psbt.addWalletInput( - { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + { + txid: walletPrevTx.txid, + vout: 0, + value: walletValue, + prevTx: walletPrevTx.prevTx, + sequence: 0xfffffffd, + }, rootWalletKeys, { scriptId: { chain: 0, index: 0 } }, ); psbt.addReplayProtectionInput( - { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + { + txid: replayPrevTx.txid, + vout: 0, + value: 1000n, + prevTx: replayPrevTx.prevTx, + sequence: 0xfffffffd, + }, ecpair, ); psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); diff --git a/packages/wasm-utxo/test/fixedScript/legacyUtxoAuthentication.ts b/packages/wasm-utxo/test/fixedScript/legacyUtxoAuthentication.ts new file mode 100644 index 00000000000..b69da2fd540 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/legacyUtxoAuthentication.ts @@ -0,0 +1,108 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { ECPair } from "../../js/ecpair.js"; +import { + outputScript, + p2shP2pkOutputScript, +} from "../../js/fixedScriptWallet/index.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; +import { getDefaultWalletKeys } from "../../js/testutils/keys.js"; + +describe("legacy PSBT prevout authentication", function () { + const walletKeys = getDefaultWalletKeys(); + const p2sh = outputScript(walletKeys, 0, 0, "btc"); + const value = 10_000n; + const prevTx = createSyntheticPrevTx(p2sh, value); + + it("requires the matching previous transaction for legacy P2SH", function () { + const psbt = BitGoPsbt.createEmpty("btc", walletKeys); + + assert.throws( + () => + psbt.addWalletInput( + { txid: prevTx.txid, vout: 0, value }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ), + /non_witness_utxo is required/i, + ); + + assert.doesNotThrow(() => + psbt.addWalletInput( + { ...prevTx, vout: 0, value }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ), + ); + }); + + it("rejects a previous transaction with a different txid", function () { + const psbt = BitGoPsbt.createEmpty("btc", walletKeys); + + assert.throws( + () => + psbt.addWalletInput( + { ...prevTx, txid: "11".repeat(32), vout: 0, value }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ), + /does not match prevout txid/i, + ); + }); + + it("rejects a caller value that disagrees with the spent output", function () { + const psbt = BitGoPsbt.createEmpty("btc", walletKeys); + + assert.throws( + () => + psbt.addWalletInput( + { ...prevTx, vout: 0, value: value - 1n }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ), + /does not match the supplied script and value/i, + ); + }); + + it("requires authenticated replay-protection inputs on Bitcoin", function () { + const psbt = BitGoPsbt.createEmpty("btc", walletKeys); + const key = ECPair.fromPublicKey(walletKeys.userKey().publicKey); + + assert.throws( + () => psbt.addReplayProtectionInput({ txid: "22".repeat(32), vout: 0, value }, key), + /non_witness_utxo is required/i, + ); + + const replayScript = p2shP2pkOutputScript(key.publicKey); + const replayPrevTx = createSyntheticPrevTx(replayScript, value); + assert.doesNotThrow(() => + psbt.addReplayProtectionInput({ ...replayPrevTx, vout: 0, value }, key), + ); + }); + + it("rejects a conflicting witness_utxo in the generic input API", function () { + const psbt = BitGoPsbt.createEmpty("btc", walletKeys); + + assert.throws( + () => + psbt.addInput( + { ...prevTx, vout: 0, value: value - 1n }, + p2sh, + ), + /witness_utxo does not match/i, + ); + }); + + it("keeps witness-only P2SH available on value-committing networks", function () { + const psbt = BitGoPsbt.createEmpty("bch", walletKeys); + + assert.doesNotThrow(() => + psbt.addWalletInput( + { txid: "33".repeat(32), vout: 0, value }, + walletKeys, + { scriptId: { chain: 0, index: 0 } }, + ), + ); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts index 2a2ffcc965e..b7f185cd720 100644 --- a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts +++ b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts @@ -16,6 +16,8 @@ import { } from "./fixtureUtil.js"; import { mainnetCoinNames } from "./networkSupport.util.js"; import { createOtherWalletKeys } from "./generateFixture.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; +import { requiresPrevTxForP2sh } from "../../js/fixedScriptWallet/prevTx.js"; // Zcash Sapling consensus branch ID for test fixtures const ZCASH_SAPLING_BRANCH_ID = 0x76b809bb; @@ -98,6 +100,11 @@ describe("PSBT reconstruction", function () { // Convert fixture txid (internal byte order) to display order const txid = reverseHex(fixtureInput.hash); + const prevTx = + requiresPrevTxForP2sh(networkName) && + (parsedInput.scriptType === "p2sh" || parsedInput.scriptType === "p2shP2pk") + ? createSyntheticPrevTx(parsedInput.script, parsedInput.value, fixtureInput.index) + : undefined; if (parsedInput.scriptId !== null) { // Wallet input - use addWalletInput @@ -109,6 +116,7 @@ describe("PSBT reconstruction", function () { vout: fixtureInput.index, value: parsedInput.value, sequence: parsedInput.sequence, + prevTx: prevTx?.prevTx, }, rootWalletKeys, { scriptId: parsedInput.scriptId, signPath }, @@ -127,6 +135,7 @@ describe("PSBT reconstruction", function () { vout: fixtureInput.index, value: parsedInput.value, sequence: parsedInput.sequence, + prevTx: prevTx?.prevTx, }, replayProtectionKey, ); diff --git a/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts b/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts index e13d110fe54..c8c378ab61b 100644 --- a/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts +++ b/packages/wasm-utxo/test/fixedScript/singleInputSigning.ts @@ -7,10 +7,12 @@ import assert from "node:assert"; import { BIP32 } from "../../js/bip32.js"; -import { BitGoPsbt, RootWalletKeys } from "../../js/fixedScriptWallet/index.js"; +import { BitGoPsbt, ChainCode, RootWalletKeys } from "../../js/fixedScriptWallet/index.js"; import type { BIP32Interface } from "../../js/bip32.js"; import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import type { NetworkName } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { outputScript } from "../../js/fixedScriptWallet/address.js"; +import { createSyntheticPrevTx } from "../../js/testutils/AcidTest.js"; type Triple = [T, T, T]; @@ -53,7 +55,11 @@ function createPsbtWithInputs( for (let i = 0; i < inputCount; i++) { const txidBytes = Buffer.alloc(32); txidBytes.writeUInt32BE(i, 0); - const txid = txidBytes.toString("hex"); + const value = 100000n; + const prevTx = + ChainCode.is(chain) && ChainCode.scriptType(chain) === "p2sh" + ? createSyntheticPrevTx(outputScript(walletKeys, chain, i, network), value) + : undefined; const walletOptions: { scriptId: { chain: number; index: number }; signPath?: SignPath } = { scriptId: { chain, index: i }, @@ -66,9 +72,10 @@ function createPsbtWithInputs( psbt.addWalletInput( { - txid, + txid: prevTx?.txid ?? txidBytes.toString("hex"), vout: 0, - value: BigInt(100000), + value, + prevTx: prevTx?.prevTx, sequence: 0xfffffffe, }, walletKeys,