Merge bitcoin/bitcoin#35665: psbt: avoid duplicate global xpub keys when merging
What changed, and why it matters
This commit fixes a bug in Bitcoin Core's PSBT (Partially Signed Bitcoin Transaction) merging code. When two PSBTs contained the same extended public key (xpub) but with different key origin metadata, the merge would create a result that serialized the same xpub key twice. Because the PSBT standard forbids duplicate keys, the merged PSBT could not be decoded by any Bitcoin Core RPC afterward. The fix deduplicates by xpub during merging, keeping the first origin encountered. It is a correctness/denial-of-service bug rather than a theft-of-funds vulnerability.
No immediate emergency action is required; this is a correctness bug, not a critical exploit. Users and downstream projects should apply the patch and ensure they are not relying on merged PSBTs that may have been rendered unparseable. Wallet and PSBT-tooling maintainers should review their own merge/deduplication logic for similar origin-vs-xpub mismatch issues.
Security signals we found
Fixes a bug that caused combinepsbt to produce an unparseable PSBT
BIP 174 duplicate-key violation in serialized output
Shared merge logic between combinepsbt and joinpsbts
Removal of dead duplicate-tracking code in deserialization
Functional test demonstrates real reproduction vector
Evidence from the diff
PartiallySignedTransaction::Merge and the joinpsbts RPC previously unioned m_xpubs origin-by-origin. Since m_xpubs is keyed by KeyOriginInfo but serialized with one record per xpub (keyed by the xpub bytes), two PSBTs sharing an xpub under different origins produced duplicate PSBT_GLOBAL_XPUB keys. BIP 174 declares such PSBTs invalid, and Unserialize rejects them with ‘Duplicate Key’. The patch introduces MergeGlobalXPubs, which checks whether an xpub already exists in any origin before inserting, preserving the existing origin on conflict. It also removes the now-unused global_xpubs tracking set from Unserialize and adds a functional test reproducing the combinepsbt failure.
Changed components
src/psbt.cppsrc/psbt.hsrc/rpc/rawtransaction.cpptest/functional/rpc_psbt.pyInspect captured patch +46 / −18
### src/psbt.cpp
@@ -13,6 +13,8 @@
#include <util/result.h>
#include <util/strencodings.h>
+#include <algorithm>
+
using common::PSBTError;
PartiallySignedTransaction::PartiallySignedTransaction(const CMutableTransaction& tx, uint32_t version) : m_version(version)
@@ -53,13 +55,7 @@ bool PartiallySignedTransaction::Merge(const PartiallySignedTransaction& psbt)
return false;
}
}
- for (auto& xpub_pair : psbt.m_xpubs) {
- if (!m_xpubs.contains(xpub_pair.first)) {
- m_xpubs[xpub_pair.first] = xpub_pair.second;
- } else {
- m_xpubs[xpub_pair.first].insert(xpub_pair.second.begin(), xpub_pair.second.end());
- }
- }
+ MergeGlobalXPubs(psbt);
if (fallback_locktime == std::nullopt && psbt.fallback_locktime != std::nullopt) fallback_locktime = psbt.fallback_locktime;
// Set m_tx_modifiable only if either PSBT had it set
@@ -80,6 +76,16 @@ bool PartiallySignedTransaction::Merge(const PartiallySignedTransaction& psbt)
return true;
}
+void PartiallySignedTransaction::MergeGlobalXPubs(const PartiallySignedTransaction& psbt)
+{
+ for (const auto& [origin, xpubs] : psbt.m_xpubs) {
+ for (const CExtPubKey& xpub : xpubs) {
+ const bool known{std::ranges::any_of(m_xpubs, [&](const auto& entry) { return entry.second.contains(xpub); })};
+ if (!known) m_xpubs[origin].insert(xpub);
+ }
+ }
+}
+
std::optional<uint32_t> PartiallySignedTransaction::ComputeTimeLock() const
{
if (GetVersion() >= 2) {
### src/psbt.h
@@ -1257,6 +1257,9 @@ class PartiallySignedTransaction
/** Merge psbt into this. The two psbts must have the same underlying CTransaction (i.e. the
* same actual Bitcoin transaction.) Returns true if the merge succeeded, false otherwise. */
[[nodiscard]] bool Merge(const PartiallySignedTransaction& psbt);
+ /** Merge the global xpubs of psbt into this, keeping the existing origin for an xpub
+ * seen again with a different one, as the serialized records are keyed by xpub. */
+ void MergeGlobalXPubs(const PartiallySignedTransaction& psbt);
bool AddInput(const PSBTInput& psbtin);
bool AddOutput(const PSBTOutput& psbtout);
std::optional<uint32_t> ComputeTimeLock() const;
@@ -1354,9 +1357,6 @@ class PartiallySignedTransaction
// Used for duplicate key detection
std::set<std::vector<unsigned char>> key_lookup;
- // Track the global xpubs we have already seen. Just for sanity checking
- std::set<CExtPubKey> global_xpubs;
-
// Read global data
bool found_sep = false;
std::optional<CMutableTransaction> tx;
@@ -1459,7 +1459,6 @@ class PartiallySignedTransaction
if (!xpub.pubkey.IsFullyValid()) {
throw std::ios_base::failure("Invalid pubkey");
}
- global_xpubs.insert(xpub);
// Read in the keypath from stream
KeyOriginInfo keypath;
DeserializeHDKeypath(s, keypath);
### src/rpc/rawtransaction.cpp
@@ -1927,13 +1927,7 @@ static RPCMethod joinpsbts()
for (const PSBTOutput& output : psbt.outputs) {
merged_psbt.AddOutput(output);
}
- for (auto& xpub_pair : psbt.m_xpubs) {
- if (!merged_psbt.m_xpubs.contains(xpub_pair.first)) {
- merged_psbt.m_xpubs[xpub_pair.first] = xpub_pair.second;
- } else {
- merged_psbt.m_xpubs[xpub_pair.first].insert(xpub_pair.second.begin(), xpub_pair.second.end());
- }
- }
+ merged_psbt.MergeGlobalXPubs(psbt);
merged_psbt.unknown.insert(psbt.unknown.begin(), psbt.unknown.end());
}
### test/functional/rpc_psbt.py
@@ -8,6 +8,7 @@
from itertools import product
from random import randbytes
+from test_framework.address import base58_to_byte
from test_framework.blocktools import (
MAX_STANDARD_TX_WEIGHT,
)
@@ -28,6 +29,7 @@
PSBT_GLOBAL_PROPRIETARY,
PSBT_GLOBAL_UNSIGNED_TX,
PSBT_GLOBAL_VERSION,
+ PSBT_GLOBAL_XPUB,
PSBT_IN_RIPEMD160,
PSBT_IN_SHA256,
PSBT_IN_SIGHASH_TYPE,
@@ -359,6 +361,32 @@ def proprietary_entry(key, value, identifier, subtype):
proprietary_entry(key=output_key_b, value=b"\xff", identifier=b"out", subtype=6),
])
+ def test_combinepsbt_global_xpub_origin_conflict(self):
+ self.log.info("Test that combining PSBTs with conflicting origins for the same xpub keeps a single record")
+
+ tx = CTransaction()
+ tx.vin = [CTxIn(outpoint=COutPoint(hash=int('aa' * 32, 16), n=0), scriptSig=b"")]
+ tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
+
+ xpub = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp"
+ xpub_data, xpub_version = base58_to_byte(xpub)
+ xpub_key = bytes([PSBT_GLOBAL_XPUB]) + bytes([xpub_version]) + xpub_data
+
+ def psbt_with_origin(fingerprint):
+ return PSBT(
+ g=PSBTMap({
+ PSBT_GLOBAL_UNSIGNED_TX: tx.serialize(),
+ xpub_key: fingerprint,
+ }),
+ i=[PSBTMap({})],
+ o=[PSBTMap({})],
+ ).to_base64()
+
+ combined = self.nodes[0].combinepsbt([psbt_with_origin(b"\x00\x00\x00\x00"), psbt_with_origin(b"\x11\x11\x11\x11")])
+ # The same xpub under both origins would serialize as duplicate keys, making the combined PSBT unparseable
+ decoded = self.nodes[0].decodepsbt(combined)
+ assert_equal(decoded["global_xpubs"], [{"xpub": xpub, "master_fingerprint": "00000000", "path": "m"}])
+
def test_sighash_mismatch(self):
self.log.info("Test sighash type mismatches")
self.nodes[0].createwallet("sighash_mismatch")
@@ -1380,6 +1408,7 @@ def test_psbt_input_keys(psbt_input, keys):
self.test_decodepsbt_musig2_input_output_types()
self.test_combinepsbt_preserves_proprietary_fields()
+ self.test_combinepsbt_global_xpub_origin_conflict()
self.log.info("Test that combining PSBTs with different transactions fails")
tx = CTransaction()Why this scored 49/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.