Skip to content

Commit 6a02816

Browse files
committed
Merge bitcoin/bitcoin#36025: psbt: avoid duplicate taproot leaf script keys when merging
1cb4163 psbt: avoid duplicate taproot leaf script keys when merging (Shuvam Pandey) Pull request description: Follow-up to #35665, which fixed the same combiner defect for `PSBT_GLOBAL_XPUB`. thomasbuilds and winterrdog asked for this one as its own PR when I reported it there. `m_tap_scripts` maps a leaf script to a set of control blocks, but is serialized as one record per control block, keyed by the control block (`SerializeToVector(s, PSBT_IN_TAP_LEAF_SCRIPT, std::span{control_block})`). `PSBTInput::Merge` unions it by the map key, so two PSBTs that map the same control block to different leaf scripts merge into an input that serializes the `0x15` key twice. Duplicate keys make a PSBT invalid, so it is the same `combinepsbt` then `decodepsbt` failure as the xpub case, at the input level. Present since #22558 (v24.0). Both decode on their own, and differ only in the leaf script the control block maps to, `OP_1` against `OP_1 OP_1`: ``` $ A=cHNidP8BADwCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAIhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsACUcAAAA== $ B=cHNidP8BADwCAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAIhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsADUVHAAAA= $ bitcoin-cli -regtest decodepsbt "$(bitcoin-cli -regtest combinepsbt "[\"$A\",\"$B\"]")" error code: -22 error message: TX decode failed Duplicate Key, input key "15c050929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" already provided: unspecified iostream_category error ``` winterrdog reproduced it on the #35665 thread with another pair. Merge the records rather than the map entries, keeping the leaf script already there. BIP 174 lets the combiner "pick arbitrarily when conflicts occur", and unknown and proprietary records already resolve that way. Refusing to combine is the BIP's other option, but that would fail `combinepsbt` on input it accepts today. Merging by map key drops records as well. `std::map::insert` leaves existing keys alone, so when both PSBTs carry the same leaf script with different control blocks, the incoming set was dropped. Those keys do not conflict, so merging per record keeps them. The control blocks already present are collected once per merge rather than searched for per incoming record, which would be quadratic in the size of the two PSBTs `combinepsbt` takes from the caller. Since this is the second field with this shape I checked the rest. `m_xpubs` (#35665) and `m_tap_scripts` are the only two whose record key comes from the value, so two map entries can serialize the same key. `partial_sigs` is keyed by `CKeyID` and serialized under the pubkey, but the pubkey determines the `CKeyID`, so those records stay distinct. The others key the record by the map key, `m_proprietary` included, and `PSBTOutput` has no such field. The test fails on master on both counts, and covers the merges that do not conflict as well. I found this with a local assertion in the psbt fuzz target that a combined PSBT must roundtrip. That assertion can go in a follow-up. Tested: ``` ./build/bin/test_bitcoin --run_test=psbt_tests ./build/bin/test_bitcoin --run_test=psbt_wallet_tests ./build/test/functional/test_runner.py rpc_psbt.py rpc_rawtransaction.py wallet_taproot.py wallet_signer.py feature_taproot.py wallet_basic.py ``` ACKs for top commit: achow101: ACK 1cb4163 winterrdog: re-ACK 1cb4163 Tree-SHA512: 2599beffe701ba9b672e8dcc3d43844f3853ceeb3d86fc53798a428d3288aa8edd2e42f338b32a1046fa558d6cc5cfa5d55b3a0aaf960278b0f3002158381623
2 parents 07d92a9 + 1cb4163 commit 6a02816

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

src/psbt.cpp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include <util/strencodings.h>
1515

1616
#include <algorithm>
17+
#include <set>
1718

1819
using common::PSBTError;
1920

@@ -436,7 +437,17 @@ bool PSBTInput::Merge(const PSBTInput& input)
436437
m_proprietary.insert(input.m_proprietary.begin(), input.m_proprietary.end());
437438
unknown.insert(input.unknown.begin(), input.unknown.end());
438439
m_tap_script_sigs.insert(input.m_tap_script_sigs.begin(), input.m_tap_script_sigs.end());
439-
m_tap_scripts.insert(input.m_tap_scripts.begin(), input.m_tap_scripts.end());
440+
// Merge by control block, the serialized key (BIP 371), to avoid duplicate keys. Keep the
441+
// leaf script already present; BIP 174 lets the Combiner pick arbitrarily on conflict.
442+
std::set<std::vector<unsigned char>> seen_control_blocks;
443+
for (const auto& [_, control_blocks] : m_tap_scripts) {
444+
seen_control_blocks.insert(control_blocks.begin(), control_blocks.end());
445+
}
446+
for (const auto& [leaf, control_blocks] : input.m_tap_scripts) {
447+
for (const auto& control_block : control_blocks) {
448+
if (seen_control_blocks.insert(control_block).second) m_tap_scripts[leaf].insert(control_block);
449+
}
450+
}
440451
m_tap_bip32_paths.insert(input.m_tap_bip32_paths.begin(), input.m_tap_bip32_paths.end());
441452

442453
if (redeem_script.empty() && !input.redeem_script.empty()) redeem_script = input.redeem_script;

test/functional/rpc_psbt.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,15 @@
4343
PSBT_IN_PROPRIETARY,
4444
PSBT_IN_TAP_BIP32_DERIVATION,
4545
PSBT_IN_TAP_INTERNAL_KEY,
46+
PSBT_IN_TAP_LEAF_SCRIPT,
4647
PSBT_IN_WITNESS_UTXO,
4748
PSBT_IN_FINAL_SCRIPTWITNESS,
4849
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
4950
PSBT_OUT_PROPRIETARY,
5051
PSBT_OUT_TAP_TREE,
5152
PSBT_OUT_SCRIPT,
5253
)
53-
from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160
54+
from test_framework.script import CScript, LEAF_VERSION_TAPSCRIPT, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160
5455
from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE, output_key_to_p2tr_script
5556
from test_framework.test_framework import BitcoinTestFramework
5657
from test_framework.util import (
@@ -409,6 +410,66 @@ def psbt_with_origin(fingerprint):
409410
decoded = self.nodes[0].decodepsbt(combined)
410411
assert_equal(decoded["global_xpubs"], [{"xpub": xpub, "master_fingerprint": "00000000", "path": "m"}])
411412

413+
def test_combinepsbt_tap_leaf_script_conflict(self):
414+
self.log.info("Test that combining PSBTs with conflicting leaf scripts for the same control block keeps a single record")
415+
416+
tx = CTransaction()
417+
tx.vin = [CTxIn(outpoint=COutPoint(hash=int('aa' * 32, 16), n=0), scriptSig=b"")]
418+
tx.vout = [CTxOut(nValue=0, scriptPubKey=b"")]
419+
420+
def psbt_with_leaf_scripts(*records):
421+
return PSBT(
422+
g=PSBTMap({PSBT_GLOBAL_UNSIGNED_TX: tx.serialize()}),
423+
i=[PSBTMap({
424+
bytes([PSBT_IN_TAP_LEAF_SCRIPT]) + control_block: bytes(leaf_script) + bytes([LEAF_VERSION_TAPSCRIPT])
425+
for leaf_script, control_block in records
426+
})],
427+
o=[PSBTMap({})],
428+
).to_base64()
429+
430+
def combined_tap_scripts(psbts):
431+
return self.nodes[0].decodepsbt(self.nodes[0].combinepsbt(psbts))["inputs"][0]["taproot_scripts"]
432+
433+
def tap_script(leaf_script, control_blocks):
434+
return {"script": leaf_script.hex(), "leaf_ver": LEAF_VERSION_TAPSCRIPT, "control_blocks": [cb.hex() for cb in control_blocks]}
435+
436+
control_block = bytes([LEAF_VERSION_TAPSCRIPT]) + bytes.fromhex(H_POINT)
437+
control_block_with_path = control_block + bytes(32)
438+
control_block_with_longer_path = control_block + bytes(64)
439+
leaf_script_a = CScript([OP_TRUE])
440+
leaf_script_b = CScript([OP_TRUE, OP_TRUE])
441+
leaf_script_c = CScript([OP_TRUE, OP_TRUE, OP_TRUE])
442+
443+
psbt_a = psbt_with_leaf_scripts((leaf_script_a, control_block))
444+
psbt_b = psbt_with_leaf_scripts((leaf_script_b, control_block))
445+
psbt_c = psbt_with_leaf_scripts((leaf_script_c, control_block))
446+
447+
# The same control block under two leaf scripts would serialize as duplicate keys
448+
assert_equal(combined_tap_scripts([psbt_a, psbt_b]), [tap_script(leaf_script_a, [control_block])])
449+
# Reversed, so the leaf script kept is decided by the argument order and not by its content
450+
assert_equal(combined_tap_scripts([psbt_b, psbt_a]), [tap_script(leaf_script_b, [control_block])])
451+
# A third PSBT conflicting with what the first merge kept is dropped the same way
452+
assert_equal(combined_tap_scripts([psbt_a, psbt_b, psbt_c]), [tap_script(leaf_script_a, [control_block])])
453+
# Combining a PSBT with itself leaves it untouched
454+
assert_equal(self.nodes[0].combinepsbt([psbt_a, psbt_a]), psbt_a)
455+
456+
# Records that do not conflict are all kept, whether or not they share a leaf script
457+
psbt_same_leaf = psbt_with_leaf_scripts((leaf_script_a, control_block_with_path))
458+
assert_equal(combined_tap_scripts([psbt_a, psbt_same_leaf]), [tap_script(leaf_script_a, [control_block, control_block_with_path])])
459+
psbt_other_leaf = psbt_with_leaf_scripts((leaf_script_b, control_block_with_path))
460+
assert_equal(combined_tap_scripts([psbt_a, psbt_other_leaf]), [
461+
tap_script(leaf_script_a, [control_block]),
462+
tap_script(leaf_script_b, [control_block_with_path]),
463+
])
464+
465+
# Only the conflicting control block of an incoming leaf script is dropped, not all of them
466+
psbt_leaf_a_two_blocks = psbt_with_leaf_scripts((leaf_script_a, control_block), (leaf_script_a, control_block_with_path))
467+
psbt_leaf_b_two_blocks = psbt_with_leaf_scripts((leaf_script_b, control_block_with_path), (leaf_script_b, control_block_with_longer_path))
468+
assert_equal(combined_tap_scripts([psbt_leaf_a_two_blocks, psbt_leaf_b_two_blocks]), [
469+
tap_script(leaf_script_a, [control_block, control_block_with_path]),
470+
tap_script(leaf_script_b, [control_block_with_longer_path]),
471+
])
472+
412473
def test_sighash_mismatch(self):
413474
self.log.info("Test sighash type mismatches")
414475
self.nodes[0].createwallet("sighash_mismatch")
@@ -1470,6 +1531,7 @@ def global_xpub_key(extended_pubkey):
14701531

14711532
self.test_combinepsbt_preserves_proprietary_fields()
14721533
self.test_combinepsbt_global_xpub_origin_conflict()
1534+
self.test_combinepsbt_tap_leaf_script_conflict()
14731535

14741536
self.log.info("Test that combining PSBTs with different transactions fails")
14751537
tx = CTransaction()

0 commit comments

Comments
 (0)