psbt: avoid duplicate taproot leaf script keys when merging #36025

pull shuv-amp wants to merge 1 commits into bitcoin:master from shuv-amp:fix-psbt-tapleaf-dupkey changing 2 files +76 −2
  1. shuv-amp commented at 3:29 PM on August 19, 2026: contributor

    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
    
  2. DrahtBot added the label PSBT on Aug 19, 2026
  3. DrahtBot commented at 3:29 PM on August 19, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36025.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    ACK thomasbuilds
    Approach ACK winterrdog

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35933 (psbt: don't abort on invalid MuSig2 derivations by l0rinc)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. achow101 commented at 6:28 PM on August 19, 2026: member

    Please rebase

  5. shuv-amp force-pushed on Aug 19, 2026
  6. shuv-amp commented at 7:04 PM on August 19, 2026: contributor

    Rebased on master.

  7. DrahtBot added the label CI failed on Aug 19, 2026
  8. DrahtBot commented at 7:09 PM on August 19, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task Alpine (musl): https://github.com/bitcoin/bitcoin/actions/runs/32290632833/job/96190361218</sub> <sub>LLM reason (✨ experimental): CI failed because the test_kernel CTest job aborted during setup with an out-of-memory (exp: Out of memory) system_error.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  9. DrahtBot removed the label CI failed on Aug 19, 2026
  10. winterrdog commented at 8:19 PM on August 19, 2026: contributor

    concept ACK

    from the developer-notes:

    For a simple feature or bug fix without existing coverage, the change and its test can often be in the same commit.

    shouldn't both the test and bug fix happen in a single commit ?

  11. shuv-amp commented at 8:35 PM on August 19, 2026: contributor

    Either works here. I split them to match #35665, which kept the fix and its test as separate commits. I can squash them if you'd prefer.

  12. in src/psbt.cpp:443 in 6b09708464
     436 | @@ -436,7 +437,19 @@ bool PSBTInput::Merge(const PSBTInput& input)
     437 |      m_proprietary.insert(input.m_proprietary.begin(), input.m_proprietary.end());
     438 |      unknown.insert(input.unknown.begin(), input.unknown.end());
     439 |      m_tap_script_sigs.insert(input.m_tap_script_sigs.begin(), input.m_tap_script_sigs.end());
     440 | -    m_tap_scripts.insert(input.m_tap_scripts.begin(), input.m_tap_scripts.end());
     441 | +    // Leaf scripts are serialized as one record per control block, keyed by the control block, so
     442 | +    // merging the maps entry by entry can put the same control block under two leaf scripts and
     443 | +    // serialize that key twice, which the deserializer rejects. Merge the records instead and keep
     444 | +    // the leaf script we already have, as BIP 174 lets the Combiner pick arbitrarily on conflict.
    


    winterrdog commented at 9:23 PM on August 19, 2026:

    i think this comment can be shortened because it explains the whole merge behavior in detail

    the main thing worth documenting here is the key invariant: the control block is the serialised key, so we need to merge by it to avoid duplicate keys. the conflict behavior can then be stated briefly as a consequence of keeping the existing record. as a result, the comment just focuses on the non-obvious part of the implementation

    i am thinking of sth like this:

    // Merge by control block, which is the serialized key, to avoid duplicate keys.
    // Keep the existing leaf script when control blocks conflict.
    

    thoughts ?


    shuv-amp commented at 12:22 AM on August 20, 2026:

    Fair, it was explaining the whole merge. Used your version with two changes:

    // 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.

    371 is where the control block being the key comes from, and without the 174 clause keep the existing one looks like a tie-break someone invented rather than something the spec allows.

  13. in test/functional/rpc_psbt.py:408 in 6b09708464
     403 | +                })],
     404 | +                o=[PSBTMap({})],
     405 | +            ).to_base64()
     406 | +
     407 | +        def combined_tap_scripts(psbts):
     408 | +            # decodepsbt fails if the combined PSBT serializes the same key twice
    


    winterrdog commented at 10:42 PM on August 19, 2026:

    i think this is obvious from the PR description and commit log. so, i do not think it requires a mention here as well


    shuv-amp commented at 12:20 AM on August 20, 2026:

    Removed.

  14. in test/functional/rpc_psbt.py:442 in 6b09708464 outdated
     437 | +        psbt_other_leaf = psbt_with_leaf_script(leaf_script_b, control_block_with_path)
     438 | +        assert_equal(combined_tap_scripts([psbt_a, psbt_other_leaf]), [
     439 | +            tap_script(leaf_script_a, [control_block]),
     440 | +            tap_script(leaf_script_b, [control_block_with_path]),
     441 | +        ])
     442 | +
    


    winterrdog commented at 10:48 PM on August 19, 2026:

    we can also add a case whereby an incoming leaf has multiple control blocks and some of them conflict with existing control blocks while others do not, for instance leaf A can have control blocks X and Y merged with leaf B that has control blocks Y and Z -- merging results into leaf A having X and Y, then, leaf B having only Z. this will put the per-control-block merge logic to the test and verify that records that do not conflict are not dropped alongside conflicting ones

    i had sth like this in mind:

    diff --git a/test/functional/rpc_psbt.py b/test/functional/rpc_psbt.py
    index 2025f4eef2..474d66e0eb 100755
    --- a/test/functional/rpc_psbt.py
    +++ b/test/functional/rpc_psbt.py
    @@ -438,10 +438,20 @@ class PSBTTest(BitcoinTestFramework):
             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]),
             ])
    
    +        # an incoming leaf can carry several control blocks where only some conflict: A -> {X,Y}
    +        # merged with B -> {Y,Z} must keep A untouched and only drop Y from B, not all of B
    +        control_block_with_longer_path = control_block + bytes(64)
    +        psbt_leaf_a_xy = psbt_with_tap_scripts([(leaf_script_a, control_block), (leaf_script_a, control_block_with_path)])
    +        psbt_leaf_b_yz = psbt_with_tap_scripts([(leaf_script_b, control_block_with_path), (leaf_script_b, control_block_with_longer_path)])
    +        assert_equal(combined_tap_scripts([psbt_leaf_a_xy, psbt_leaf_b_yz]), [
    +            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")
             wallet = self.nodes[0].get_wallet_rpc("sighash_mismatch")
             def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
    

    shuv-amp commented at 12:21 AM on August 20, 2026:

    Added. That combination wasn't covered: every existing case has at most one control block per incoming leaf, so nothing exercised "some clash, some don't".

    It also catches something the others don't. If the merge is changed to drop the whole incoming leaf whenever any of its control blocks conflicts, the other six assertions still pass and only this one fails.

  15. in test/functional/rpc_psbt.py:428 in 6b09708464
     423 | +        psbt_c = psbt_with_leaf_script(leaf_script_c, control_block)
     424 | +
     425 | +        # The same control block under two leaf scripts would serialize as duplicate keys
     426 | +        assert_equal(combined_tap_scripts([psbt_a, psbt_b]), [tap_script(leaf_script_a, [control_block])])
     427 | +        # The first PSBT wins, so the order of the arguments decides which leaf script we keep
     428 | +        assert_equal(combined_tap_scripts([psbt_b, psbt_a]), [tap_script(leaf_script_b, [control_block])])
    


    winterrdog commented at 10:57 PM on August 19, 2026:

    just curious.

    i noticed that this tests the same conflict as the case before it, but with the PSBTs reversed. what is the motive for doing this ?

    i am not sure what distinct behavior this is intended to cover compared to the previous case. is there a specific property of the merge that this is meant to guarantee ?


    shuv-amp commented at 12:26 AM on August 20, 2026:

    The property is that the leaf script kept is decided by the order the PSBTs are passed in, not by the scripts themselves.

    leaf_script_a is OP_1 and leaf_script_b is OP_1 OP_1, and m_tap_scripts is keyed by the script, so A also sorts first. In the forward case the survivor is both the first argument and the lower-sorting one, so on its own it can't tell you which rule produced it.

    If the merge keeps the lower-sorting leaf script on conflict instead of the one already there, which BIP 174 also allows, the whole test still passes except this one assertion.

    The comment didn't say any of that, so:

    # Reversed, so the leaf script kept is decided by the argument order and not by its content
  16. winterrdog commented at 11:32 PM on August 19, 2026: contributor

    approach ACK

    I can squash them if you'd prefer.

    much better

  17. psbt: avoid duplicate taproot leaf script keys when merging
    m_tap_scripts maps a leaf script to a set of control blocks, but is serialized
    one record per control block, keyed by the 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 serializing the 0x15 key twice.
    Duplicate keys are invalid, so combinepsbt hands back a PSBT that can no longer
    be decoded. Present since #22558 (v24.0).
    
    Merge the records instead of the map entries, keeping the leaf script already
    there, as BIP 174 lets the Combiner pick arbitrarily when conflicts occur. The
    control blocks already present are collected once rather than searched for per
    incoming record, which would be quadratic in the size of the PSBTs.
    
    Control blocks under a leaf script that both PSBTs carry are now kept as well,
    where the map level union dropped them.
    
    The test covers conflicting and non-conflicting merges, including an incoming
    leaf script whose control blocks only partly conflict, so records that do not
    conflict are not dropped alongside those that do.
    019fd708c2
  18. shuv-amp force-pushed on Aug 20, 2026
  19. shuv-amp commented at 12:24 AM on August 20, 2026: contributor

    Squashed. Took all four points. The psbt.cpp comment is the one place I didn't follow you exactly, reasoning in that thread.

  20. thomasbuilds commented at 5:19 AM on August 20, 2026: contributor

    ACK 019fd70

  21. DrahtBot requested review from winterrdog on Aug 20, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-21 04:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me