test: add PSBT proprietary merge regression coverage
What changed, and why it matters
This commit only adds new regression tests for Bitcoin Core's PSBT (Partially Signed Bitcoin Transaction) combine/merge logic. It checks that custom 'proprietary' data fields are preserved when two PSBTs are merged at global, input, and output levels. There is no production code change, no bug fix, and no security patch in the diff itself.
No security action required; this is a test-only commit. Reviewers may optionally confirm the underlying Merge/combinepsbt behavior is already correct and that these tests adequately cover the proprietary-field preservation case.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit adds unit tests in src/test/psbt_tests.cpp and functional tests in test/functional/rpc_psbt.py verifying that PartiallySignedTransaction::Merge and the RPC combinepsbt preserve proprietary fields across global, per-input, and per-output PSBT maps. The tests construct two PSBTs with distinct proprietary keys/values, merge them, and assert both sets remain present. No implementation code is modified.
Changed components
src/test/psbt_tests.cpptest/functional/rpc_psbt.pyInspect captured patch +114 / −0
diff --git a/src/test/psbt_tests.cpp b/src/test/psbt_tests.cpp
index 74690d32..c8e1206e 100644
--- a/src/test/psbt_tests.cpp
+++ b/src/test/psbt_tests.cpp
@@ -9,6 +9,16 @@
BOOST_FIXTURE_TEST_SUITE(psbt_tests, BasicTestingSetup)
+static PSBTProprietary MakeProprietary(uint64_t subtype, uint8_t key_data, uint8_t value)
+{
+ return PSBTProprietary{
+ .subtype = subtype,
+ .identifier = {'p', 's', 'b', 't'},
+ .key = {key_data},
+ .value = {value},
+ };
+}
+
void CheckTimeLock(const std::string& base64_psbt, std::optional<uint32_t> timelock)
{
util::Result<PartiallySignedTransaction> psbt = DecodeBase64PSBT(base64_psbt);
@@ -167,4 +177,43 @@ BOOST_AUTO_TEST_CASE(psbt2_addoutput)
BOOST_CHECK_EQUAL(psbt.outputs.size(), 2);
}
+BOOST_AUTO_TEST_CASE(merge_proprietary_fields)
+{
+ CMutableTransaction tx;
+ tx.vin.emplace_back(COutPoint{});
+ tx.vout.emplace_back(0, CScript{});
+
+ PartiallySignedTransaction left(tx);
+ PartiallySignedTransaction right(tx);
+
+ const auto left_prop = MakeProprietary(/*subtype=*/1, /*key_data=*/0x01, /*value=*/0xaa);
+ const auto right_prop = MakeProprietary(/*subtype=*/2, /*key_data=*/0x02, /*value=*/0xbb);
+
+ left.m_proprietary.insert(left_prop);
+ left.inputs[0].m_proprietary.insert(left_prop);
+ left.outputs[0].m_proprietary.insert(left_prop);
+
+ right.m_proprietary.insert(right_prop);
+ right.inputs[0].m_proprietary.insert(right_prop);
+ right.outputs[0].m_proprietary.insert(right_prop);
+
+ BOOST_REQUIRE(left.Merge(right));
+
+ BOOST_REQUIRE_EQUAL(left.m_proprietary.size(), 2U);
+ BOOST_REQUIRE_EQUAL(left.inputs[0].m_proprietary.size(), 2U);
+ BOOST_REQUIRE_EQUAL(left.outputs[0].m_proprietary.size(), 2U);
+
+ const auto global_it = left.m_proprietary.find(right_prop);
+ BOOST_REQUIRE(global_it != left.m_proprietary.end());
+ BOOST_CHECK(global_it->value == right_prop.value);
+
+ const auto input_it = left.inputs[0].m_proprietary.find(right_prop);
+ BOOST_REQUIRE(input_it != left.inputs[0].m_proprietary.end());
+ BOOST_CHECK(input_it->value == right_prop.value);
+
+ const auto output_it = left.outputs[0].m_proprietary.find(right_prop);
+ BOOST_REQUIRE(output_it != left.outputs[0].m_proprietary.end());
+ BOOST_CHECK(output_it->value == right_prop.value);
+}
+
BOOST_AUTO_TEST_SUITE_END()
diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py
index 4703ee3b..9aad09a0 100755
--- a/test/functional/rpc_psbt.py
+++ b/test/functional/rpc_psbt.py
@@ -20,10 +20,12 @@ from test_framework.messages import (
CTxOut,
MAX_BIP125_RBF_SEQUENCE,
WITNESS_SCALE_FACTOR,
+ ser_compact_size,
)
from test_framework.psbt import (
PSBT,
PSBTMap,
+ PSBT_GLOBAL_PROPRIETARY,
PSBT_GLOBAL_UNSIGNED_TX,
PSBT_GLOBAL_VERSION,
PSBT_IN_RIPEMD160,
@@ -35,8 +37,10 @@ from test_framework.psbt import (
PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS,
PSBT_IN_MUSIG2_PUB_NONCE,
PSBT_IN_NON_WITNESS_UTXO,
+ PSBT_IN_PROPRIETARY,
PSBT_IN_WITNESS_UTXO,
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
+ PSBT_OUT_PROPRIETARY,
PSBT_OUT_TAP_TREE,
PSBT_OUT_SCRIPT,
)
@@ -294,6 +298,65 @@ class PSBTTest(BitcoinTestFramework):
assert "participant_pubkeys" in out_participant_pks
assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()])
+ def test_combinepsbt_preserves_proprietary_fields(self):
+ self.log.info("Test that combining PSBTs preserves proprietary fields")
+
+ def proprietary_key(type_byte, identifier, subtype, key_data=b""):
+ return bytes([type_byte]) + ser_compact_size(len(identifier)) + identifier + ser_compact_size(subtype) + key_data
+
+ def proprietary_entry(key, value, identifier, subtype):
+ return {"identifier": identifier.hex(), "subtype": subtype, "key": key.hex(), "value": value.hex()}
+
+ tx = CTransaction()
+ tx.vin = [CTxIn(outpoint=COutPoint(hash=int('aa' * 32, 16), n=0), scriptSig=b"")]
+ tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+
+ global_key_a = proprietary_key(type_byte=PSBT_GLOBAL_PROPRIETARY, identifier=b"gc", subtype=1, key_data=b"\x01")
+ global_key_b = proprietary_key(type_byte=PSBT_GLOBAL_PROPRIETARY, identifier=b"gc", subtype=2, key_data=b"\x02")
+ input_key_a = proprietary_key(type_byte=PSBT_IN_PROPRIETARY, identifier=b"in", subtype=3, key_data=b"\x03")
+ input_key_b = proprietary_key(type_byte=PSBT_IN_PROPRIETARY, identifier=b"in", subtype=4, key_data=b"\x04")
+ output_key_a = proprietary_key(type_byte=PSBT_OUT_PROPRIETARY, identifier=b"out", subtype=5, key_data=b"\x05")
+ output_key_b = proprietary_key(type_byte=PSBT_OUT_PROPRIETARY, identifier=b"out", subtype=6, key_data=b"\x06")
+
+ psbt1 = PSBT(
+ g=PSBTMap({
+ PSBT_GLOBAL_UNSIGNED_TX: tx.serialize(),
+ global_key_a: b"\xaa",
+ }),
+ i=[PSBTMap({
+ input_key_a: b"\xbb",
+ })],
+ o=[PSBTMap({
+ output_key_a: b"\xcc",
+ })],
+ ).to_base64()
+ psbt2 = PSBT(
+ g=PSBTMap({
+ PSBT_GLOBAL_UNSIGNED_TX: tx.serialize(),
+ global_key_b: b"\xdd",
+ }),
+ i=[PSBTMap({
+ input_key_b: b"\xee",
+ })],
+ o=[PSBTMap({
+ output_key_b: b"\xff",
+ })],
+ ).to_base64()
+
+ decoded = self.nodes[0].decodepsbt(self.nodes[0].combinepsbt([psbt1, psbt2]))
+ assert_equal(decoded["proprietary"], [
+ proprietary_entry(key=global_key_a, value=b"\xaa", identifier=b"gc", subtype=1),
+ proprietary_entry(key=global_key_b, value=b"\xdd", identifier=b"gc", subtype=2),
+ ])
+ assert_equal(decoded["inputs"][0]["proprietary"], [
+ proprietary_entry(key=input_key_a, value=b"\xbb", identifier=b"in", subtype=3),
+ proprietary_entry(key=input_key_b, value=b"\xee", identifier=b"in", subtype=4),
+ ])
+ assert_equal(decoded["outputs"][0]["proprietary"], [
+ proprietary_entry(key=output_key_a, value=b"\xcc", identifier=b"out", subtype=5),
+ proprietary_entry(key=output_key_b, value=b"\xff", identifier=b"out", subtype=6),
+ ])
+
def test_sighash_mismatch(self):
self.log.info("Test sighash type mismatches")
self.nodes[0].createwallet("sighash_mismatch")
@@ -1281,6 +1344,8 @@ class PSBTTest(BitcoinTestFramework):
self.test_decodepsbt_musig2_input_output_types()
+ self.test_combinepsbt_preserves_proprietary_fields()
+
self.log.info("Test that combining PSBTs with different transactions fails")
tx = CTransaction()
tx.vin = [CTxIn(outpoint=COutPoint(hash=int('aa' * 32, 16), n=0), scriptSig=b"")]
Why this scored 15/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.