From da769855d0ce60c4a5ded844a4d4c43917a6e027 Mon Sep 17 00:00:00 2001 From: w0xlt <94266259+w0xlt@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:13:00 -0700 Subject: [PATCH] test: add PSBT proprietary merge regression coverage Add unit and functional regression tests asserting that combine/merge preserves proprietary fields at the global, input, and output scopes. --- src/test/psbt_tests.cpp | 49 ++++++++++++++++++++++++++++ test/functional/rpc_psbt.py | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/test/psbt_tests.cpp b/src/test/psbt_tests.cpp index 74690d32f1b..c8e1206e5ed 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 timelock) { util::Result 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 4703ee3be11..9aad09a086e 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"")]