Fix bug in `Psbt::spend_utxo` when missing output
What changed, and why it matters
This commit fixes a panic (sudden crash) in a Bitcoin transaction-signing helper. The function `spend_utxo` previously assumed that a referenced transaction output always existed, and used unchecked array indexing. If the output was missing, the program would crash instead of returning a proper error. The fix replaces the unchecked lookup with a bounds-checked one that returns a clean error.
Review other index-based accesses in PSBT and signing paths for similar unchecked assumptions, and ensure malformed PSBTs from external sources are handled with controlled errors rather than panics.
Security signals we found
Unchecked index into `Vec` replaced with bounds-checked `get`
Panic-to-error conversion in transaction signing code
Regression test added for malformed/missing UTXO data
PSBT input validation hardening
Evidence from the diff
In bitcoin/src/psbt/mod.rs, Psbt::spend_utxo resolves the UTXO being spent for a given input. When only a non_witness_utxo (the full previous transaction) is present, it previously indexed non_witness_utxo.outputs[vout as usize] directly. If vout was out of range—because the provided previous transaction had no outputs or an invalid index—this caused a Rust panic. The patch changes the access to non_witness_utxo.outputs.get(vout as usize).ok_or(SignError::MissingSpendUtxo)?, propagating a controlled SignError::MissingSpendUtxo instead of panicking. A regression test constructs a PSBT whose non_witness_utxo has empty outputs and verifies the function now returns the expected error.
Changed components
bitcoin/src/psbt/mod.rsPsbt::spend_utxonon_witness_utxo output resolutionInspect captured patch +47 / −1
diff --git a/bitcoin/src/psbt/mod.rs b/bitcoin/src/psbt/mod.rs
index 9de51a7e..c6f2c357 100644
--- a/bitcoin/src/psbt/mod.rs
+++ b/bitcoin/src/psbt/mod.rs
@@ -616,7 +616,7 @@ impl Psbt {
witness_utxo
} else if let Some(non_witness_utxo) = &input.non_witness_utxo {
let vout = self.unsigned_tx.inputs[input_index].previous_output.vout;
- &non_witness_utxo.outputs[vout as usize]
+ non_witness_utxo.outputs.get(vout as usize).ok_or(SignError::MissingSpendUtxo)?
} else {
return Err(SignError::MissingSpendUtxo);
};
@@ -2552,6 +2552,52 @@ mod tests {
Err(ExtractTxError::MissingInputAmount { tx: _ })
))
}
+
+ #[test]
+ fn spending_psbt_with_missing_txout() {
+ let psbt = Psbt {
+ unsigned_tx: Transaction {
+ version: transaction::Version::TWO,
+ lock_time: absolute::LockTime::from_consensus(1257139),
+ inputs: vec![TxIn {
+ previous_output: OutPoint {
+ txid: "f61b1742ca13176464adb3cb66050c00787bb3a4eead37e985f2df1e37718126"
+ .parse()
+ .unwrap(),
+ vout: 0,
+ },
+ script_sig: ScriptSigBuf::new(),
+ sequence: Sequence::ENABLE_LOCKTIME_NO_RBF,
+ witness: Witness::default(),
+ }],
+ outputs: vec![
+ TxOut {
+ amount: Amount::from_sat_u32(99_999_699),
+ script_pubkey: ScriptPubKeyBuf::from_hex_no_length_prefix(
+ "76a914d0c59903c5bac2868760e90fd521a4665aa7652088ac",
+ )
+ .unwrap(),
+ },
+ ],
+ },
+ xpub: Default::default(),
+ version: 0,
+ proprietary: Default::default(),
+ unknown: Default::default(),
+ inputs: vec![Input {
+ non_witness_utxo: Some(Transaction {
+ version: transaction::Version::TWO,
+ lock_time: absolute::LockTime::ZERO,
+ inputs: vec![],
+ outputs: vec![], // No outputs here
+ }),
+ ..Default::default()
+ }],
+ outputs: vec![Output::default()],
+ };
+
+ assert!(matches!(psbt.spend_utxo(0), Err(SignError::MissingSpendUtxo)))
+ }
#[test]
#[cfg(all(feature = "rand", feature = "std"))]
Why this scored 45/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.