descriptor: compare x-only public keys in Tapscript Miniscript duplicate check #36420

pull Yudis-bit wants to merge 1 commits into bitcoin:master from Yudis-bit:tr-miniscript-xonly-dupkeys changing 3 files +26 −1
  1. Yudis-bit commented at 3:45 PM on October 2, 2026: none

    In Tapscript Miniscript descriptors (tr()), public keys serialize as 32-byte x-only pubkeys per BIP-340, BIP-342, and BIP-379, discarding parity. However, KeyParser::KeyCompare in src/script/descriptor.cpp compared keys as full 33-byte CPubKeys, retaining the parity byte.

    When a descriptor contains public keys that differ only in parity prefix (such as pk(X) alongside pk(03X) where X defaults to 02, or pk(02X) alongside pk(03X)), KeyParser::KeyCompare treated them as distinct keys. Miniscript duplicate key checking passed, declaring the descriptor sane even though both branches serialize on-chain to <X> OP_CHECKSIG. Because a single signature satisfies both branches, third parties could malleate transactions by altering branch selectors without holding private keys, violating BIP-379 non-malleability invariants.

    This patch updates KeyParser::KeyCompare in src/script/descriptor.cpp and KeyConverter::KeyCompare in src/test/miniscript_tests.cpp to compare keys as XOnlyPubKey whenever miniscript::IsTapscript(m_script_ctx) evaluates true. Under Tapscript context, keys sharing identical x-coordinates compare equivalent (!comp(a, b) && !comp(b, a)), ensuring DuplicateKeyCheck() detects the collision and rejects the descriptor. In src/test/descriptor_tests.cpp, unit tests verify that tr() descriptors containing parity-differing duplicate keys (including public hex and private WIF formats) are rejected as not sane, while wsh() descriptors continue to treat 33-byte compressed keys with differing parities as distinct.

    Fixes #36414.

  2. Yudis-bit requested review from Copilot on Oct 2, 2026
  3. DrahtBot added the label Descriptors on Oct 2, 2026
  4. Copilot commented at 3:45 PM on October 2, 2026: none

    Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

  5. DrahtBot commented at 3:45 PM on October 2, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK fametrano

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  6. fametrano commented at 4:36 PM on October 2, 2026: contributor

    Tested at cb85c2cdde. descriptor_tests fails on the multi_a check because that descriptor parses: a multi_a directly in tr() is not Miniscript and has no duplicate-key check, so even multi_a(2,X,X) is accepted. wallet_taproot.py repeats a key inside one multi_a, so refusing duplicates there would be a separate change. I suggest dropping the check here and in the description. The two or_i cases are refused with this change and accepted without it.

  7. in src/test/descriptor_tests.cpp:1137 in cb85c2cdde
    1132 | +    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1133 | +                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1134 | +                    "or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
    1135 | +    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,multi_a(2,6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4,036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4))",
    1136 | +                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,multi_a(2,6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4,036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4))",
    1137 | +                    "multi_a(2,6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4,036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4) is not sane: contains duplicate public keys");
    


    fametrano commented at 4:36 PM on October 2, 2026:

    This descriptor parses, so this check fails:

  8. in src/test/descriptor_tests.cpp:1134 in cb85c2cdde outdated
    1129 | +    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1130 | +                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1131 | +                    "or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
    1132 | +    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1133 | +                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
    1134 | +                    "or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
    


    fametrano commented at 4:36 PM on October 2, 2026:

    Optional: the same case with the second key as a WIF whose public key is 03X. Without the descriptor.cpp change this private descriptor is accepted, while its public form (two identical x-only keys) is already refused:

                        "or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
        CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(KztWktoNG1biXs3GSD6M7YDEynGaQPgjLguiWhMXiJM7sDA5vZFt)))",
                        "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
                        "or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
    

    Yudis-bit commented at 12:32 PM on October 3, 2026:

    Applied in 0956ace45d, thanks.

  9. Yudis-bit force-pushed on Oct 3, 2026
  10. Yudis-bit commented at 6:27 AM on October 3, 2026: none

    Good catch on multi_a parsing directly as MultiADescriptor rather than Miniscript. Dropped that test and updated the PR description. Thanks for testing!

  11. fametrano commented at 11:35 AM on October 3, 2026: contributor

    ACK a1c65a776c252ce253972d1712936f315cbc5abc

    descriptor_tests and miniscript_tests pass, and both new tr() checks fail without the descriptor.cpp change.

    The optional WIF case in #36420 (review) still adds coverage: without the fix its private form is accepted, and this PR's cases all use public keys.

  12. descriptor: compare x-only public keys in Tapscript Miniscript duplicate check
    In Tapscript Miniscript descriptors (inside tr()), public keys serialize
    as 32-byte x-only pubkeys (BIP-340 / BIP-342 / BIP-379), discarding parity.
    However, KeyParser::KeyCompare compared keys as full 33-byte CPubKeys,
    causing keys that differ only in parity (such as pk(X) and pk(03X), or
    pk(02X) and pk(03X)) to be treated as distinct.
    
    As a result, DuplicateKeyCheck() passed and marked the script sane, even
    though both branches serialize to the same script on-chain (<X> OP_CHECKSIG).
    This violates BIP-379 malleability requirements and enables third parties
    to malleate transactions by flipping branch selectors with a single signature.
    
    Fix this by comparing keys as XOnlyPubKey when miniscript::IsTapscript(m_script_ctx)
    in KeyParser::KeyCompare and KeyConverter::KeyCompare. In Tapscript, keys with
    matching x-coordinates evaluate as equivalent, correctly failing duplicate-key
    checks, while P2WSH (wsh()) continues to distinguish 33-byte compressed keys.
    
    Fixes #36414.
    0956ace45d
  13. Yudis-bit force-pushed on Oct 3, 2026
  14. Yudis-bit commented at 12:32 PM on October 3, 2026: none

    Re-pushed a1c65a7 -> 0956ace45d:

    • Added the suggested WIF test case from #36420 (review) to cover the private descriptor path in KeyConverter.

    <details><summary>Range-diff</summary>

    1:  a1c65a776c ! 1:  0956ace45d descriptor: compare x-only public keys in Tapscript Miniscript duplicate check
        @@ src/test/descriptor_tests.cpp: BOOST_AUTO_TEST_CASE(descriptor_test)
         +    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
         +                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
         +                    "or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
        ++    CheckUnparsable("tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(KztWktoNG1biXs3GSD6M7YDEynGaQPgjLguiWhMXiJM7sDA5vZFt)))",
        ++                    "tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))",
        ++                    "or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys");
         +    // But in wsh(), keys differing only in parity are distinct 33-byte compressed pubkeys and not duplicate.
         +    {
         +        FlatSigningProvider keys;
    

    </details>

  15. fametrano commented at 6:32 PM on October 3, 2026: contributor

    reACK 0956ace45dac99bc11256f4db1ca59f53016af6b

    Only the WIF test case was added since a1c65a77. descriptor_tests and miniscript_tests pass; without the descriptor.cpp change, the two tr() cases and the WIF case fail, the WIF one because its private form parses.

  16. DrahtBot added the label CI failed on Oct 9, 2026
  17. DrahtBot removed the label CI failed on Oct 9, 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-10-11 10:51 UTC

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