{
 "number": 36344,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36344",
 "title": "wallet: reject duplicate inputs in sendall",
 "author": "Bruce039",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-09-26T09:57:37Z",
 "updated_at": "2026-10-30T19:26:33Z",
 "description_hash": "e84288538441c501",
 "age_days": 36,
 "draft": false,
 "labels": [
  "Wallet"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "1beefce3519348662d833734cadf01a23d60d83e",
 "head_ref": "fix-sendall-duplicate-inputs",
 "head_repo": "Bruce039/bitcoin",
 "head_history": [
  {
   "t": "2026-09-26T10:01:50Z",
   "sha": "c46a0b34fd06c874191cf880ba7082790b96c194"
  },
  {
   "t": "2026-10-27T10:50:10Z",
   "sha": "1beefce3519348662d833734cadf01a23d60d83e"
  }
 ],
 "additions": 23,
 "deletions": 0,
 "changed_files": 2,
 "commit_count": 1,
 "size_bucket": "S",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "molnard",
      "url": "https://github.com/bitcoin/bitcoin/pull/36344#pullrequestreview-5713521792"
     },
     {
      "login": "l0rinc",
      "url": "https://github.com/bitcoin/bitcoin/pull/36344#pullrequestreview-5717619419"
     }
    ],
    "stale_ack": [
     {
      "login": "achow101",
      "url": "https://github.com/bitcoin/bitcoin/pull/36344#issuecomment-5900949316"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "achow101": {
   "kind": "ack",
   "hash": "c46a0b34fd06c874191cf880ba7082790b96c194",
   "t": "2026-09-29T23:21:37Z",
   "stale": true
  },
  "l0rinc": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-10-18T06:08:17Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 1,
  "concept_ack": 1,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 1,
  "changes_requested": 1,
  "distinct_reviewers": [
   "achow101",
   "l0rinc",
   "molnard"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-10-27T10:50:38Z",
  "last_reviewer_activity": "2026-10-30T19:26:28Z",
  "last_reviewer": "l0rinc",
  "author_silent_days": 5,
  "waiting_on_author_days": 2,
  "days_since_update": 2
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/wallet/rpc/spend.cpp",
  "test/functional/wallet_sendall.py"
 ],
 "body": "`sendall` with the `inputs` option doesn't check for duplicates. Each listed input is added to `total_input_value` in the loop at spend.cpp:1472, so passing the same UTXO twice counts its value twice.\n\nOn regtest, with a wallet that has a single 1 BTC UTXO:\n\n```\nsendall '[\"<addr>\"]' '{\"inputs\": [<utxo>, <utxo>]}'\n```\n\n- the RPC returns `complete: true`\n- the committed tx has 2 inputs (the same outpoint twice) and one output of 1.99998230 BTC\n- it isn't in the mempool, and `testmempoolaccept` rejects it with `bad-txns-inputs-duplicate`\n- after that, `listunspent` is empty and `getbalances` shows `nonmempool: -1.0` until the tx is abandoned\n\n`send` and `walletcreatefundedpsbt` aren't affected, because `CCoinControl::Select` removes duplicates.\n\nThis rejects a duplicated input with `Invalid parameter, duplicated input: <txid>:<vout>` (RPC_INVALID_PARAMETER). The message matches what `createrawtransaction` says for duplicated addresses. The new `sendall_fails_on_duplicate_input` test fails on master (\"No exception raised\") and passes with the fix. `wallet_send.py` still passes.",
 "commits": [
  {
   "sha": "1beefce3519348662d833734cadf01a23d60d83e",
   "date": "2026-10-27T10:50:06Z",
   "message": "wallet: reject duplicate inputs in sendall\n\nsendall with the inputs option adds up the value of each listed input\nwithout checking for duplicates. When the same UTXO is given twice, the\ninput value is counted twice, and the wallet builds, signs and commits\na transaction that spends one outpoint twice and pays out twice its\nvalue. The RPC reports success, but the transaction is consensus-invalid\n(bad-txns-inputs-duplicate), and the coin disappears from listunspent\nuntil the transaction is abandoned.\n\nReject duplicated inputs with an error, like createrawtransaction does\nfor duplicated addresses."
  }
 ],
 "timeline": [
  {
   "t": "2026-09-26T10:01:50Z",
   "kind": "force_push",
   "who": "Bruce039",
   "commit": "c46a0b34fd06c874191cf880ba7082790b96c194",
   "id": "push:c46a0b34fd06c874191cf880ba7082790b96c194"
  },
  {
   "t": "2026-09-29T23:21:37Z",
   "kind": "comment",
   "who": "achow101",
   "assoc": "MEMBER",
   "text": "ACK c46a0b34fd06c874191cf880ba7082790b96c194",
   "id": "c:5900949316",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#issuecomment-5900949316",
   "hash": "f48794d863caa1c2",
   "edited": null
  },
  {
   "t": "2026-10-18T06:06:15Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "test/functional/wallet_sendall.py",
   "commit": "c46a0b34fd06c874191cf880ba7082790b96c194",
   "in_reply_to": null,
   "text": "The previous line correctly fails without the fix, but this assertion would pass before and after because the invalid transaction never enters the mempool - we should assert the affected wallet state instead.\n\nSee my suggested alternative in https://github.com/l0rinc/bitcoin/pull/308/commits which demonstrates the full state transition by first characterizing the old behavior and then updating the expectations with the fix.",
   "id": "rc:4305956514",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#discussion_r4305956514",
   "hash": "8570727c05f0e7cc",
   "edited": "2026-10-18T06:08:17Z"
  },
  {
   "t": "2026-10-18T06:08:17Z",
   "kind": "review",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "state": "CHANGES_REQUESTED",
   "commit": "c46a0b34fd06c874191cf880ba7082790b96c194",
   "text": "Concept ACK\n\nThe RPC-error assertion catches the missing rejection, but we should cover the reported wallet-state correction also - see inline suggestion.",
   "id": "r:5561688631",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#pullrequestreview-5561688631",
   "hash": "237cef47f438c6f1",
   "edited": null
  },
  {
   "t": "2026-10-27T10:50:10Z",
   "kind": "force_push",
   "who": "Bruce039",
   "commit": "1beefce3519348662d833734cadf01a23d60d83e",
   "id": "push:1beefce3519348662d833734cadf01a23d60d83e"
  },
  {
   "t": "2026-10-27T10:50:38Z",
   "kind": "review_comment",
   "who": "Bruce039",
   "assoc": "CONTRIBUTOR",
   "path": "test/functional/wallet_sendall.py",
   "commit": "c46a0b34fd06c874191cf880ba7082790b96c194",
   "in_reply_to": 4305956514,
   "text": "Thanks, you're right that the pending-balance check passed either way. 1beefce checks the wallet state instead: txcount and the nonmempool balance stay the same and the outpoint is still listed in listunspent. Without the fix the first two change (txcount +1, nonmempool -17) and the outpoint disappears.",
   "id": "rc:4387759956",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#discussion_r4387759956",
   "hash": "7ae9ea8d1edc8239",
   "edited": null
  },
  {
   "t": "2026-10-30T14:08:56Z",
   "kind": "review",
   "who": "molnard",
   "assoc": "NONE",
   "state": "COMMENTED",
   "commit": "1beefce3519348662d833734cadf01a23d60d83e",
   "text": "tACK 1beefce3519348662d833734cadf01a23d60d83e\n\nRan `wallet_sendall.py` on win. It passes with the fix and fails with `No exception raised` when the duplicate input check is removed.",
   "id": "r:5713521792",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#pullrequestreview-5713521792",
   "hash": "31616adaddd8ce79",
   "edited": null
  },
  {
   "t": "2026-10-30T19:23:26Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "src/wallet/rpc/spend.cpp",
   "commit": "1beefce3519348662d833734cadf01a23d60d83e",
   "in_reply_to": null,
   "text": "Nit: I\u2019d prefer `std::unordered_set<COutPoint, SaltedOutpointHasher>` with `reserve(rawTx.vin.size())` here. We don\u2019t need ordering, and for the expected happy path this gives expected linear work, avoids rehashing, and would probably be faster and potentially use less memory for large sweeps. A tree could be cheaper when an early duplicate lets it reject before allocating for the whole list, but I\u2019d optimize for valid requests rather than that uncommon error case. We already use this unordered set for input checks in `IsConsistentPackage`.",
   "id": "rc:4427661311",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#discussion_r4427661311",
   "hash": "498b21746bc8fa16",
   "edited": "2026-10-30T19:26:29Z"
  },
  {
   "t": "2026-10-30T19:26:28Z",
   "kind": "review",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "state": "APPROVED",
   "commit": "1beefce3519348662d833734cadf01a23d60d83e",
   "text": "I like 1 beef, ACK 1beefce3519348662d833734cadf01a23d60d83e\n\nLeft a nit, also fine with it as-is.\n\nCharacterization used to verify the state transition\n\n```patch\ndiff --git a/test/functional/wallet_sendall.py b/test/functional/wallet_sendall.py\n--- a/test/functional/wallet_sendall.py\n+++ b/test/functional/wallet_sendall.py\n@@ -266,12 +266,13 @@\n       txcount_before = self.wallet.getwalletinfo()[\"txcount\"]\n       nonmempool_before = self.wallet.getbalances()[\"mine\"][\"nonmempool\"]\n\n-        duplicate_txid = self.wallet.sendall(recipients=[self.remainder_target], inputs=[utxo, utxo])[\"txid\"]\n-        assert_equal(self.wallet.getwalletinfo()[\"txcount\"], txcount_before + 1)  # TODO: Duplicate inputs must not add a wallet transaction\n-        assert_equal(self.wallet.getbalances()[\"mine\"][\"nonmempool\"], nonmempool_before - utxo[\"amount\"])  # TODO: Duplicate inputs must not record a spend\n+        assert_raises_rpc_error(-8,\n+                \"Invalid parameter, duplicated input: {}:{}\".format(utxo[\"txid\"], utxo[\"vout\"]),\n+                self.wallet.sendall, recipients=[self.remainder_target], inputs=[utxo, utxo])\n+        assert_equal(self.wallet.getwalletinfo()[\"txcount\"], txcount_before)\n+        assert_equal(self.wallet.getbalances()[\"mine\"][\"nonmempool\"], nonmempool_before)\n       assert_equal(sum(c[\"txid\"] == utxo[\"txid\"] and c[\"vout\"] == utxo[\"vout\"]\n-                         for c in self.wallet.listunspent()), 0)  # TODO: Duplicate inputs must leave the outpoint available\n-        self.wallet.abandontransaction(duplicate_txid)\n+                         for c in self.wallet.listunspent()), 1)\n\n   @cleanup\n   def sendall_fails_on_no_address(self):\n```",
   "id": "r:5717619419",
   "url": "https://github.com/bitcoin/bitcoin/pull/36344#pullrequestreview-5717619419",
   "hash": "3cd2af45d804cb13",
   "edited": "2026-10-30T19:26:29Z"
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-26T09:57:41Z",
   "action": "labeled",
   "label": "Wallet",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-26T18:32:32Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-26T20:04:16Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-27T10:51:15Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-27T11:59:10Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 5079,
 "text_tokens_estimate": 1269,
 "changed_paths": [
  "src/wallet/rpc/spend.cpp",
  "test/functional/wallet_sendall.py"
 ],
 "files": [
  {
   "path": "src/wallet/rpc/spend.cpp",
   "add": 4,
   "del": 0
  },
  {
   "path": "test/functional/wallet_sendall.py",
   "add": 19,
   "del": 0
  }
 ],
 "test_lines": 19,
 "git": {
  "head": "1beefce3519348662d833734cadf01a23d60d83e",
  "head_matches_backup": true,
  "base": "ed7dd7cf4e1561a97edf72eba29a67b14e28c717",
  "patch_id": "91ba923530f4b447",
  "commits": [
   {
    "sha": "1beefce351",
    "subject": "wallet: reject duplicate inputs in sendall",
    "files": 2,
    "add": 23,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "91ba923530f4b447",
 "input_hash": "f54302d58328c8f0",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}