Merge bitcoin/bitcoin#35516: rpc: preserve global xpubs and proprietary fields in joinpsbts

436921eb46 test: check joinpsbts preserves global xpubs and proprietary fields (Thomas)
011094b282 rpc: preserve global xpubs and proprietary fields in joinpsbts (Thomas)

Pull request description:

  `joinpsbts` collects the global xpubs of all the joined PSBTs into `merged_psbt`, but returns a separately constructed `shuffled_psbt` into which only the inputs, outputs, and unknown fields are copied. The collected `PSBT_GLOBAL_XPUB` records are silently dropped, and `PSBT_GLOBAL_PROPRIETARY` records are not collected at all.

  The xpub collection was added in #17034, which was written against a `joinpsbts` that still returned `merged_psbt`, but was merged after #16512 had introduced the `shuffled_psbt` rebuild, so the collected xpubs have never reached the result.

  Shuffle the inputs and outputs of `merged_psbt` in place instead of rebuilding a new PSBT, so that all global data is preserved, and union the global proprietary records in the merge loop, matching the `combinepsbt` behavior from #34893.

ACKs for top commit:
  jpk68:
    ACK 436921eb46
  achow101:
    ACK 436921eb46
  winterrdog:
    tACK 436921eb46

Tree-SHA512: d9de34c25aecc29b6b4fb80d6584fa919cc5ff9b7ef2f4d8ce35c4043fe7638fefb8af10448f2cd14021f5d25e149f0efc8798c5b8c9bc8b5582c6152010e891
This commit is contained in:
Ava Chow
2026-08-24 14:38:14 -07:00
2 changed files with 44 additions and 20 deletions

View File

@@ -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);
},
};

View File

@@ -1180,6 +1180,45 @@ class PSBTTest(BitcoinTestFramework):
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]