Merge bitcoin/bitcoin#35665: psbt: avoid duplicate global xpub keys when merging

6d387af562 psbt: remove write-only global xpub tracking set (Thomas)
3b7051c7e3 test: check combinepsbt with conflicting global xpub origins (Thomas)
7c632c0e2a psbt: avoid duplicate global xpub keys when merging (Thomas)

Pull request description:

  Global xpubs are stored in a map of key origin to set of xpubs, while the serialization writes one record per xpub, keyed by the xpub. `Merge` unions the map origin-by-origin, so when the combined PSBTs provide different key origins for the same xpub, the result serializes the same `PSBT_GLOBAL_XPUB` key twice. BIP 174 declares PSBTs with duplicate keys invalid and the deserializer rejects them, so `combinepsbt` returns a PSBT that no RPC can parse again. This affects all releases since the merge loop was added in #17034 (v23.0).

  <details><summary>Reproduction on master</summary>

  The PSBTs share the unsigned transaction and xpub, and differ only in the master fingerprint of the global xpub record (`00000000` vs `11111111`):

  ```
  $ A=cHNidP8BADwCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAD/////AQAAAAAAAAAAAAAAAABPAQQ1h88AAAAAAAAAAACHPf+BwC9SViP9H+UWfqw6VaBJ3j0xS7Qu4if/7TfVCAM5o2ATMBWX2u9B++WToCzFE9C1VSfsLfEFDi6P9JyFwgQAAAAAAAAA
  $ B=cHNidP8BADwCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAD/////AQAAAAAAAAAAAAAAAABPAQQ1h88AAAAAAAAAAACHPf+BwC9SViP9H+UWfqw6VaBJ3j0xS7Qu4if/7TfVCAM5o2ATMBWX2u9B++WToCzFE9C1VSfsLfEFDi6P9JyFwgQRERERAAAA
  $ bitcoin-cli -regtest decodepsbt "$(bitcoin-cli -regtest combinepsbt "[\"$A\",\"$B\"]")"
  error code: -22
  error message:
  TX decode failed Duplicate Key, global key "01043587cf00...9c85c2" already provided: iostream error
  ```

  </details>

  Deduplicate by xpub when merging, keeping the origin that is already present: BIP 174 lets the Combiner "pick arbitrarily when conflicts occur", and conflicting unknown and proprietary records are already resolved the same way. The logic is shared between `combinepsbt` and `joinpsbts` through a new `MergeGlobalXPubs` helper. The second commit adds a test that fails on master with the error above, and the last commit removes the `global_xpubs` tracking set in `Unserialize`, write-only since the generic duplicate key check introduced in #21283 (1e2d146b47) replaced the explicit one.

  Note: the xpub loop in `joinpsbts` currently has no observable effect, since the collected xpubs never reach the returned PSBT. My #35516 fixes that, so this PR should land first: on its own, #35516 would make the same duplicate key issue reachable through `joinpsbts`, while with the shared helper in place it never becomes reachable. I will rebase #35516 on top afterwards.

ACKs for top commit:
  Bicaru20:
    tACK 6d387af562.
  achow101:
    ACK 6d387af562
  winterrdog:
    tACK 6d387af562

Tree-SHA512: e2a9e02617eeec22a9240d7cf9386ee880a5f3639b143df7de4d8ea3e7b808f8c123f0b0410ff4a22e9a564bd86b2335a5c4aa3b2281af47d111484a6f1fd108
This commit is contained in:
Ava Chow
2026-08-19 10:58:34 -07:00
4 changed files with 46 additions and 18 deletions

View File

@@ -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) {