primitives: handle zero-input transactions
What changed, and why it matters
This commit fixes a bug in how the rust-bitcoin library decodes Bitcoin transactions that have no inputs. Normally, the library uses a special encoding for zero-input transactions. The decoder was incorrectly trying to read witness data even when there were no inputs, which would cause decoding to fail. The fix skips witness decoding when there are no inputs, and a new test verifies that an empty transaction can be encoded and decoded correctly.
Review whether any other decoder paths assume witness presence after the Segwit marker, and consider adding fuzz tests for zero-input and single-input Segwit transaction round-trips. Downstream users should update to include this fix if they deserialize untrusted transaction bytes.
Security signals we found
Denial-of-service vector: malformed or edge-case transaction encoding could cause decoding failures in downstream consumers
Consensus-adjacent parsing bug in transaction deserialization
Fix is narrowly scoped to zero-input Segwit transaction decoding
Evidence from the diff
In primitives/src/transaction.rs, the TransactionDecoder’s Outputs state previously transitioned to the Witnesses state whenever is_segwit == IsSegwit::Yes. For zero-input transactions, the Segwit marker is present but there are no witnesses to decode, so this transition was invalid and would lead to a NoWitnesses error. The patch adds an && !inputs.is_empty() guard so the decoder proceeds directly to locktime decoding for zero-input Segwit transactions. A unit test decode_zero_inputs confirms round-trip encoding/decoding of an empty transaction.
Changed components
primitives/src/transaction.rsTransactionDecoderzero-input transaction deserializationInspect captured patch +20 / −1
diff --git a/primitives/src/transaction.rs b/primitives/src/transaction.rs
index 33aac638..6955edd4 100644
--- a/primitives/src/transaction.rs
+++ b/primitives/src/transaction.rs
@@ -458,7 +458,8 @@ impl Decoder for TransactionDecoder {
}
State::Outputs(version, inputs, is_segwit, decoder) => {
let outputs = decoder.end()?;
- if is_segwit == IsSegwit::Yes {
+ // Handle the zero-input case described in the `Transaction` docs.
+ if is_segwit == IsSegwit::Yes && !inputs.is_empty() {
self.state = State::Witnesses(
version,
inputs,
@@ -2157,4 +2158,22 @@ mod tests {
assert_eq!(err, TransactionDecoderError(TransactionDecoderErrorInner::NoWitnesses));
}
+
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn decode_zero_inputs() {
+ // Test empty transaction with no inputs or outputs.
+ let block: u32 = 741_521;
+ let original_tx = Transaction {
+ version: Version::ONE,
+ lock_time: absolute::LockTime::from_height(block).expect("valid height"),
+ inputs: vec![],
+ outputs: vec![],
+ };
+
+ let encoded = encoding::encode_to_vec(&original_tx);
+ let decoded_tx = encoding::decode_from_slice(&encoded).unwrap();
+
+ assert_eq!(original_tx, decoded_tx);
+ }
}
Why this scored 34/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.