Merge bitcoin/bitcoin#36025: psbt: avoid duplicate taproot leaf script 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 Taproot control block but mapped it to different leaf scripts, the old code would produce an invalid PSBT with duplicate keys, causing tools like `combinepsbt` followed by `decodepsbt` to fail. The fix merges records by their serialized key (the control block) rather than by logical map entries, keeping one leaf script arbitrarily when there is a conflict and preserving non-conflicting records. It also prevents dropping valid records that shared a leaf script but had different control blocks.
Apply the patch. It is a targeted, well-tested fix with no breaking API changes. Users relying on `combinepsbt` for Taproot PSBTs should upgrade to a release containing this fix to avoid invalid combined PSBTs or lost leaf script data.
Security signals we found
Denial of service / availability impact: `combinepsbt` can produce a PSBT that `decodepsbt` rejects with 'Duplicate Key'
Data integrity impact: prior merge could silently drop valid Taproot leaf script records that shared a leaf script but had different control blocks
Taproot PSBT input field affected: `PSBT_IN_TAP_LEAF_SCRIPT` (BIP 371)
Follow-up to a similar combiner defect fixed in PR #35665 for `PSBT_GLOBAL_XPUB`
Present since PR #22558 (v24.0)
Evidence from the diff
The patch changes PSBTInput::Merge in src/psbt.cpp. Previously, m_tap_scripts (a std::map<CScript, std::set<std::vector<unsigned char>>>) was merged via std::map::insert, which unions by leaf script. Because the serialized PSBT record key is the control block, two different leaf scripts under the same control block produced two records with identical keys (PSBT_IN_TAP_LEAF_SCRIPT || control_block), violating BIP 174’s unique-key rule and making the combined PSBT unparseable. Conversely, the same leaf script with different control blocks could lose records because insert skips existing keys. The new code collects all already-present control blocks into a std::set, then iterates incoming leaf scripts and control blocks, inserting only control blocks not yet seen. On conflict it keeps the existing leaf script, which BIP 174 permits. A functional test in rpc_psbt.py covers duplicate-key conflicts, argument-order behavior, self-combination, and preservation of non-conflicting records.
Changed components
src/psbt.cpp - PSBTInput::Mergetest/functional/rpc_psbt.py - new test_combinepsbt_tap_leaf_script_conflictInspect captured patch +75 / −2
### src/psbt.cpp
@@ -14,6 +14,7 @@
#include <util/strencodings.h>
#include <algorithm>
+#include <set>
using common::PSBTError;
@@ -436,7 +437,17 @@ bool PSBTInput::Merge(const PSBTInput& input)
m_proprietary.insert(input.m_proprietary.begin(), input.m_proprietary.end());
unknown.insert(input.unknown.begin(), input.unknown.end());
m_tap_script_sigs.insert(input.m_tap_script_sigs.begin(), input.m_tap_script_sigs.end());
- m_tap_scripts.insert(input.m_tap_scripts.begin(), input.m_tap_scripts.end());
+ // Merge by control block, the serialized key (BIP 371), to avoid duplicate keys. Keep the
+ // leaf script already present; BIP 174 lets the Combiner pick arbitrarily on conflict.
+ std::set<std::vector<unsigned char>> seen_control_blocks;
+ for (const auto& [_, control_blocks] : m_tap_scripts) {
+ seen_control_blocks.insert(control_blocks.begin(), control_blocks.end());
+ }
+ for (const auto& [leaf, control_blocks] : input.m_tap_scripts) {
+ for (const auto& control_block : control_blocks) {
+ if (seen_control_blocks.insert(control_block).second) m_tap_scripts[leaf].insert(control_block);
+ }
+ }
m_tap_bip32_paths.insert(input.m_tap_bip32_paths.begin(), input.m_tap_bip32_paths.end());
if (redeem_script.empty() && !input.redeem_script.empty()) redeem_script = input.redeem_script;
### test/functional/rpc_psbt.py
@@ -43,14 +43,15 @@
PSBT_IN_PROPRIETARY,
PSBT_IN_TAP_BIP32_DERIVATION,
PSBT_IN_TAP_INTERNAL_KEY,
+ PSBT_IN_TAP_LEAF_SCRIPT,
PSBT_IN_WITNESS_UTXO,
PSBT_IN_FINAL_SCRIPTWITNESS,
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
PSBT_OUT_PROPRIETARY,
PSBT_OUT_TAP_TREE,
PSBT_OUT_SCRIPT,
)
-from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160
+from test_framework.script import CScript, LEAF_VERSION_TAPSCRIPT, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160
from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE, output_key_to_p2tr_script
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
@@ -409,6 +410,66 @@ def psbt_with_origin(fingerprint):
decoded = self.nodes[0].decodepsbt(combined)
assert_equal(decoded["global_xpubs"], [{"xpub": xpub, "master_fingerprint": "00000000", "path": "m"}])
+ def test_combinepsbt_tap_leaf_script_conflict(self):
+ self.log.info("Test that combining PSBTs with conflicting leaf scripts for the same control block 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"")]
+
+ def psbt_with_leaf_scripts(*records):
+ return PSBT(
+ g=PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()}),
+ i=[PSBTMap({
+ bytes([PSBT_IN_TAP_LEAF_SCRIPT]) + control_block: bytes(leaf_script) + bytes([LEAF_VERSION_TAPSCRIPT])
+ for leaf_script, control_block in records
+ })],
+ o=[PSBTMap({})],
+ ).to_base64()
+
+ def combined_tap_scripts(psbts):
+ return self.nodes[0].decodepsbt(self.nodes[0].combinepsbt(psbts))["inputs"][0]["taproot_scripts"]
+
+ def tap_script(leaf_script, control_blocks):
+ return {"script": leaf_script.hex(), "leaf_ver": LEAF_VERSION_TAPSCRIPT, "control_blocks": [cb.hex() for cb in control_blocks]}
+
+ control_block = bytes([LEAF_VERSION_TAPSCRIPT]) + bytes.fromhex(H_POINT)
+ control_block_with_path = control_block + bytes(32)
+ control_block_with_longer_path = control_block + bytes(64)
+ leaf_script_a = CScript([OP_TRUE])
+ leaf_script_b = CScript([OP_TRUE, OP_TRUE])
+ leaf_script_c = CScript([OP_TRUE, OP_TRUE, OP_TRUE])
+
+ psbt_a = psbt_with_leaf_scripts((leaf_script_a, control_block))
+ psbt_b = psbt_with_leaf_scripts((leaf_script_b, control_block))
+ psbt_c = psbt_with_leaf_scripts((leaf_script_c, control_block))
+
+ # The same control block under two leaf scripts would serialize as duplicate keys
+ assert_equal(combined_tap_scripts([psbt_a, psbt_b]), [tap_script(leaf_script_a, [control_block])])
+ # Reversed, so the leaf script kept is decided by the argument order and not by its content
+ assert_equal(combined_tap_scripts([psbt_b, psbt_a]), [tap_script(leaf_script_b, [control_block])])
+ # A third PSBT conflicting with what the first merge kept is dropped the same way
+ assert_equal(combined_tap_scripts([psbt_a, psbt_b, psbt_c]), [tap_script(leaf_script_a, [control_block])])
+ # Combining a PSBT with itself leaves it untouched
+ assert_equal(self.nodes[0].combinepsbt([psbt_a, psbt_a]), psbt_a)
+
+ # Records that do not conflict are all kept, whether or not they share a leaf script
+ psbt_same_leaf = psbt_with_leaf_scripts((leaf_script_a, control_block_with_path))
+ assert_equal(combined_tap_scripts([psbt_a, psbt_same_leaf]), [tap_script(leaf_script_a, [control_block, control_block_with_path])])
+ psbt_other_leaf = psbt_with_leaf_scripts((leaf_script_b, control_block_with_path))
+ assert_equal(combined_tap_scripts([psbt_a, psbt_other_leaf]), [
+ tap_script(leaf_script_a, [control_block]),
+ tap_script(leaf_script_b, [control_block_with_path]),
+ ])
+
+ # Only the conflicting control block of an incoming leaf script is dropped, not all of them
+ psbt_leaf_a_two_blocks = psbt_with_leaf_scripts((leaf_script_a, control_block), (leaf_script_a, control_block_with_path))
+ psbt_leaf_b_two_blocks = psbt_with_leaf_scripts((leaf_script_b, control_block_with_path), (leaf_script_b, control_block_with_longer_path))
+ assert_equal(combined_tap_scripts([psbt_leaf_a_two_blocks, psbt_leaf_b_two_blocks]), [
+ tap_script(leaf_script_a, [control_block, control_block_with_path]),
+ tap_script(leaf_script_b, [control_block_with_longer_path]),
+ ])
+
def test_sighash_mismatch(self):
self.log.info("Test sighash type mismatches")
self.nodes[0].createwallet("sighash_mismatch")
@@ -1470,6 +1531,7 @@ def global_xpub_key(extended_pubkey):
self.test_combinepsbt_preserves_proprietary_fields()
self.test_combinepsbt_global_xpub_origin_conflict()
+ self.test_combinepsbt_tap_leaf_script_conflict()
self.log.info("Test that combining PSBTs with different transactions fails")
tx = CTransaction()Why this scored 50/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.