{
 "number": 36500,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36500",
 "title": "AssumeUTXO: reject duplicate coins and empty snapshot groups",
 "author": "l0rinc",
 "author_association": "MEMBER",
 "created_at": "2026-10-11T11:10:56Z",
 "updated_at": "2026-10-31T04:45:51Z",
 "description_hash": "fabf72a7554da0a1",
 "age_days": 21,
 "draft": false,
 "labels": [
  "Validation"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0",
 "head_ref": "l0rinc/assumeutxo-import-count",
 "head_repo": "l0rinc/bitcoin",
 "head_history": [
  {
   "t": "2026-10-11T12:11:59Z",
   "sha": "963611c1fc5a98a825a8f0a42fc35eddbd9f7045"
  },
  {
   "t": "2026-10-11T12:59:20Z",
   "sha": "bf7ea087bc0c6e91a5c049028e5087444f8f0379"
  },
  {
   "t": "2026-10-15T11:38:34Z",
   "sha": "e7a381661cac0676107833b0cd4bfce5764b5cc9"
  },
  {
   "t": "2026-10-15T13:41:17Z",
   "sha": "e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0"
  }
 ],
 "additions": 36,
 "deletions": 10,
 "changed_files": 3,
 "commit_count": 4,
 "size_bucket": "S",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "fametrano",
      "url": "https://github.com/bitcoin/bitcoin/pull/36500#issuecomment-6397148633"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {},
 "acks_tally": {
  "ack": 0,
  "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": [
   "cyb3ralbert",
   "fametrano"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-10-15T13:41:17Z",
  "last_reviewer_activity": "2026-10-25T11:37:57Z",
  "last_reviewer": "fametrano",
  "author_silent_days": 17,
  "waiting_on_author_days": 7,
  "days_since_update": 1
 },
 "refs": {
  "mentioned": [
   35307
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 35307,
    "type": "pull",
    "state": "closed",
    "merged": false,
    "merged_at": null,
    "title": "blockstorage: keep snapshot base in normal blockfile range"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [],
 "body": "**Problem:** Snapshot loading doesn't validate the announced coin count against the unique UTXOs loaded.\nRepeated outpoints collapse into one coin, so a snapshot can repeat every record, announce the doubled count, and still match the committed UTXO hash.\n`loadtxoutset` then reports the inflated count as `coins_loaded`.\n\nSnapshot loading also accepts transaction groups with no coins.\nThey leave the UTXO hash unchanged and don't advance the coin count, so parsing them bypasses the interrupt check and can delay shutdown.\n\n**Fix:** Compare the announced count with the unique coin count already produced by the existing UTXO statistics scan, and reject zero-count transaction groups while parsing the snapshot.\nWhen both the content hash and coin count are wrong, loading reports the hash error first.",
 "commits": [
  {
   "sha": "8e2b70b77b8d64134b0d359a47457b869dd51145",
   "date": "2026-10-15T11:38:15Z",
   "message": "test: characterize duplicate snapshot records\n\nA repeated snapshot payload preserves the unique UTXO set and content hash while doubling the announced record count.\nRecord that current loading accepts this mismatch before enforcing the metadata contract.\nAlso preserve the content-hash error when the announced count differs from the unique UTXO count."
  },
  {
   "sha": "f1a903e47b758dab2a3e93c50025c0cfdd1aa20d",
   "date": "2026-10-15T11:38:15Z",
   "message": "validation: check imported unique UTXO count\n\nRepeated records can produce the committed set while overstating the announced coin count.\nCheck the unique count from the existing statistics scan after the content hash, preserving hash-error precedence."
  },
  {
   "sha": "773024812cb033d2191d37c2780884fcd75b67a6",
   "date": "2026-10-15T11:38:16Z",
   "message": "test: characterize empty snapshot groups\n\nAn empty transaction group adds no coin and leaves the committed UTXO hash unchanged.\nRecord its current acceptance before requiring every group to contain a coin."
  },
  {
   "sha": "e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0",
   "date": "2026-10-15T13:30:30Z",
   "message": "validation: reject empty snapshot groups\n\nReject transaction groups with no coins instead of accepting bytes that contribute nothing to the checked UTXO set.\nSnapshots generated by dumptxoutset already contain only nonempty groups."
  }
 ],
 "timeline": [
  {
   "t": "2026-10-11T12:11:59Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "963611c1fc5a98a825a8f0a42fc35eddbd9f7045",
   "id": "push:963611c1fc5a98a825a8f0a42fc35eddbd9f7045"
  },
  {
   "t": "2026-10-11T12:59:20Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "bf7ea087bc0c6e91a5c049028e5087444f8f0379",
   "id": "push:bf7ea087bc0c6e91a5c049028e5087444f8f0379"
  },
  {
   "t": "2026-10-15T07:04:11Z",
   "kind": "comment",
   "who": "cyb3ralbert",
   "assoc": "CONTRIBUTOR",
   "text": "Could the new cases use the existing `node` (`nodes[1]`) instead of adding a fifth node? At bf7ea08, `feature_assumeutxo.py` passes with `snapshot_node = self.nodes[1]`, both with and without `num_nodes = 4`. The fifth node conflicts with #35307, which also sets `num_nodes = 5` and uses `nodes[4]`. With both PRs merged (head d07900cf8), `src/` merges cleanly, but `feature_assumeutxo.py` fails on the duplicate-records case with \"Can't activate a snapshot-based chainstate more than once\".\n\nTested bf7ea08 on regtest with a 299-coin snapshot. The new cases fail on the merge base (a8b69bd) and pass on the PR head. A file with duplicated records and a doubled count is accepted on the base (`coins_loaded: 598`) but rejected on the head with \"Bad snapshot coins count: expected 598, got 299\". A single empty group after the header is accepted on the base and rejected on the head.\n\nWith 30M empty groups (990 MB) before a valid body, the base accepts the file in 2.13 s and exits 1.00 s after `stop` is issued 1 s into the load. The head rejects it in 0.04 s. After either rejection, `chainstate_snapshot` is gone, `getchainstates` shows one chainstate, and a valid snapshot then loads and survives a restart.\n\nThe count check also catches a single repeated outpoint, both within a group and in a repeated group. In either case, the head rejects it with \"Bad snapshot coins count: expected 300, got 299\" and leaves one chainstate.\n\n`wallet_assumeutxo.py` and `validation_chainstatemanager_tests` pass. Both `utxo_snapshot` and `utxo_snapshot_invalid` fuzz targets ran for 10 minutes each on the head using libFuzzer without sanitizers, starting from an empty corpus, with no crashes (127k and 1.45M runs, respectively).\n\nNot tested: mainnet-size snapshots.",
   "id": "c:6186482161",
   "url": "https://github.com/bitcoin/bitcoin/pull/36500#issuecomment-6186482161",
   "hash": "e6376d6dc7d15a88",
   "edited": null
  },
  {
   "t": "2026-10-15T11:38:34Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "e7a381661cac0676107833b0cd4bfce5764b5cc9",
   "id": "push:e7a381661cac0676107833b0cd4bfce5764b5cc9"
  },
  {
   "t": "2026-10-15T13:41:17Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0",
   "id": "push:e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0"
  },
  {
   "t": "2026-10-25T11:37:57Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "NONE",
   "text": "tACK e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0\n\nA repeated outpoint need not repeat the same coin. I appended a second record for an existing outpoint to the regtest snapshot, with a 500-byte script, and raised the count by one. On the base (cce32b1687) the file loads with `coins_loaded` 300, and `gettxout` returns the original coin: the coins cache keeps the first record for an outpoint, so the content hash still matches. The head rejects the file with \"Bad snapshot coins count: expected 300, got 299\". With the extra record placed before the original, both reject it on the hash.\n\nThe loaded UTXO set is still the committed one. What the base leaves unchecked is the reported count and the extra bytes in the file. `AssumeutxoData` holds no coin count, so this holds for a file matching any hardcoded entry, mainnet included.\n\nI built bitcoind at the head and at the base, and ran the head's `feature_assumeutxo.py` and a small `loadtxoutset` script on both. The test fails on the base at the duplicate case and passes on the head. I can share the script.",
   "id": "c:6397148633",
   "url": "https://github.com/bitcoin/bitcoin/pull/36500#issuecomment-6397148633",
   "hash": "0cc1f834a499ef8e",
   "edited": null
  }
 ],
 "labels_log": [
  {
   "t": "2026-10-11T11:11:00Z",
   "action": "labeled",
   "label": "Validation",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-11T12:12:19Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-11T14:10:02Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2026-10-11T12:12:58Z",
   "kind": "renamed",
   "who": "l0rinc",
   "from": "validation: reject duplicate UTXO snapshot records",
   "to": "validation: reject duplicate coins and empty snapshot groups"
  },
  {
   "t": "2026-10-14T07:11:42Z",
   "kind": "renamed",
   "who": "l0rinc",
   "from": "validation: reject duplicate coins and empty snapshot groups",
   "to": "assumeUTXO: reject duplicate coins and empty snapshot groups"
  },
  {
   "t": "2026-10-15T11:35:46Z",
   "kind": "renamed",
   "who": "l0rinc",
   "from": "assumeUTXO: reject duplicate coins and empty snapshot groups",
   "to": "AssumeUTXO: reject duplicate coins and empty snapshot groups"
  }
 ],
 "text_chars": 4732,
 "text_tokens_estimate": 1183,
 "changed_paths": [
  "src/node/utxo_snapshot.h",
  "src/validation.cpp",
  "test/functional/feature_assumeutxo.py"
 ],
 "files": [
  {
   "path": "src/node/utxo_snapshot.h",
   "add": 1,
   "del": 1
  },
  {
   "path": "src/validation.cpp",
   "add": 10,
   "del": 0
  },
  {
   "path": "test/functional/feature_assumeutxo.py",
   "add": 25,
   "del": 9
  }
 ],
 "test_lines": 34,
 "git": {
  "head": "e8e8cbcaa25ba75ad04cbacea2ca3d4254bf23d0",
  "head_matches_backup": true,
  "base": "cce32b16874e85e8a00cfa1a2190f0b6f4dcaf66",
  "patch_id": "a2d8d32f74f22326",
  "commits": [
   {
    "sha": "8e2b70b77b",
    "subject": "test: characterize duplicate snapshot records",
    "files": 1,
    "add": 23,
    "del": 11
   },
   {
    "sha": "f1a903e47b",
    "subject": "validation: check imported unique UTXO count",
    "files": 3,
    "add": 7,
    "del": 3
   },
   {
    "sha": "773024812c",
    "subject": "test: characterize empty snapshot groups",
    "files": 1,
    "add": 8,
    "del": 0
   },
   {
    "sha": "e8e8cbcaa2",
    "subject": "validation: reject empty snapshot groups",
    "files": 2,
    "add": 9,
    "del": 7
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "a2d8d32f74f22326",
 "input_hash": "3371800c2f054d09",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}