{
 "number": 36114,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36114",
 "title": "wallet: harden external signer psbt processing, revamp mock",
 "author": "Sjors",
 "author_association": "MEMBER",
 "created_at": "2026-08-28T11:27:33Z",
 "updated_at": "2026-09-15T05:53:07Z",
 "age_days": 20,
 "draft": true,
 "labels": [
  "Wallet"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
 "head_ref": "2026/08/external-signer-mock",
 "head_repo": "Sjors/bitcoin",
 "head_history": [
  {
   "t": "2026-09-08T10:57:12Z",
   "sha": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4"
  }
 ],
 "additions": 276,
 "deletions": 66,
 "changed_files": 5,
 "commit_count": 4,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "jeanpablojp",
      "url": "https://github.com/bitcoin/bitcoin/pull/36114#pullrequestreview-5141340050"
     }
    ]
   },
   "conflicts": [
    {
     "number": 35358,
     "title": "external signer: verify PSBT is reliable after signing it",
     "author": "brunoerg"
    },
    {
     "number": 33112,
     "title": "wallet: relax external_signer flag constraints",
     "author": "Sjors"
    }
   ]
  }
 },
 "acks_parsed": {
  "jeanpablojp": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-08T11:54:39Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 1,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "dangervslash",
   "jeanpablojp"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-09-08T10:57:13Z",
  "last_reviewer_activity": "2026-09-08T11:54:39Z",
  "last_reviewer": "jeanpablojp",
  "author_silent_days": 9,
  "waiting_on_author_days": 9,
  "days_since_update": 2
 },
 "refs": {
  "mentioned": [
   35358,
   36076,
   36113
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 35358,
    "type": "pull",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "external signer: verify PSBT is reliable after signing it"
   },
   {
    "number": 36076,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-09",
    "title": "psbt: preserve sighash type when merging inputs"
   },
   {
    "number": 36113,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-07",
    "title": "psbt: fix rendering for invalid long sighash type field"
   }
  ],
  "conflicts": [
   35358,
   33112
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/external_signer.cpp",
  "test/functional/wallet_signer.py"
 ],
 "body": "The current external signer functional test mock can only echo a prepared PSBT. This gets in the way of testing more complicated scenarios, i.e. misbehaving signers and (MuSig2) multisig.\n\nCommit `test: have external signer mock use a wallet` revamps the mock signer by handing it its own node and wallet. The (offline) mock wallet has the private keys, while the test uses the watch-only version.\n\nCommit `external_signer: merge PSBT response instead of replacing` lets the mock signer manipulate the PSBT it returns. It can drop outputs, reduce their value and change the script. Not all manipulations are malicious, e.g. a signer may simply not echo all PSBT fields, which is also covered. All this can be compensated for (thwarted) by _merging_ the external signer PSBT instead of discarding the original.\n\nCommit `external_signer: reject unsafe sighash types` adds scenarios where devices use an unsafe sighash type.\n\nThis PR absorbs the scenarios from #35358, but is better able to test them thanks to the new mock signer implementation.\n\nThis PR does not intend to fully harden against a malicious external signer, and can't protect against a malicious HWI (equivalent) process running as the same user.\n\nBased on:\n- #36076",
 "commits": [
  {
   "sha": "ea785a31f783e67adc9b4f0c4a8e54acd2794904",
   "date": "2026-09-08T04:40:54Z",
   "message": "psbt: preserve sighash type when merging inputs\n\n`PSBTInput::Merge` copies every optional input field from the other\ninput when it is absent locally, except `PSBT_IN_SIGHASH_TYPE`. So\n`combinepsbt` silently drops the sighash type whenever the first PSBT\ndoes not carry it, making the result depend on the argument order.\n\nThe field is what lets finalizers enforce the sighash type of existing\nsignatures (BIP 174). When it is lost, `FinalizePSBT` falls back to the\ndefault type (`SIGHASH_ALL`, or `SIGHASH_DEFAULT` for taproot inputs),\nrejects signatures made with any other type as a sighash mismatch, and\nthe PSBT can no longer be finalized. Combining a PSBT signed with\n`ALL|ANYONECANPAY` after a merely updated copy of the same PSBT\nreproduces this: `finalizepsbt` reports it as incomplete, while the\nreverse order finalizes and broadcasts fine.\n\nMerge the sighash type like the other optional fields, keeping the one\nalready present, and test both combine orders."
  },
  {
   "sha": "9078ef76ed06b7a185d0b08eb3366b2041cce5e1",
   "date": "2026-09-08T10:35:30Z",
   "message": "test: have external signer mock use a wallet\n\nThe external signer mock previously replayed a PSBT that the test\nprepared in advance. This makes it difficult to test more complicated\nscenarios like a misbehaving wallet and (MuSig2) multisig.\n\nInstead, give the mock its own descriptor wallet. The test provides a\ndedicated node for this wallet and keeps it offline, so the mock can't\ncheat by e.g. inspecting the UTXO set. The mock creates the wallet on\nfirst use and signs with walletprocesspsbt.\n\nwallet_signer.py now funds all four descriptor types and spends them\nin a single transaction, exercising every signing code path."
  },
  {
   "sha": "d5b357da0127db43f5eb7851478fe0bf03f31b89",
   "date": "2026-09-08T10:35:30Z",
   "message": "external_signer: merge PSBT response instead of replacing\n\nPreviously the PSBT returned by the external signer replaced the\noriginal wholesale, trusting the signer not to modify the transaction.\nMerge it instead. This rejects a response that describes a different\ntransaction.\n\nMerging also supports signers that strip fields they don't need from\ntheir response. New tests cover both scenarios.\n\nCo-authored-by: brunoerg <brunoely.gc@gmail.com>"
  },
  {
   "sha": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
   "date": "2026-09-08T10:35:30Z",
   "message": "external_signer: reject unsafe sighash types\n\nA signature with SIGHASH_NONE or SIGHASH_SINGLE doesn't commit to all\noutputs, letting anyone alter them after signing. Reject a PSBT from\nan external signer that declares such a sighash type or contains\nsignatures made with one.\n\nSIGHASH_ANYONECANPAY is still accepted: it only permits adding inputs,\nwhich does not affect us.\n\nThe mock signer produces real signatures for these scenarios by\nletting its wallet sign with the requested sighash type, optionally\nhiding the declared sighash type field so that only the signatures\nthemselves reveal it.\n\nCo-authored-by: brunoerg <brunoely.gc@gmail.com>"
  }
 ],
 "timeline": [
  {
   "t": "2026-08-28T11:30:00Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "cc @bigspider and other hardware wallet folks, any other hardware wallet shenanigans we should check against (perhaps in a followup)?"
  },
  {
   "t": "2026-08-28T22:00:16Z",
   "kind": "review",
   "who": "dangervslash",
   "assoc": "NONE",
   "state": "COMMENTED",
   "commit": "79fb761ed88b1c9e261cffe666a2eeb2ee5a4005",
   "text": "Approved flame"
  },
  {
   "t": "2026-08-28T22:00:35Z",
   "kind": "comment",
   "who": "dangervslash",
   "assoc": "NONE",
   "text": "MarkUp"
  },
  {
   "t": "2026-09-08T10:57:12Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4"
  },
  {
   "t": "2026-09-08T10:57:13Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "Rebased after #36113, still based on #36076."
  },
  {
   "t": "2026-09-08T11:54:39Z",
   "kind": "review",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
   "text": "Concept ACK\n\nI made the new mock return a finalized response and `FindUnsafeSighashType` doesn't catch it. The fields it reads are the ones `PSBTInput::Serialize` writes only when the input isn't finalized, and BIP 174 has a finalizer clear them, so the signatures arrive in `final_script_sig` and `final_script_witness` alone.\n\nWith `SIGHASH_NONE` the `send` comes back complete, both signatures in the transaction end in `0x02`, and `testmempoolaccept` allows it.\n\nThe commit message says the change rejects a response containing signatures made with an unsafe sighash type, and a finalized one does. The different-transaction check still rejects a finalized response that changes an output.\n\nIs skipping the finalized case deliberate, or worth covering?"
  },
  {
   "t": "2026-09-08T11:54:39Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/external_signer.cpp",
   "commit": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
   "in_reply_to": null,
   "text": "nit: `Merge` only returns false for two reasons, `GetUniqueID` and the version comparison, since neither `PSBTInput::Merge` nor `PSBTOutput::Merge` has a failure path.\n\nI returned the same transaction as a v0 PSBT and `send` failed with this message. `GetUniqueID` is what stops it, since it zeroes `nSequence` only from version 2 on, and before this commit that response was accepted.\n\nWorth a separate message, or does the signer have to return the version it was given?"
  },
  {
   "t": "2026-09-08T11:54:39Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "test/functional/wallet_signer.py",
   "commit": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
   "in_reply_to": null,
   "text": "nit: this transaction has an ECDSA and a taproot input, and `FindUnsafeSighashType` collects from both before it scans, so either signature alone triggers the rejection. Disabling the `m_tap_key_sig` collection leaves `wallet_signer.py` green, and disabling the `partial_sigs` one does too. Only removing both makes `sighash_none_hidden` fail.\n\nA single-input transaction of each type would make each branch carry its own case. Worth adding?"
  }
 ],
 "labels_log": [
  {
   "t": "2026-08-28T11:27:37Z",
   "action": "labeled",
   "label": "Wallet",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-07T22:31:19Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-08T11:29:33Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 5851,
 "text_tokens_estimate": 1462,
 "changed_paths": [
  "src/external_signer.cpp",
  "test/functional/mocks/signer.py",
  "test/functional/wallet_signer.py"
 ],
 "files": [
  {
   "path": "src/external_signer.cpp",
   "add": 40,
   "del": 1
  },
  {
   "path": "test/functional/mocks/signer.py",
   "add": 98,
   "del": 10
  },
  {
   "path": "test/functional/wallet_signer.py",
   "add": 108,
   "del": 55
  }
 ],
 "test_lines": 271,
 "git": {
  "head": "0567fa6d2e506f74031f29e7db88cb5c038dd9b4",
  "head_matches_backup": true,
  "base": "ea785a31f783e67adc9b4f0c4a8e54acd2794904",
  "commits": [
   {
    "sha": "9078ef76ed",
    "subject": "test: have external signer mock use a wallet",
    "files": 2,
    "add": 75,
    "del": 65
   },
   {
    "sha": "d5b357da01",
    "subject": "external_signer: merge PSBT response instead of replacing",
    "files": 3,
    "add": 109,
    "del": 3
   },
   {
    "sha": "0567fa6d2e",
    "subject": "external_signer: reject unsafe sighash types",
    "files": 3,
    "add": 66,
    "del": 2
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "d73a7a7abf490eb5",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}