python client: first-class PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE field
What changed, and why it matters
This commit adds support in Ledger's Python Bitcoin client for a new PSBT global field used by BIP-322 generic signed messages. It is a feature addition: parsing, serialization, and round-trip tests for the new field. There is no indication of a security bug being fixed or introduced.
No security action required. Treat as routine feature/test addition.
Security signals we found
No security-relevant signals present
Feature addition for BIP-322 PSBT field support
Includes input-validation tests (duplicate key rejection, keydata rejection)
Evidence from the diff
The change introduces first-class handling of PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (0x09) in ledger_bitcoin/psbt.py. It adds an attribute, parses the field during deserialization (rejecting duplicate keys and keydata), serializes it, and preserves it across PSBT v0/v2 conversions. A new test file verifies round-trips and error cases.
Changed components
bitcoin_client/ledger_bitcoin/psbt.pybitcoin_client/tests/test_psbt_generic_signed_message.pyInspect captured patch +83 / −2
### bitcoin_client/CHANGELOG.md
@@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are in `dd-mm-yyyy` format.
-## [0.4.2] - 18-09-2026
+## [0.4.2] - 29-09-2026
+
+### Added
+
+- `PSBT.generic_signed_message`: support for the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (0x09) of [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki), in both PSBT versions.
### Fixed
### bitcoin_client/ledger_bitcoin/psbt.py
@@ -772,6 +772,7 @@ class PSBT(object):
PSBT_GLOBAL_INPUT_COUNT = 0x04
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
+ PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09
PSBT_GLOBAL_VERSION = 0xFB
def __init__(self, tx: Optional[CTransaction] = None) -> None:
@@ -789,6 +790,8 @@ def __init__(self, tx: Optional[CTransaction] = None) -> None:
self.tx_version: Optional[int] = None
self.fallback_locktime: Optional[int] = None
self.tx_modifiable: Optional[int] = None
+ # BIP-322: the message of a generic signed message request (allowed in any PSBT version)
+ self.generic_signed_message: Optional[bytes] = None
# Assume version 0 PSBT
self.version = 0
@@ -888,6 +891,12 @@ def deserialize(self, psbt: str) -> None:
if len(v) != 1:
raise PSBTSerializationError("Global tx modifiable flags is not 1 bytes")
self.tx_modifiable = struct.unpack("<B", v)[0]
+ elif key_type == PSBT.PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
+ if key in key_lookup:
+ raise PSBTSerializationError("Duplicate key, global generic signed message is already provided")
+ elif len(key) > 1:
+ raise PSBTSerializationError("Global generic signed message key is more than one byte type")
+ self.generic_signed_message = deser_string(f)
elif key_type == PSBT.PSBT_GLOBAL_VERSION:
if key in key_lookup:
raise PSBTSerializationError("Duplicate key, global PSBT version is already provided")
@@ -1018,6 +1027,10 @@ def serialize(self) -> str:
r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_TX_MODIFIABLE))
r += ser_string(struct.pack("<B", self.tx_modifiable))
+ if self.generic_signed_message is not None:
+ r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE))
+ r += ser_string(self.generic_signed_message)
+
if self.version > 0 or self.explicit_version:
r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_VERSION))
r += ser_string(struct.pack("<I", self.version))
### bitcoin_client/tests/README.md
@@ -1 +1 @@
-These tests verify the compatibility layer of the bitcoin_client when ran against the legacy app (v1.6.5) using the LegacyClient.
+These tests verify the compatibility layer of the bitcoin_client when ran against the legacy app (v1.6.5) using the LegacyClient, plus some pure-Python unit tests.
### bitcoin_client/tests/test_psbt_generic_signed_message.py
@@ -0,0 +1,64 @@
+import pytest
+
+from bitcoin_client.ledger_bitcoin.errors import PSBTSerializationError
+from bitcoin_client.ledger_bitcoin.psbt import PSBT, PartiallySignedInput, PartiallySignedOutput
+from bitcoin_client.ledger_bitcoin.tx import CTransaction, CTxIn, CTxOut, COutPoint
+
+
+def make_psbt(message=None) -> PSBT:
+ """A PSBTv0 with one input and one bare OP_RETURN output, like a BIP-322 to_sign."""
+ tx = CTransaction()
+ tx.nVersion = 0
+ tx.vin = [CTxIn(COutPoint(1, 0), b"", 0)]
+ tx.vout = [CTxOut(0, b"\x6a")]
+
+ psbt = PSBT(tx)
+ psbt.inputs = [PartiallySignedInput(0)]
+ psbt.outputs = [PartiallySignedOutput(0)]
+ psbt.generic_signed_message = message
+ return psbt
+
+
+def roundtrip(psbt: PSBT) -> PSBT:
+ result = PSBT()
+ result.deserialize(psbt.serialize())
+ return result
+
+
+@pytest.mark.parametrize("message", [b"Hello World", b""])
+def test_generic_signed_message_roundtrip(message: bytes):
+ """PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (BIP-322) is kept in both PSBT versions."""
+ psbt = make_psbt(message)
+
+ psbt_v0 = roundtrip(psbt)
+ assert psbt_v0.version == 0
+ assert psbt_v0.generic_signed_message == message
+ assert psbt_v0.unknown == {}
+
+ psbt_v0.convert_to_v2()
+ psbt_v2 = roundtrip(psbt_v0)
+ assert psbt_v2.version == 2
+ assert psbt_v2.generic_signed_message == message
+
+ psbt_v2.convert_to_v0()
+ assert roundtrip(psbt_v2).generic_signed_message == message
+
+
+def test_generic_signed_message_absent():
+ psbt = roundtrip(make_psbt())
+ assert psbt.generic_signed_message is None
+
+
+def test_generic_signed_message_key_with_keydata():
+ # the field has no keydata
+ psbt = make_psbt()
+ psbt.unknown[b"\x09\x00"] = b"Hello World"
+ with pytest.raises(PSBTSerializationError):
+ roundtrip(psbt)
+
+
+def test_generic_signed_message_duplicate_key():
+ psbt = make_psbt(b"Hello World")
+ psbt.unknown[b"\x09"] = b"x"
+ with pytest.raises(PSBTSerializationError):
+ roundtrip(psbt)Why this scored 19/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.