{
 "number": 36420,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36420",
 "title": "descriptor: compare x-only public keys in Tapscript Miniscript duplicate check",
 "author": "Yudis-bit",
 "author_association": "FIRST_TIME_CONTRIBUTOR",
 "created_at": "2026-10-02T15:45:05Z",
 "updated_at": "2026-11-01T00:57:22Z",
 "description_hash": "1a165c960196c654",
 "age_days": 30,
 "draft": false,
 "labels": [
  "Descriptors"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "28121591bb60e62d46f566c4775776d5cc37d9ec",
 "head_ref": "tr-miniscript-xonly-dupkeys",
 "head_repo": "Yudis-bit/bitcoin",
 "head_history": [
  {
   "t": "2026-10-03T06:25:23Z",
   "sha": "a1c65a776c252ce253972d1712936f315cbc5abc"
  },
  {
   "t": "2026-10-03T12:32:00Z",
   "sha": "0956ace45dac99bc11256f4db1ca59f53016af6b"
  },
  {
   "t": "2026-11-01T00:54:36Z",
   "sha": "28121591bb60e62d46f566c4775776d5cc37d9ec"
  }
 ],
 "additions": 26,
 "deletions": 1,
 "changed_files": 3,
 "commit_count": 1,
 "size_bucket": "S",
 "mergeable_state": "unstable",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "stale_ack": [
     {
      "login": "fametrano",
      "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-5972208982"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "fametrano": {
   "kind": "ack",
   "hash": "0956ace45dac99bc11256f4db1ca59f53016af6b",
   "t": "2026-10-03T18:32:37Z",
   "stale": true
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 1,
  "concept_ack": 0,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "fametrano"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "unstable",
  "last_author_activity": "2026-11-01T00:57:22Z",
  "last_reviewer_activity": "2026-10-03T18:32:37Z",
  "last_reviewer": "fametrano",
  "author_silent_days": 1,
  "waiting_on_author_days": 0,
  "days_since_update": 1
 },
 "refs": {
  "mentioned": [
   36414
  ],
  "depends_on": [],
  "fixes": [
   36414
  ],
  "linked_issues": [
   {
    "number": 36414,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "descriptor: tr() duplicate-key check misses keys that differ only in parity"
   }
  ],
  "references": [
   {
    "number": 36414,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "descriptor: tr() duplicate-key check misses keys that differ only in parity"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/test/descriptor_tests.cpp"
 ],
 "body": "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 `CPubKey`s, retaining the parity byte.\n\nWhen 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.\n\nThis 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.\n\nFixes #36414.",
 "commits": [
  {
   "sha": "28121591bb60e62d46f566c4775776d5cc37d9ec",
   "date": "2026-10-31T23:49:54Z",
   "message": "descriptor: compare x-only public keys in Tapscript Miniscript duplicate check\n\nIn Tapscript Miniscript descriptors (inside tr()), public keys serialize\nas 32-byte x-only pubkeys (BIP-340 / BIP-342 / BIP-379), discarding parity.\nHowever, KeyParser::KeyCompare compared keys as full 33-byte CPubKeys,\ncausing keys that differ only in parity (such as pk(X) and pk(03X), or\npk(02X) and pk(03X)) to be treated as distinct.\n\nAs a result, DuplicateKeyCheck() passed and marked the script sane, even\nthough both branches serialize to the same script on-chain (<X> OP_CHECKSIG).\nThis violates BIP-379 malleability requirements and enables third parties\nto malleate transactions by flipping branch selectors with a single signature.\n\nFix this by comparing keys as XOnlyPubKey when miniscript::IsTapscript(m_script_ctx)\nin KeyParser::KeyCompare and KeyConverter::KeyCompare. In Tapscript, keys with\nmatching x-coordinates evaluate as equivalent, correctly failing duplicate-key\nchecks, while P2WSH (wsh()) continues to distinguish 33-byte compressed keys.\n\nFixes #36414."
  }
 ],
 "timeline": [
  {
   "t": "2026-10-02T16:36:23Z",
   "kind": "review",
   "who": "fametrano",
   "assoc": "NONE",
   "state": "COMMENTED",
   "commit": "cb85c2cdde6601d0a21b7bd567dbc74f44f39846",
   "text": "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.",
   "id": "r:5394296896",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#pullrequestreview-5394296896",
   "hash": "4a720118d32bbcd0",
   "edited": "2026-10-02T16:36:24Z"
  },
  {
   "t": "2026-10-02T16:36:23Z",
   "kind": "review_comment",
   "who": "fametrano",
   "assoc": "NONE",
   "path": "src/test/descriptor_tests.cpp",
   "commit": "cb85c2cdde6601d0a21b7bd567dbc74f44f39846",
   "in_reply_to": null,
   "text": "This descriptor parses, so this check fails:\n\n```suggestion\n```",
   "id": "rc:4167758786",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#discussion_r4167758786",
   "hash": "5d77791154bdcb97",
   "edited": "2026-10-02T16:36:24Z"
  },
  {
   "t": "2026-10-02T16:36:24Z",
   "kind": "review_comment",
   "who": "fametrano",
   "assoc": "NONE",
   "path": "src/test/descriptor_tests.cpp",
   "commit": "28121591bb60e62d46f566c4775776d5cc37d9ec",
   "in_reply_to": null,
   "text": "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:\n\n```suggestion\n                    \"or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys\");\n    CheckUnparsable(\"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(KztWktoNG1biXs3GSD6M7YDEynGaQPgjLguiWhMXiJM7sDA5vZFt)))\",\n                    \"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))\",\n                    \"or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys\");\n```",
   "id": "rc:4167758806",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#discussion_r4167758806",
   "hash": "a99817a76d7c3385",
   "edited": null
  },
  {
   "t": "2026-10-03T06:25:23Z",
   "kind": "force_push",
   "who": "Yudis-bit",
   "commit": "a1c65a776c252ce253972d1712936f315cbc5abc",
   "id": "push:a1c65a776c252ce253972d1712936f315cbc5abc"
  },
  {
   "t": "2026-10-03T06:27:38Z",
   "kind": "comment",
   "who": "Yudis-bit",
   "assoc": "NONE",
   "text": "Good catch on `multi_a` parsing directly as `MultiADescriptor` rather than Miniscript. Dropped that test and updated the PR description. Thanks for testing!",
   "id": "c:5966355751",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-5966355751",
   "hash": "836632d3a7f2ec6e",
   "edited": "2026-10-03T06:28:23Z"
  },
  {
   "t": "2026-10-03T11:35:39Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "NONE",
   "text": "ACK a1c65a776c252ce253972d1712936f315cbc5abc\n\ndescriptor_tests and miniscript_tests pass, and both new tr() checks fail without the descriptor.cpp change.\n\nThe optional WIF case in https://github.com/bitcoin/bitcoin/pull/36420#discussion_r4167758806 still adds coverage: without the fix its private form is accepted, and this PR's cases all use public keys.",
   "id": "c:5968773857",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-5968773857",
   "hash": "13f115638a3e602b",
   "edited": null
  },
  {
   "t": "2026-10-03T12:32:00Z",
   "kind": "force_push",
   "who": "Yudis-bit",
   "commit": "0956ace45dac99bc11256f4db1ca59f53016af6b",
   "id": "push:0956ace45dac99bc11256f4db1ca59f53016af6b"
  },
  {
   "t": "2026-10-03T12:32:18Z",
   "kind": "comment",
   "who": "Yudis-bit",
   "assoc": "NONE",
   "text": "Re-pushed `a1c65a7` -> `0956ace45d`:\n- Added the suggested WIF test case from https://github.com/bitcoin/bitcoin/pull/36420#discussion_r4167758806 to cover the private descriptor path in `KeyConverter`.\n\nRange-diff\n\n```diff\n1:  a1c65a776c ! 1:  0956ace45d descriptor: compare x-only public keys in Tapscript Miniscript duplicate check\n    @@ src/test/descriptor_tests.cpp: BOOST_AUTO_TEST_CASE(descriptor_test)\n     +    CheckUnparsable(\"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))\",\n     +                    \"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))\",\n     +                    \"or_i(pk(026116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(036116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys\");\n    ++    CheckUnparsable(\"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(KztWktoNG1biXs3GSD6M7YDEynGaQPgjLguiWhMXiJM7sDA5vZFt)))\",\n    ++                    \"tr(a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)))\",\n    ++                    \"or_i(pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4),pk(6116733562ba4df653e4ec6b98c904c8dd3c677428264d33f4ae0089b72abaa4)) is not sane: contains duplicate public keys\");\n     +    // But in wsh(), keys differing only in parity are distinct 33-byte compressed pubkeys and not duplicate.\n     +    {\n     +        FlatSigningProvider keys;\n```",
   "id": "c:5969196350",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-5969196350",
   "hash": "1ad9d5db55438e71",
   "edited": null
  },
  {
   "t": "2026-10-03T12:32:37Z",
   "kind": "review_comment",
   "who": "Yudis-bit",
   "assoc": "NONE",
   "path": "src/test/descriptor_tests.cpp",
   "commit": "28121591bb60e62d46f566c4775776d5cc37d9ec",
   "in_reply_to": 4167758806,
   "text": "Applied in 0956ace45d, thanks.",
   "id": "rc:4173179191",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#discussion_r4173179191",
   "hash": "fd88d5fc2c861065",
   "edited": null
  },
  {
   "t": "2026-10-03T18:32:37Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "NONE",
   "text": "reACK 0956ace45dac99bc11256f4db1ca59f53016af6b\n\nOnly 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.",
   "id": "c:5972208982",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-5972208982",
   "hash": "d4877a7a165b8513",
   "edited": null
  },
  {
   "t": "2026-11-01T00:54:36Z",
   "kind": "force_push",
   "who": "Yudis-bit",
   "commit": "28121591bb60e62d46f566c4775776d5cc37d9ec",
   "id": "push:28121591bb60e62d46f566c4775776d5cc37d9ec"
  },
  {
   "t": "2026-11-01T00:57:22Z",
   "kind": "comment",
   "who": "Yudis-bit",
   "assoc": "NONE",
   "text": "Rebased on master (6c01bd0b2). Range-diff is clean:\n\n```\n1:  0956ace45 = 1:  28121591b descriptor: compare x-only public keys in Tapscript Miniscript duplicate check\n```",
   "id": "c:6551460341",
   "url": "https://github.com/bitcoin/bitcoin/pull/36420#issuecomment-6551460341",
   "hash": "c918cd30c0cbcb89",
   "edited": null
  }
 ],
 "labels_log": [
  {
   "t": "2026-10-02T15:45:09Z",
   "action": "labeled",
   "label": "Descriptors",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-09T13:44:38Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-09T17:00:50Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 7364,
 "text_tokens_estimate": 1841,
 "changed_paths": [
  "src/script/descriptor.cpp",
  "src/test/descriptor_tests.cpp",
  "src/test/miniscript_tests.cpp"
 ],
 "files": [
  {
   "path": "src/script/descriptor.cpp",
   "add": 6,
   "del": 1
  },
  {
   "path": "src/test/descriptor_tests.cpp",
   "add": 17,
   "del": 0
  },
  {
   "path": "src/test/miniscript_tests.cpp",
   "add": 3,
   "del": 0
  }
 ],
 "test_lines": 20,
 "git": {
  "head": "0956ace45dac99bc11256f4db1ca59f53016af6b",
  "head_matches_backup": true,
  "base": "69142eacd1374925cc9e4ea736c21fcec16307d0",
  "patch_id": "41cd33a9a3bb4f05",
  "commits": [
   {
    "sha": "0956ace45d",
    "subject": "descriptor: compare x-only public keys in Tapscript Miniscript duplicate check",
    "files": 3,
    "add": 26,
    "del": 1
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "41cd33a9a3bb4f05",
 "input_hash": "efdb65c888924d43",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}