{
 "number": 36352,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36352",
 "title": "psbt: clear Taproot and MuSig2 signatures when joining",
 "author": "FlashWayne",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-09-26T22:35:10Z",
 "updated_at": "2026-10-05T09:36:38Z",
 "description_hash": "b8874ddb25e0363c",
 "age_days": 36,
 "draft": false,
 "labels": [
  "PSBT"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba",
 "head_ref": "fix-joinpsbts-clear-taproot-sigs",
 "head_repo": "FlashWayne/bitcoin",
 "head_history": [
  {
   "t": "2026-10-02T15:26:39Z",
   "sha": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba"
  }
 ],
 "additions": 30,
 "deletions": 0,
 "changed_files": 2,
 "commit_count": 1,
 "size_bucket": "S",
 "mergeable_state": "unknown",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "fametrano",
      "url": "https://github.com/bitcoin/bitcoin/pull/36352#issuecomment-5968518553"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "fametrano": {
   "kind": "ack",
   "hash": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba",
   "t": "2026-10-03T11:01:06Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 1,
  "stale_ack": 0,
  "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": "unknown",
  "last_author_activity": "2026-10-02T15:26:56Z",
  "last_reviewer_activity": "2026-10-03T11:01:06Z",
  "last_reviewer": "fametrano",
  "author_silent_days": 30,
  "waiting_on_author_days": 29,
  "days_since_update": 27
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [],
 "body": "`joinpsbts` builds a new transaction from the inputs and outputs of the given PSBTs, so any existing signatures are no longer valid for it. `PartiallySignedTransaction::AddInput` handles that by clearing `partial_sigs` and the final scriptSig/witness (psbt.cpp:180), but that code predates Taproot and was never extended to the Taproot and MuSig2 fields.\n\nSo a Taproot input that was signed before the join keeps its old `taproot_key_path_sig`. `SignTaproot` (sign.cpp:608) doesn't produce a new key path signature when one is already present, so the input can't be completed afterwards:\n\n- a bech32 (P2WPKH) input: after `joinpsbts`, `walletprocesspsbt` re-signs and returns `complete: true`\n- a bech32m (P2TR) input: `taproot_key_path_sig` is still there after the join, and `walletprocesspsbt` / `finalizepsbt` return `complete: false`. `utxoupdatepsbt`, `descriptorprocesspsbt` and signing again don't help, so the only way out is editing the PSBT by hand.\n\nScript path signatures (`taproot_script_path_sigs`) behave the same. For MuSig2 the old public nonce is kept, but the secret nonce is looked up by a session id that includes the old sighash, so no new partial signature is made either.\n\nThis also clears `m_tap_key_sig`, `m_tap_script_sigs`, `m_musig2_pubnonces` and `m_musig2_partial_sigs` in the v0 branch of `AddInput`. Participant pubkeys are key data, not signatures, so they're kept. The v2 branch is unchanged: there, adding inputs is governed by the BIP370 modifiable flags.\n\nThe new check in `rpc_psbt.py` signs a v0 PSBT with a P2TR input, joins it with another one, and checks that the key path signature is gone and the joined PSBT can be signed. It fails on master and passes with the fix. `wallet_musig.py` still passes.",
 "commits": [
  {
   "sha": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba",
   "date": "2026-10-02T15:26:31Z",
   "message": "psbt: clear Taproot and MuSig2 signatures when joining\n\njoinpsbts builds a new transaction, so signatures made for the original\nones are no longer valid. PartiallySignedTransaction::AddInput clears\npartial_sigs and the final scriptSig/witness for that reason, but it was\nnever updated for the Taproot and MuSig2 fields.\n\nThe stale Taproot signature is then kept. SignTaproot only signs when no\nkey path signature is present, so the input can't be completed anymore:\nwalletprocesspsbt and finalizepsbt keep returning complete=false. Script\npath signatures and MuSig2 partial signatures/nonces have the same\nproblem.\n\nClear m_tap_key_sig, m_tap_script_sigs, m_musig2_pubnonces and\nm_musig2_partial_sigs as well."
  }
 ],
 "timeline": [
  {
   "t": "2026-10-02T11:33:22Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "CONTRIBUTOR",
   "text": "tACK e1aa0baa146a3edc3dd3fc143e870419d81e2e4a\n\nThanks for the fix. I built it and ran `rpc_psbt.py`: it passes here and fails on master. When I call `joinpsbts` directly, all four fields are dropped on this branch and kept on master.\n\nThe test only checks `taproot_key_path_sig`, though: it still passes if any one of the other three new `clear()` lines is deleted. Adding the other three fields to `tr_psbt1` before the join (random values, as in `test_decodepsbt_musig2_input_output_types`) and checking all four afterwards catches each deletion:\n\n```diff\n         tr_psbt1 = self.nodes[1].walletprocesspsbt(psbt=tr_psbt1, finalize=False)['psbt']\n-        assert \"taproot_key_path_sig\" in self.nodes[0].decodepsbt(tr_psbt1)['inputs'][0]\n+        tr_psbt1_obj = PSBT.from_base64(tr_psbt1)\n+        _, part_pubkey = generate_keypair()\n+        _, agg_pubkey = generate_keypair()\n+        leaf_hash = randbytes(32)\n+        tr_psbt1_obj.i[0].map[bytes([PSBT_IN_TAP_SCRIPT_SIG]) + part_pubkey[1:] + leaf_hash] = randbytes(64)\n+        tr_psbt1_obj.i[0].map[bytes([PSBT_IN_MUSIG2_PUB_NONCE]) + part_pubkey + agg_pubkey + leaf_hash] = randbytes(66)\n+        tr_psbt1_obj.i[0].map[bytes([PSBT_IN_MUSIG2_PARTIAL_SIG]) + part_pubkey + agg_pubkey + leaf_hash] = randbytes(32)\n+        tr_psbt1 = tr_psbt1_obj.to_base64()\n+        tr_sig_fields = [\"taproot_key_path_sig\", \"taproot_script_path_sigs\", \"musig2_pubnonces\", \"musig2_partial_sigs\"]\n+        for field in tr_sig_fields:\n+            assert field in self.nodes[0].decodepsbt(tr_psbt1)['inputs'][0]\n         ...\n         for tr_input in self.nodes[0].decodepsbt(tr_joined)['inputs']:\n-            assert \"taproot_key_path_sig\" not in tr_input\n+            for field in tr_sig_fields:\n+                assert field not in tr_input\n```\n\n(plus `PSBT_IN_TAP_SCRIPT_SIG` in the imports). This still passes on the PR and fails on master and on each single deletion.",
   "id": "c:5951477740",
   "url": "https://github.com/bitcoin/bitcoin/pull/36352#issuecomment-5951477740",
   "hash": "0ed01cdadcc29334",
   "edited": null
  },
  {
   "t": "2026-10-02T15:26:39Z",
   "kind": "force_push",
   "who": "FlashWayne",
   "commit": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba",
   "id": "push:4efa6b7d70e36afac66d5eb6a2fa05f683becdba"
  },
  {
   "t": "2026-10-02T15:26:56Z",
   "kind": "comment",
   "who": "FlashWayne",
   "assoc": "CONTRIBUTOR",
   "text": "Thanks for testing, and good catch on the test. Took your suggestion in 4efa6b7d70: the input now also gets a script path sig, a MuSig2 pubnonce and a partial sig before the join, and all four fields are checked afterwards. Removing any one of the four `clear()` lines now makes `rpc_psbt.py` fail.",
   "id": "c:5955626229",
   "url": "https://github.com/bitcoin/bitcoin/pull/36352#issuecomment-5955626229",
   "hash": "d1d8a1488471925a",
   "edited": "2026-10-02T15:30:39Z"
  },
  {
   "t": "2026-10-03T11:01:06Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "CONTRIBUTOR",
   "text": "reACK 4efa6b7d70e36afac66d5eb6a2fa05f683becdba\n\nThanks for taking the test. `rpc_psbt.py` passes here. It fails without the fix, and when any one of the four `clear()` lines is removed.",
   "id": "c:5968518553",
   "url": "https://github.com/bitcoin/bitcoin/pull/36352#issuecomment-5968518553",
   "hash": "ab659cb71f156c2e",
   "edited": null
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-26T22:35:13Z",
   "action": "labeled",
   "label": "PSBT",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-28T12:04:27Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-29T19:22:53Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 4904,
 "text_tokens_estimate": 1226,
 "changed_paths": [
  "src/psbt.cpp",
  "test/functional/rpc_psbt.py"
 ],
 "files": [
  {
   "path": "src/psbt.cpp",
   "add": 4,
   "del": 0
  },
  {
   "path": "test/functional/rpc_psbt.py",
   "add": 26,
   "del": 0
  }
 ],
 "test_lines": 26,
 "git": {
  "head": "4efa6b7d70e36afac66d5eb6a2fa05f683becdba",
  "head_matches_backup": true,
  "base": "ed7dd7cf4e1561a97edf72eba29a67b14e28c717",
  "patch_id": "1022a0b9514426e1",
  "commits": [
   {
    "sha": "4efa6b7d70",
    "subject": "psbt: clear Taproot and MuSig2 signatures when joining",
    "files": 2,
    "add": 30,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "1022a0b9514426e1",
 "input_hash": "b87d7b05d3ae83b2",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}