From 7a8c07b9ad496a7e66a54aaceda06c052b7d7f7d Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Mon, 16 Feb 2026 15:33:27 +0000 Subject: [PATCH 1/2] pset: correctly mask transaction input indices in Psbt::extract_tx We have a convention that in memory, we don't have the pegin or witness flags on the input index for TxIn, but we *do* keep these around in the Psbt::Input map. --- src/pset/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pset/mod.rs b/src/pset/mod.rs index 85ee99b6..2f895831 100644 --- a/src/pset/mod.rs +++ b/src/pset/mod.rs @@ -287,8 +287,14 @@ impl PartiallySignedTransaction { let mut outputs = vec![]; for psetin in &self.inputs { + let prev_index = if psetin.previous_output_index == 0xffff_ffff { + // special-case coinbase inputs, which do not have flags + psetin.previous_output_index + } else { + psetin.previous_output_index & !((1u32 << 30) | (1 << 31)) + }; let txin = TxIn { - previous_output: OutPoint::new(psetin.previous_txid, psetin.previous_output_index), + previous_output: OutPoint::new(psetin.previous_txid, prev_index), is_pegin: psetin.is_pegin(), script_sig: psetin.final_script_sig.clone().unwrap_or_default(), sequence: psetin.sequence.unwrap_or(Sequence::MAX), @@ -781,6 +787,7 @@ mod tests { use super::*; use crate::hex::{FromHex, ToHex}; + #[track_caller] fn tx_pset_rtt(tx_hex: &str) { let tx: Transaction = encode::deserialize(&Vec::::from_hex(tx_hex).unwrap()[..]).unwrap(); From a9ba3ec51f8130c982e67c4cba2bf5e4554cf885 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Mon, 16 Feb 2026 14:24:53 +0000 Subject: [PATCH 2/2] psbt: add unit test which covers round-tripping of pegins and various witnesses This test case was written by Claude and (fairly heavily) edited by me to add the PSBT round-tripping and to tighten up some checks. Originally we thought the bug was related to witness encoding, so the test covers various permutations of null witnesses. Actually this was a red herring and the bug was just about the pegin flag in the input index being preserved across PSBTs. But I left most of the witness-deleting test in place anyway because it seems potentially useful. --- src/transaction.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/transaction.rs b/src/transaction.rs index de76410b..327eb0df 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -1302,7 +1302,7 @@ impl ::std::error::Error for SighashTypeParseError {} mod tests { use std::str::FromStr; - use crate::encode::serialize; + use crate::{encode::serialize, pset::PartiallySignedTransaction}; use crate::confidential; use crate::hex::FromHex; use secp256k1_zkp::{self, ZERO_TWEAK}; @@ -2531,4 +2531,70 @@ mod tests { assert!(tx3.input[0].asset_issuance.amount.explicit().unwrap() > max_money); assert!(tx4.input[0].asset_issuance.inflation_keys.explicit().unwrap() > max_money); } + + #[test] + fn pset_pegin_witnesses_encoding_round_trip() { + use crate::encode::{serialize, deserialize}; + + // Start with a transaction that has a pegin. + let base_tx: Transaction = hex_deserialize!(include_str!("../tests/data/1in2out_pegin.hex")); + + // Test case (a): input witnesses but no output witnesses + let mut tx_input_only = base_tx.clone(); + for output in &mut tx_input_only.output { + output.witness = TxOutWitness::empty(); + } + + // Test case (b): output witnesses but no input witnesses + let mut tx_output_only = base_tx.clone(); + for input in &mut tx_output_only.input { + input.witness = TxInWitness::empty(); + } + + // Test case (c): no witnesses at all + let mut tx_no_witnesses = base_tx.clone(); + for input in &mut tx_no_witnesses.input { + input.witness = TxInWitness::empty(); + } + for output in &mut tx_no_witnesses.output { + output.witness = TxOutWitness::empty(); + } + + // Test all cases: serialize then deserialize and verify they match + let test_cases = vec![ + ("input_witnesses_only", tx_input_only), + ("output_witnesses_only", tx_output_only), + ("no_witnesses", tx_no_witnesses), + ("both_witnesses", base_tx), + ]; + + for (name, original_tx) in test_cases { + let psbt = PartiallySignedTransaction::from_tx(original_tx.clone()); + let psbt_tx = psbt.extract_tx().unwrap(); + assert_eq!(original_tx, psbt_tx); + + // Serialize the transaction + let serialized = serialize(&original_tx); + let serialized_psbt = serialize(&psbt_tx); + + // Deserialize it back + let deserialized_tx: Transaction = deserialize(&serialized) + .unwrap_or_else(|e| panic!("Failed to deserialize {} transaction: {}", name, e)); + let deserialized_psbt_tx: Transaction = deserialize(&serialized_psbt) + .unwrap_or_else(|e| panic!("Failed to deserialize {} transaction: {}", name, e)); + + // Verify they match exactly + assert_eq!(original_tx, deserialized_tx, + "Roundtrip failed for {} transaction", name); + assert_eq!(original_tx, deserialized_psbt_tx, + "Roundtrip failed for {} transaction", name); + + // Verify reserialization is consistent + let reserialized = serialize(&deserialized_tx); + assert_eq!(serialized.len(), reserialized.len(), + "Serialized length changed for {} transaction", name); + assert_eq!(serialized, reserialized, + "Serialized bytes changed for {} transaction", name); + } + } }