psbt: track whether an unsigned tx key was seen
What changed, and why it matters
This change fixes a subtle parsing bug in how the wallet software reads PSBT (Partially Signed Bitcoin Transaction) files. Previously, the code could not tell the difference between a PSBT that genuinely contained an unsigned transaction with zero inputs and outputs, and a PSBT where the unsigned transaction was simply missing. This could allow a malformed or intentionally crafted PSBT to be treated as valid when it should have been rejected, potentially leading to incorrect transaction handling or confusion in hardware wallet workflows.
Review whether any downstream callers or hardware wallet integrations rely on the old behavior of accepting a missing unsigned tx key when self.tx.is_null(). Ensure the new test vector is exercised in CI. Consider whether additional PSBT edge cases (e.g., duplicate global unsigned tx keys) need similar key-presence tracking.
Security signals we found
Ambiguous sentinel value used as a proxy for key presence
PSBT version-specific validation bypass possible
New test vector added for empty unsigned transaction edge case
Validation logic changed from content-based to key-presence-based
Evidence from the diff
The PSBT deserializer previously used self.tx.is_null() to decide whether the global unsigned transaction key (PSBT_GLOBAL_UNSIGNED_TX) was present. Because an empty transaction (zero inputs, zero outputs) is also represented by a null transaction object, a PSBTv0 that explicitly includes an unsigned tx key with an empty transaction was indistinguishable from one that omitted the key entirely. The patch introduces a boolean flag, found_unsigned_tx, set only when the key is actually parsed. For PSBTv0 it now raises an error if the key was not seen, and for PSBTv2 it raises an error if the key was seen, regardless of whether the transaction object is null. A new test vector for an empty unsigned tx is added to test_psbt.json.
Changed components
hwilib/psbt.py PSBT.deserialize()test/data/test_psbt.jsonInspect captured patch +6 / −3
### hwilib/psbt.py
@@ -809,6 +809,7 @@ def deserialize(self, psbt: str) -> None:
input_count = None
output_count = None
+ found_unsigned_tx = False
# Read loop
while True:
@@ -841,6 +842,7 @@ def deserialize(self, psbt: str) -> None:
raise PSBTSerializationError("Global unsigned tx is invalid")
if len(tx_bytes.read(1)) > 0:
raise PSBTSerializationError("Global unsigned tx is not serialized without witness")
+ found_unsigned_tx = True
# Make sure that all scriptSigs and scriptWitnesses are empty
for txin in self.tx.vin:
@@ -910,7 +912,7 @@ def deserialize(self, psbt: str) -> None:
# Check PSBT version constraints
if self.version == 0:
# make sure that we got an unsigned tx
- if self.tx.is_null():
+ if not found_unsigned_tx:
raise PSBTSerializationError("No unsigned transaction was provided")
# Make sure no v2 fields are present
if self.tx_version is not None:
@@ -936,7 +938,7 @@ def deserialize(self, psbt: str) -> None:
if output_count is None:
raise PSBTSerializationError("PSBT_GLOBAL_OUTPUT_COUNT is required in PSBTv2")
# Unsigned tx is disallowed
- if not self.tx.is_null():
+ if found_unsigned_tx:
raise PSBTSerializationError("PSBT_GLOBAL_UNSIGNED_TX is not allowed in PSBTv2")
# Read input data
### test/data/test_psbt.json
@@ -86,7 +86,8 @@
"cHNidP8BAgQCAAAAAQQBAQEFAQIBBgEHAfsEAgAAAAABAFICAAAAAcGqJW4hS5ahgi+T3kK/87Xz/40FGTBuNRXXUVpegFsSAAAAAAD/////ARjGmjsAAAAAFgAUsKOvFEIIQSaTyn0WaFK1LbCu8G4AAAAAAQEfGMaaOwAAAAAWABSwo68UQghBJpPKfRZoUrUtsK7wbgEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAAiAgLWAfhIRqZ1X3dr4A49nej7EKzJNfuDxF+wFi1MrVq3khj2nYc+VAAAgAEAAIAAAACAAAAAACoAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAIgIC42+/9T3VNAcM+P05ZhRoDzV6m4Xbc0C/HPp0XSrXs0AY9p2HPlQAAIABAACAAAAAgAEAAABkAAAAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA==",
"cHNidP8BAgQCAAAAAQQBAQEFAQIBBgH/AfsEAgAAAAABAFICAAAAAcGqJW4hS5ahgi+T3kK/87Xz/40FGTBuNRXXUVpegFsSAAAAAAD/////ARjGmjsAAAAAFgAUsKOvFEIIQSaTyn0WaFK1LbCu8G4AAAAAAQEfGMaaOwAAAAAWABSwo68UQghBJpPKfRZoUrUtsK7wbgEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAAiAgLWAfhIRqZ1X3dr4A49nej7EKzJNfuDxF+wFi1MrVq3khj2nYc+VAAAgAEAAIAAAACAAAAAACoAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAIgIC42+/9T3VNAcM+P05ZhRoDzV6m4Xbc0C/HPp0XSrXs0AY9p2HPlQAAIABAACAAAAAgAEAAABkAAAAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA==",
"cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAQYBBwH7BAIAAAAAAQBSAgAAAAHBqiVuIUuWoYIvk95Cv/O18/+NBRkwbjUV11FaXoBbEgAAAAAA/////wEYxpo7AAAAABYAFLCjrxRCCEEmk8p9FmhStS2wrvBuAAAAAAEBHxjGmjsAAAAAFgAUsKOvFEIIQSaTyn0WaFK1LbCu8G4BDiALCtkhQZwchxlzXXLcc5+eqeBjjR/kwe7w+ZRAhIFfyAEPBAAAAAABEAT+////AREEjI3EYgESBBAnAAAAIgIC1gH4SEamdV93a+AOPZ3o+xCsyTX7g8RfsBYtTK1at5IY9p2HPlQAAIABAACAAAAAgAAAAAAqAAAAAQMIAAivLwAAAAABBBYAFMQw9kxHVtoxDb0aCFVy7ymZJicsACICAuNvv/U91TQHDPj9OWYUaA81epuF23NAvxz6dF0q17NAGPadhz5UAACAAQAAgAAAAIABAAAAZAAAAAEDCIu96wsAAAAAAQQWABRN0ZOslkpWrBueHMqEVP4vR0+FEwA=",
- "cHNidP8BAEwCAAAAAALT3/UFAAAAABl2qRTQxZkDxbrChodg6Q/VIaRmWqdlIIisAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4ezLhMAAAAA"
+ "cHNidP8BAEwCAAAAAALT3/UFAAAAABl2qRTQxZkDxbrChodg6Q/VIaRmWqdlIIisAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4ezLhMAAAAA",
+ "cHNidP8BAAoAAAAAAAAAAAAAAA=="
],
"creator" : [
{Why this scored 46/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.