Merge bitcoin/bitcoin#35516: rpc: preserve global xpubs and proprietary fields in joinpsbts
What changed, and why it matters
This commit fixes a bug in Bitcoin Core's `joinpsbts` RPC command. When joining multiple PSBTs (Partially Signed Bitcoin Transactions), the command was accidentally dropping some global metadata—specifically global xpubs (extended public keys used for wallet coordination) and proprietary fields (vendor-specific data). The fix makes the command preserve this data instead of rebuilding the result PSBT from scratch, which had been silently discarding it. This is a data-loss bug rather than a theft-of-funds vulnerability, but losing xpubs or proprietary fields can break multi-party wallet workflows that rely on `joinpsbts`.
Apply the patch and run the updated functional test `rpc_psbt.py`. Users relying on `joinpsbts` for multi-sig or hardware-wallet workflows should upgrade to a release containing this fix, as missing global xpubs can prevent proper transaction signing or key derivation. No immediate emergency response is required because no funds can be directly stolen.
Security signals we found
Data loss in RPC output: global xpubs and proprietary fields silently omitted
Regression introduced by prior refactor that added shuffled_psbt rebuild
Fix aligns joinpsbts with combinepsbt proprietary-field behavior (#34893)
Functional test added to prevent regression
Evidence from the diff
The joinpsbts RPC previously merged inputs/outputs/xpubs into merged_psbt, then constructed a fresh shuffled_psbt containing only inputs, outputs, and unknown fields. Because the shuffle rebuilt the PSBT, PSBT_GLOBAL_XPUB records collected by MergeGlobalXPubs() and PSBT_GLOBAL_PROPRIETARY records were lost. The patch removes the rebuild, shuffles merged_psbt.inputs and merged_psbt.outputs in place, unions m_proprietary during the merge loop, and serializes merged_psbt directly. A regression test verifies preservation of global xpubs, proprietary fields, first-wins collision behavior for proprietary keys, and first-wins behavior for conflicting xpub origins.
Changed components
src/rpc/rawtransaction.cppRPC command: joinpsbtsPSBT global fields: PSBT_GLOBAL_XPUB, PSBT_GLOBAL_PROPRIETARY, unknownInspect captured patch +44 / −20
### src/rpc/rawtransaction.cpp
@@ -43,7 +43,6 @@
#include <validationinterface.h>
#include <cstdint>
-#include <numeric>
#include <univalue.h>
@@ -1929,30 +1928,16 @@ static RPCMethod joinpsbts()
merged_psbt.AddOutput(output);
}
merged_psbt.MergeGlobalXPubs(psbt);
+ merged_psbt.m_proprietary.insert(psbt.m_proprietary.begin(), psbt.m_proprietary.end());
merged_psbt.unknown.insert(psbt.unknown.begin(), psbt.unknown.end());
}
- // Generate list of shuffled indices for shuffling inputs and outputs of the merged PSBT
- std::vector<int> input_indices(merged_psbt.inputs.size());
- std::iota(input_indices.begin(), input_indices.end(), 0);
- std::vector<int> output_indices(merged_psbt.outputs.size());
- std::iota(output_indices.begin(), output_indices.end(), 0);
-
- // Shuffle input and output indices lists
- std::shuffle(input_indices.begin(), input_indices.end(), FastRandomContext());
- std::shuffle(output_indices.begin(), output_indices.end(), FastRandomContext());
-
- PartiallySignedTransaction shuffled_psbt(tx, merged_psbt.GetVersion());
- for (int i : input_indices) {
- shuffled_psbt.AddInput(merged_psbt.inputs[i]);
- }
- for (int i : output_indices) {
- shuffled_psbt.AddOutput(merged_psbt.outputs[i]);
- }
- shuffled_psbt.unknown.insert(merged_psbt.unknown.begin(), merged_psbt.unknown.end());
+ // Shuffle the inputs and outputs for privacy
+ std::shuffle(merged_psbt.inputs.begin(), merged_psbt.inputs.end(), FastRandomContext());
+ std::shuffle(merged_psbt.outputs.begin(), merged_psbt.outputs.end(), FastRandomContext());
DataStream ssTx{};
- ssTx << shuffled_psbt;
+ ssTx << merged_psbt;
return EncodeBase64(ssTx);
},
};
### test/functional/rpc_psbt.py
@@ -1180,6 +1180,45 @@ def test_psbt_input_keys(psbt_input, keys):
break
assert shuffled
+ # Check that joining preserves global xpub and proprietary records
+ def global_xpub_key(extended_pubkey):
+ xpub_data, xpub_version = base58_to_byte(extended_pubkey)
+ return bytes([PSBT_GLOBAL_XPUB]) + bytes([xpub_version]) + xpub_data
+
+ xpub1 = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp"
+ xpub_key1 = global_xpub_key(xpub1)
+ xpub_key2 = global_xpub_key("tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B")
+ xpub_value = b"\x00\x00\x00\x00" # master key fingerprint with an empty derivation path
+ global_prop_key = bytes([PSBT_GLOBAL_PROPRIETARY]) + b"\x02\x01\x02\x00" # identifier "0102", subtype 0
+ global_prop_value = b"\xde\xad\xbe\xef"
+
+ psbt1_obj = PSBT.from_base64(psbt1)
+ psbt1_obj.g.map[xpub_key1] = xpub_value
+ psbt1_obj.g.map[global_prop_key] = global_prop_value
+ psbt2_obj = PSBT.from_base64(psbt2)
+ psbt2_obj.g.map[xpub_key2] = xpub_value
+ joined_globals = PSBT.from_base64(self.nodes[0].joinpsbts([psbt1_obj.to_base64(), psbt2_obj.to_base64()]))
+ assert_equal(joined_globals.g.map[xpub_key1], xpub_value)
+ assert_equal(joined_globals.g.map[xpub_key2], xpub_value)
+ assert_equal(joined_globals.g.map[global_prop_key], global_prop_value)
+
+ # Same proprietary key in both PSBTs with different values: the first PSBT's value wins
+ collide_key = bytes([PSBT_GLOBAL_PROPRIETARY]) + b"\x02\x03\x04\x00"
+ psbt_first_obj = PSBT.from_base64(psbt1)
+ psbt_first_obj.g.map[collide_key] = b"\x11\x11\x11\x11"
+ psbt_second_obj = PSBT.from_base64(psbt2)
+ psbt_second_obj.g.map[collide_key] = b"\x22\x22\x22\x22"
+ joined_collision = PSBT.from_base64(self.nodes[0].joinpsbts([psbt_first_obj.to_base64(), psbt_second_obj.to_base64()]))
+ assert_equal(joined_collision.g.map[collide_key], b"\x11\x11\x11\x11")
+
+ # Same xpub with conflicting origins: the first PSBT's origin is kept, avoiding duplicate keys
+ conflict_first_obj = PSBT.from_base64(psbt1)
+ conflict_first_obj.g.map[xpub_key1] = xpub_value
+ conflict_second_obj = PSBT.from_base64(psbt2)
+ conflict_second_obj.g.map[xpub_key1] = b"\x11\x11\x11\x11"
+ joined_conflict = self.nodes[0].joinpsbts([conflict_first_obj.to_base64(), conflict_second_obj.to_base64()])
+ assert_equal(self.nodes[0].decodepsbt(joined_conflict)["global_xpubs"], [{"xpub": xpub1, "master_fingerprint": "00000000", "path": "m"}])
+
# Newly created PSBT needs UTXOs and updating
addr = self.nodes[1].getnewaddress("", "p2sh-segwit")
utxo = self.create_outpoints(self.nodes[0], outputs=[{addr: 7}])[0]Why this scored 38/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.