{
 "number": 34864,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/34864",
 "title": "coins: tighten cache entry state invariants",
 "author": "l0rinc",
 "author_association": "MEMBER",
 "created_at": "2026-03-19T12:01:42Z",
 "updated_at": "2026-09-17T04:08:24Z",
 "age_days": 182,
 "draft": false,
 "labels": [
  "UTXO Db and Indexes"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
 "head_ref": "l0rinc/coins-cache-invariants",
 "head_repo": "l0rinc/bitcoin",
 "head_history": [
  {
   "t": "2026-04-19T20:09:35Z",
   "sha": "fa9683af5f74d81c4907ad81d888a94ef2e880be"
  },
  {
   "t": "2026-07-05T05:49:07Z",
   "sha": "4812de53e99d1a502f66764efb019c1c08b87ab8"
  },
  {
   "t": "2026-08-11T23:32:21Z",
   "sha": "f27eba862da60c11e5e1e70250cc267f4a1b0a52"
  }
 ],
 "additions": 150,
 "deletions": 192,
 "changed_files": 9,
 "commit_count": 9,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "optout21",
      "url": "https://github.com/bitcoin/bitcoin/pull/34864#issuecomment-5566315263"
     }
    ],
    "concept_ack": [
     {
      "login": "andrewtoth",
      "url": "https://github.com/bitcoin/bitcoin/pull/34864#pullrequestreview-4134896528"
     }
    ]
   },
   "conflicts": [
    {
     "number": 35793,
     "title": "Implement BIP 54 (Consensus Cleanup) without mainnet activation",
     "author": "darosior"
    },
    {
     "number": 35569,
     "title": "Encapsulation for CTransaction",
     "author": "purpleKarrot"
    }
   ]
  }
 },
 "acks_parsed": {
  "andrewtoth": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-04-18T18:59:13Z",
   "stale": false
  },
  "optout21": {
   "kind": "ack",
   "hash": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "t": "2026-09-07T06:56:53Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 1,
  "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": [
   "andrewtoth",
   "optout21",
   "ptrinh"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-08-11T23:32:21Z",
  "last_reviewer_activity": "2026-09-07T06:56:53Z",
  "last_reviewer": "optout21",
  "author_silent_days": 36,
  "waiting_on_author_days": 10,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [
   30673,
   33018
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 30673,
    "type": "pull",
    "state": "closed",
    "merged": false,
    "merged_at": null,
    "title": "coins: remove logic for spent-and-FRESH cache entries and writing non-DIRTY entries"
   },
   {
    "number": 33018,
    "type": "pull",
    "state": "closed",
    "merged": false,
    "merged_at": null,
    "title": "coins: remove SetFresh method from CCoinsCacheEntry"
   }
  ],
  "conflicts": [
   35793,
   35569
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/coins.cpp",
  "src/coins.h",
  "src/txdb.cpp"
 ],
 "body": "**Problem:** The coins cache still exposed and tested states that production code should not create: standalone `FRESH` entries, non-dirty `BatchWrite()` cursor entries, and spent `FRESH` entries.\nThat makes the representation harder to reason about, because `CCoinsCacheEntry` stores a separate `DIRTY` bit even though dirty entries are already tracked in the linked list.\n\n**Fix:** This revives and splits the relevant parts of #30673 and #33018.\nThe series passes freshness through `SetDirty()`, removes fresh-only test states, treats `BatchWrite()` cursor entries as dirty by construction, rejects spent `FRESH` entries, removes the bare `SetFresh()` helper, and derives dirtiness from linked-list membership.\nThe final commits also tighten `SpendCoin()` around the same cache-state contract: repeated spends of an entry already cached as spent now fail without changing cache accounting or caller-owned output, and callers must either check the result or explicitly discard it.\n`coinscache_sim` now checks the real `SpendCoin()` result against its simulation model, and the no-op repeated-spend path no longer emits the `utxocache:spent` tracepoint because no spend occurs.",
 "commits": [
  {
   "sha": "213ae95d873580e683f3ac252e47a2e3f97136b6",
   "date": "2026-08-11T23:28:59Z",
   "message": "coins: pass freshness through `SetDirty()` in production\n\nProduction code already knows when a dirty entry should also be fresh.\nThread that state through `SetDirty(..., fresh)` in `AddCoin()` and the inserted-entry path in `BatchWrite()` while test-only callers still use `SetFresh()` directly.\n\nCo-authored-by: Andrew Toth <andrewstoth@gmail.com>"
  },
  {
   "sha": "0e5df95d31eab0a183cd13a7db6748f947bb2604",
   "date": "2026-08-11T23:28:59Z",
   "message": "test: remove fresh-only states from `coins_tests`\n\nProduction code no longer creates standalone `FRESH` entries.\nRemove the `State::FRESH` cases from `coins_tests.cpp` so the unit tests stop constructing cache states that production code cannot reach.\nThis keeps the test helper aligned with reachable cache states before the following `BatchWrite()` cleanup asserts that every cursor entry is dirty."
  },
  {
   "sha": "8498225d35e807850a3f65628c952c9dcd7f967c",
   "date": "2026-08-11T23:29:00Z",
   "message": "coins: assert `BatchWrite()` cursor entries are `DIRTY`\n\n`CoinsViewCacheCursor` is constructed with a dirty count and iterates the dirty-entry list.\n`CCoinsViewCache::BatchWrite()` and `CCoinsViewDB::BatchWrite()` can therefore treat cursor entries as dirty instead of checking `IsDirty()` again.\nRemove the redundant branches, simplify the committed-count log, and keep the cursor contract local with `assert(it->second.IsDirty())`.\n\nCo-authored-by: Andrew Toth <andrewstoth@gmail.com>"
  },
  {
   "sha": "2c1c6a937106c74e3f9c09fd148e4f8246f76274",
   "date": "2026-08-11T23:29:00Z",
   "message": "coins: reject spent `FRESH` entries in `BatchWrite()`\n\nA spent `FRESH` entry is inconsistent with how the cache writes to its parent.\nSpending a fresh entry erases it locally because the parent never knew it existed, so `BatchWrite()` should treat that state as a logic error.\nUpdate the `ccoins_write` table to cover the failure and keep `coins_view` fuzz-generated entries within that invariant."
  },
  {
   "sha": "34b235b1b5e522618ab67e875c60fe50d1f07d24",
   "date": "2026-08-11T23:30:59Z",
   "message": "coins: remove the bare `SetFresh()` helper\n\nFresh entries must also be dirty.\nRemove the bare `SetFresh()` helper and pass freshness through `SetDirty()` at the remaining call sites so the type no longer exposes a way to create standalone `FRESH` entries."
  },
  {
   "sha": "75278cdf7404db48f52d0a20f20906a64075dfaf",
   "date": "2026-08-11T23:31:03Z",
   "message": "coins: inline `AddFlags()` into `SetDirty()`\n\n`SetDirty()` is now the only helper that adds an entry to the dirty list.\nInline `AddFlags()` there so the following representation change only needs to update `SetDirty()` and `SetClean()`."
  },
  {
   "sha": "85b5f36e0f453aef7fecc286da1e7ee044cd1db4",
   "date": "2026-08-11T23:31:10Z",
   "message": "coins: derive `DIRTY` from linked list membership\n\n`CCoinsCacheEntry` only needs to store whether a dirty entry is also fresh.\nAfter the previous cleanup, list membership already identifies dirty entries, so keeping a separate `DIRTY` bit duplicates state.\nReplace `m_flags` with `m_fresh`, derive `IsDirty()` from the linked-list pointers, and require callers to pass the resulting freshness explicitly when marking an entry dirty.\nUpdate comments and pair tests to cover the new representation.\n\nCo-authored-by: Andrew Toth <andrewstoth@gmail.com>"
  },
  {
   "sha": "2d2cb4aaae0cfbc90de566dd03434995d449de38",
   "date": "2026-08-11T23:31:12Z",
   "message": "coins: preserve cache state on repeated spends\n\nSpending an outpoint that is already cached as spent should behave like spending any other outpoint without an unspent output: return false and leave caller-owned output unchanged.\nPreviously, `CCoinsViewCache::SpendCoin()` only checked whether `FetchCoin()` returned an entry, so a valid spent cache entry could be treated as successfully spent and overwrite `moveout` with an empty `Coin`.\nReturn false before touching `moveout` or cache accounting, after assuming the spent entry has the valid dirty-not-fresh state.\nExtend the existing `ccoins_spend` coverage to check the return value, unchanged spent entry, cache accounting, and `moveout` preservation on a failed spend.\nRemove the spent-clean and spent-fresh spend rows, since those cache states violate the invariant this commit now checks.\nLet the `coinscache_sim` fuzzer compare `SpendCoin()` results against its unspent-set model."
  },
  {
   "sha": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "date": "2026-08-11T23:31:13Z",
   "message": "coins: require `SpendCoin()` callers to inspect result\n\n`SpendCoin()` returns a bool because callers have different contracts: some handle a missing unspent output, some can prove the spend must succeed, and replay code can intentionally ignore repeated deletes.\nDocument the return value and mark it `[[nodiscard]]` so accidental ignored spends are rejected.\n`CTxMemPool::check()` already proves each input exists with `CheckTxInputs()`, so it now checks that each later spend succeeds while still applying the spend unconditionally.\n`RollforwardBlock()` casts the result to void because replaying partially applied block effects after an interrupted coins DB flush intentionally treats repeated deletes as no-ops."
  }
 ],
 "timeline": [
  {
   "t": "2026-04-18T18:37:55Z",
   "kind": "review_comment",
   "who": "andrewtoth",
   "assoc": "MEMBER",
   "path": "src/coins.cpp",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": null,
   "text": "Why remove this comment? It's still relevant and provides valuable context."
  },
  {
   "t": "2026-04-18T18:43:29Z",
   "kind": "review_comment",
   "who": "andrewtoth",
   "assoc": "MEMBER",
   "path": "src/txdb.cpp",
   "commit": "c4ddb1cebbccaa9a10a3d9f73e9c564de4f86026",
   "in_reply_to": null,
   "text": "The `changed` adjective could be left here. Existing transaction outputs that are deleted are `changed`. The log now can be conflated with committing only new transactions outputs."
  },
  {
   "t": "2026-04-18T18:46:57Z",
   "kind": "review_comment",
   "who": "andrewtoth",
   "assoc": "MEMBER",
   "path": "src/coins.h",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": null,
   "text": "Why is this touched here?"
  },
  {
   "t": "2026-04-18T18:59:09Z",
   "kind": "review_comment",
   "who": "andrewtoth",
   "assoc": "MEMBER",
   "path": "src/coins.h",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": null,
   "text": "This is a behavior change, and the commit message says:\n```\nMake `SetDirty()` take the resulting freshness explicitly so callers cannot accidentally preserve an old fresh state by passing `false`.\n```\n\nWhy do we want to set a fresh entry to not-fresh? If an entry is fresh, it should stay fresh until cleaned or erased as before."
  },
  {
   "t": "2026-04-18T18:59:13Z",
   "kind": "review",
   "who": "andrewtoth",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "5c30d62df48656399e48fdda52d4c6cbd4471789",
   "text": "Concept ACK\n\nThanks for picking this up."
  },
  {
   "t": "2026-04-19T19:44:03Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "src/coins.h",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": 3105667838,
   "text": "[quoted text omitted]\n\n[quoted text omitted]\nAdded `Assume(!pair.second.m_fresh || fresh)` so the API can't silently clear FRESH, thanks"
  },
  {
   "t": "2026-04-19T19:44:10Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "src/coins.h",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": 3105652703,
   "text": "Just for symmetry with `SetDirty`"
  },
  {
   "t": "2026-04-19T19:44:13Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "src/txdb.cpp",
   "commit": "c4ddb1cebbccaa9a10a3d9f73e9c564de4f86026",
   "in_reply_to": 3105647133,
   "text": "Added `changed` back"
  },
  {
   "t": "2026-04-19T19:44:36Z",
   "kind": "review_comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "path": "src/coins.cpp",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
   "in_reply_to": 3105640231,
   "text": "Restored above the new `SetDirty` call"
  },
  {
   "t": "2026-04-19T20:09:35Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "fa9683af5f74d81c4907ad81d888a94ef2e880be"
  },
  {
   "t": "2026-04-19T20:10:31Z",
   "kind": "review",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "fa9683af5f74d81c4907ad81d888a94ef2e880be",
   "text": "Adjusted the code and commit messages, thanks.\n\n```bash\ngit range-diff 745d97496188d50ad3e1a7173bb30d363e97b9c8..5c30d62df48656399e48fdda52d4c6cbd4471789 000efb0bdf928195ddc168be80741c55708b047a..fa9683af5f74d81c4907ad81d888a94ef2e880be\n```"
  },
  {
   "t": "2026-04-23T16:26:01Z",
   "kind": "review",
   "who": "optout21",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "fa9683af5f74d81c4907ad81d888a94ef2e880be",
   "text": "ACK fa9683af5f74d81c4907ad81d888a94ef2e880be\n\nMy perspective on the benefit of this change is as follows: The `CCoinsCacheEntry` class is capable of expressing several combinations of properties, some of which are invalid and never used. They are invalid logically, and enforced by convention only. Post-change the invalid states cannot be represented. This change is a code maintainability benefit, because any code change could potentially break the conventions. It also results in slightly simpler code.\n\nThe relevant state space dimensions are:\n- The \"dirty\" and \"fresh\" properties were represented as independent bools (stored in a  bitfield), however, of the 2x2 combinations the fresh-but-not-dirty is invalid (because new entries in the cache that are not present in an underlying level must always be saved). This combination is not used.\n- A dirty (or fresh) entry must be placed in the linked list of dirty elements. The dirty flag and the existence of the linked list pointers may contradict.\n\nPost change:\n- Dirtyness is implicitly represented by being included in the linked list, not by an explicit flag.\n- Freshness is represented as a single bool flag, and the setter methods have been changed to prevent the invalid state. Asserts have been also added.\n\nThe essence of the change is concentrated to the `CCoinsCacheEntry` class, and is relatively simple. However, auxiliary changes are needed to ensure/assert the call sites and the tests. Moreover, the change is broken up into smaller consistent steps.\n\nAn overview of the commits (in chronological order):\n- `SetFresh` is being folded into `SetDirty` with a `fresh` flag. At the relevant call site the \"freshness\" information is already available.\n- Tests are adjusted to not use the invalid fresh-but-not-dirty state.\n- In `BatchWrite` drop the conditional for invalid non-dirty cases, replace them with `assert`.\n- Also in `BatchWrite`, for the invalid fresh-but-spent case instead of being ignored it is handled as an exception.\n- Drop `SetFresh`.\n- Small refactor to fold the now-single-use `AddFlags` into `SetDirty`\n- Perform the internal representation change (Dirtiness is derived from the linked list pointers, and freshness is stored as a bool. Extra asserts are added to checks for preconditions. Also, the default value for the `fresh` option on `SetDirty` is removed (so its usage is more explicit)).\n\nI have reimplemented part of the changes independently. I've reviewed the code, and found no issues. (Note: as the PR had only light review so far, further changes are likely.)"
  },
  {
   "t": "2026-06-24T08:00:23Z",
   "kind": "comment",
   "who": "ptrinh",
   "assoc": "NONE",
   "text": "Tested the coins fuzz targets on this branch (commit fa9683af5f), since the PR turns the previously-handled FRESH+spent cases in `BatchWrite()` into hard asserts/throws and the safety of that rests on those states never occurring in practice.\n\nSetup: arm64 macOS (Apple M-series), Homebrew clang 22, `-DBUILD_FOR_FUZZING=ON -DSANITIZERS=fuzzer,address,undefined`. Started from an empty corpus (no qa-assets seed), libFuzzer, 2h per target:\n\n- `coins_view`: 3,230,233 execs, peak cov 2961 edges / 14028 features\n- `coinscache_sim`: 1,443,489 execs, peak cov 1700 edges / 12217 features\n\nNo crashes, OOMs, sanitizer errors, or hits on the new `BatchWrite()` `logic_error`. The coins unit suites (`coins_tests`, `coinscachepair_tests`, `coinsviewoverlay_tests`, `coinstatsindex_tests`) also pass on this branch.\n\nCaveat: this was an unseeded 2h run, so it's shallower than a seeded run against the qa-assets corpus would be; happy to do a longer seeded run if useful. I also read through the `BatchWrite`/`SetDirty`/`IsDirty` changes and the fresh-implies-dirty invariant looks internally consistent."
  },
  {
   "t": "2026-07-05T05:49:07Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "4812de53e99d1a502f66764efb019c1c08b87ab8"
  },
  {
   "t": "2026-07-05T06:01:22Z",
   "kind": "comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "text": "Rebased and added two commits tightening `SpendCoin()` return-value handling"
  },
  {
   "t": "2026-08-11T23:32:21Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "f27eba862da60c11e5e1e70250cc267f4a1b0a52"
  },
  {
   "t": "2026-09-07T06:56:53Z",
   "kind": "comment",
   "who": "optout21",
   "assoc": "CONTRIBUTOR",
   "text": "reACK f27eba862da60c11e5e1e70250cc267f4a1b0a52\n\nNo issues. A net reduction in complexity around coins view flags ([my previous reivew](https://github.com/bitcoin/bitcoin/pull/34864#pullrequestreview-4164135258) had more details)."
  }
 ],
 "labels_log": [
  {
   "t": "2026-03-19T12:01:46Z",
   "action": "labeled",
   "label": "UTXO Db and Indexes",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-11T16:27:59Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-12T01:29:17Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2026-07-05T05:49:11Z",
   "kind": "renamed",
   "who": "l0rinc",
   "from": "coins: make cache freshness imply dirtiness and remove invalid test states",
   "to": "coins: tighten cache entry state invariants"
  }
 ],
 "text_chars": 10653,
 "text_tokens_estimate": 2663,
 "changed_paths": [
  "src/coins.cpp",
  "src/coins.h",
  "src/test/coins_tests.cpp",
  "src/test/coinscachepair_tests.cpp",
  "src/test/fuzz/coins_view.cpp",
  "src/test/fuzz/coinscache_sim.cpp",
  "src/txdb.cpp",
  "src/txmempool.cpp",
  "src/validation.cpp"
 ],
 "files": [
  {
   "path": "src/coins.cpp",
   "add": 29,
   "del": 32
  },
  {
   "path": "src/coins.h",
   "add": 49,
   "del": 62
  },
  {
   "path": "src/test/coins_tests.cpp",
   "add": 31,
   "del": 49
  },
  {
   "path": "src/test/coinscachepair_tests.cpp",
   "add": 10,
   "del": 8
  },
  {
   "path": "src/test/fuzz/coins_view.cpp",
   "add": 8,
   "del": 7
  },
  {
   "path": "src/test/fuzz/coinscache_sim.cpp",
   "add": 11,
   "del": 22
  },
  {
   "path": "src/txdb.cpp",
   "add": 7,
   "del": 10
  },
  {
   "path": "src/txmempool.cpp",
   "add": 4,
   "del": 1
  },
  {
   "path": "src/validation.cpp",
   "add": 1,
   "del": 1
  }
 ],
 "test_lines": 146,
 "git": {
  "head": "f27eba862da60c11e5e1e70250cc267f4a1b0a52",
  "head_matches_backup": true,
  "base": "1bec7fa22c9f159eda42b95543e231322df1f755",
  "commits": [
   {
    "sha": "213ae95d87",
    "subject": "coins: pass freshness through `SetDirty()` in production",
    "files": 2,
    "add": 9,
    "del": 8
   },
   {
    "sha": "0e5df95d31",
    "subject": "test: remove fresh-only states from `coins_tests`",
    "files": 1,
    "add": 9,
    "del": 31
   },
   {
    "sha": "8498225d35",
    "subject": "coins: assert `BatchWrite()` cursor entries are `DIRTY`",
    "files": 4,
    "add": 19,
    "del": 40
   },
   {
    "sha": "2c1c6a9371",
    "subject": "coins: reject spent `FRESH` entries in `BatchWrite()`",
    "files": 3,
    "add": 33,
    "del": 33
   },
   {
    "sha": "34b235b1b5",
    "subject": "coins: remove the bare `SetFresh()` helper",
    "files": 4,
    "add": 6,
    "del": 12
   },
   {
    "sha": "75278cdf74",
    "subject": "coins: inline `AddFlags()` into `SetDirty()`",
    "files": 1,
    "add": 15,
    "del": 21
   },
   {
    "sha": "85b5f36e0f",
    "subject": "coins: derive `DIRTY` from linked list membership",
    "files": 3,
    "add": 50,
    "del": 55
   },
   {
    "sha": "2d2cb4aaae",
    "subject": "coins: preserve cache state on repeated spends",
    "files": 3,
    "add": 18,
    "del": 5
   },
   {
    "sha": "f27eba862d",
    "subject": "coins: require `SpendCoin()` callers to inspect result",
    "files": 3,
    "add": 7,
    "del": 3
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "7303e1431e139ca4",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}