{
 "number": 35307,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/35307",
 "title": "blockstorage: keep snapshot base in normal blockfile range",
 "author": "shuv-amp",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-05-17T15:57:52Z",
 "updated_at": "2026-09-17T01:39:09Z",
 "age_days": 123,
 "draft": false,
 "labels": [
  "Block storage"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "d07900cf8cc724d87a6acfd6111def0bcff15a36",
 "head_ref": "blockstorage-assumeutxo-base-range",
 "head_repo": "shuv-amp/bitcoin",
 "head_history": [
  {
   "t": "2026-05-17T17:18:15Z",
   "sha": "1a22546ccf9055ffc138a5b258d2f1a00bc2c199"
  },
  {
   "t": "2026-05-21T12:21:05Z",
   "sha": "b520eabc04f66474e2a6b6462d4b9ee2919f4b71"
  },
  {
   "t": "2026-06-09T14:47:31Z",
   "sha": "bbdad237ed472609000179ac0e544f68fa3f3a8d"
  },
  {
   "t": "2026-08-19T21:57:33Z",
   "sha": "d07900cf8cc724d87a6acfd6111def0bcff15a36"
  }
 ],
 "additions": 85,
 "deletions": 26,
 "changed_files": 5,
 "commit_count": 1,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {},
   "conflicts": [
    {
     "number": 35731,
     "title": "Indexes: Harden the flush-error notification invariant",
     "author": "arejula27"
    },
    {
     "number": 35714,
     "title": "validation: stop writes after flush failure",
     "author": "l0rinc"
    },
    {
     "number": 35646,
     "title": "RFC: Separate out runtime errors from BlockValidationState using `util::Expected`",
     "author": "yuvicc"
    },
    {
     "number": 29700,
     "title": "kernel, refactor: return error status on all fatal errors",
     "author": "ryanofsky"
    }
   ]
  }
 },
 "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": [
   "mzumsande"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-08-19T22:11:42Z",
  "last_reviewer_activity": "2026-05-18T13:45:59Z",
  "last_reviewer": "mzumsande",
  "author_silent_days": 28,
  "waiting_on_author_days": 0,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": [
   35731,
   35714,
   35646,
   29700
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [],
 "body": "A node that has the assumeutxo snapshot base block on disk before `loadtxoutset()` runs ends up\nin a state it cannot recover from. Depending on whether it is restarted, it either aborts or\nrefuses to start.\n\nAborting, when background validation reaches the base block:\n\n```\nnode/blockstorage.cpp:985 bool node::BlockManager::WriteBlockUndo(const CBlockUndo &, BlockValidationState &, CBlockIndex &): Assertion `m_blockfile_cursors[type]' failed.\n```\n\nRefusing to start, if the node is restarted after the snapshot is loaded:\n\n```\n[ReadBlockUndo] OpenUndoFile failed for FlatFilePos(nFile=-1, nPos=0) while reading block undo\n[DisconnectBlock] failure reading undo data\n[VerifyDB] Verification error: irrecoverable inconsistency in block data at 299\nCorrupted block database detected.\nPlease restart with -reindex or -reindex-chainstate to recover.\n```\n\nThe block database is not corrupt, so the reindex that message asks for is wasted work.\n\nReaching this needs the base block stored before the snapshot is loaded, which `submitblock`\ndoes: it is accepted and written while `BlockManager::m_snapshot_height` is still unset, so it\ngoes to the normal blockfile cursor. `loadtxoutset()` then sets `m_snapshot_height` to that same\nheight, and `BlockfileTypeForHeight()` starts reporting `ASSUMED` for it.\n\nFrom there the two failures follow:\n\n- `WriteBlockUndo()` looks up the cursor for the block's height and does\n  `*Assert(m_blockfile_cursors[type])`. The block is now `ASSUMED`, but nothing above the\n  snapshot height has been written, so that cursor does not exist and the assertion fires when\n  the background chainstate connects the base. `FindNextBlockPos()` creates the cursor lazily in\n  the same situation; `WriteBlockUndo()` asserts instead.\n- `VerifyDB()` skips blocks without `BLOCK_HAVE_DATA` on a snapshot chainstate\n  (`validation.cpp:4674`), which is why the base is normally left alone. Here the base does have\n  data, and no undo data because the snapshot chainstate never connected it, so the skip does not\n  apply and `DisconnectBlock()` is attempted on it.\n\nThe base block is connected by the background chainstate, not the snapshot one, so it belongs to\nthe normal blockfile range. `BlockfileTypeForHeight()` now classifies only blocks *above* the\nsnapshot height as `ASSUMED`. A snapshot chainstate sitting at the base height must not flush the\nnormal cursor, so `FlushChainstateBlockFile()` takes the chainstate into account.\n\n`VerifyDB()` stops before disconnecting the snapshot base, where the snapshot database does not\nhave the ancestor UTXO data the disconnect needs.\n\nWhen a snapshot chainstate is loaded from disk, the base's hardcoded `m_chain_tx_count` does not\nmean the background chainstate has processed its parents, so the base is kept in\n`m_blocks_unlinked` until they arrive, and a historical chainstate does not add its target to the\nblock index candidates before then.\n\nThe functional test submits the base block, loads the snapshot, restarts, then feeds the missing\nhistorical blocks and checks that background validation completes. Both failures above are what\nit hits without this change.\n\nTested:\n\n```bash\nbuild/test/functional/test_runner.py feature_assumeutxo.py wallet_assumeutxo.py feature_pruning.py feature_reindex.py --timeout-factor=4\nbuild/bin/test_bitcoin\n```",
 "commits": [
  {
   "sha": "d07900cf8cc724d87a6acfd6111def0bcff15a36",
   "date": "2026-08-19T21:37:50Z",
   "message": "blockstorage: keep snapshot base in normal blockfile range\n\nThe background chainstate connects the assumeutxo snapshot base block. If\nthat block is accepted before loadtxoutset() sets m_snapshot_height, it is\nwritten using the normal blockfile cursor.\n\nAfter snapshot activation, classify only blocks above the base height as\nASSUMED. This keeps the already-stored base block and its undo data on the\nnormal cursor, while snapshot-chainstate flushes at the base height still\navoid flushing normal block data.\n\nOn restart, keep an already-stored base block in m_blocks_unlinked until\nits parents are processed, and avoid adding the historical target to\nsetBlockIndexCandidates before then. Stop VerifyDB before disconnecting the\nsnapshot base, where the snapshot database lacks the ancestor UTXO data.\n\nAdd functional coverage for the base block already being on disk before\nsnapshot activation, including a restart before background validation\ncompletes. Adjust the wallet assumeutxo prune-height test so the backup at\nthe snapshot base remains available."
  }
 ],
 "timeline": [
  {
   "t": "2026-05-17T17:18:15Z",
   "kind": "force_push",
   "who": "shuv-amp",
   "commit": "1a22546ccf9055ffc138a5b258d2f1a00bc2c199"
  },
  {
   "t": "2026-05-18T13:45:59Z",
   "kind": "review",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "1a22546ccf9055ffc138a5b258d2f1a00bc2c199",
   "text": "why would someone load a snapshot when they already downloaded the block, which means that their tip must be no more than 1024 blocks behind that block."
  },
  {
   "t": "2026-05-18T15:47:29Z",
   "kind": "comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "text": "I don't think the normal P2P download path is a strong motivation here. In that case, as you say, the node would already be close to the snapshot height.\n\nThe case covered here is that this blockstorage state is accepted through local block submission/import before snapshot activation. For example, `submitblock` can store the snapshot base block while `m_snapshot_height` is still unset, so it is accounted under the normal blockfile cursor. After `loadtxoutset()`, the same height maps to `ASSUMED`; if no assumed blockfile cursor exists yet, background validation can later connect that block and hit the cursor assertion in `WriteBlockUndo()`.\n\nSo I\u2019d frame this as making an accepted blockstorage state not abort, rather than as an important/common assumeutxo path. If maintainers prefer not to support that state, rejecting it during snapshot\nactivation would be another possible direction."
  },
  {
   "t": "2026-05-19T19:28:12Z",
   "kind": "comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "text": "Taking this back to draft for now."
  },
  {
   "t": "2026-05-21T12:21:05Z",
   "kind": "force_push",
   "who": "shuv-amp",
   "commit": "b520eabc04f66474e2a6b6462d4b9ee2919f4b71"
  },
  {
   "t": "2026-06-09T14:47:31Z",
   "kind": "force_push",
   "who": "shuv-amp",
   "commit": "bbdad237ed472609000179ac0e544f68fa3f3a8d"
  },
  {
   "t": "2026-08-19T21:57:33Z",
   "kind": "force_push",
   "who": "shuv-amp",
   "commit": "d07900cf8cc724d87a6acfd6111def0bcff15a36"
  },
  {
   "t": "2026-08-19T22:11:42Z",
   "kind": "comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "text": "Following up with what this actually does on master, in May I only described the assertion.\n\nIf the node is restarted after the snapshot is loaded, it doesn't come back up:\n\n    [ReadBlockUndo] OpenUndoFile failed for FlatFilePos(nFile=-1, nPos=0) while reading block undo\n    [DisconnectBlock] failure reading undo data\n    [VerifyDB] Verification error: irrecoverable inconsistency in block data at 299\n    Corrupted block database detected.\n    Please restart with -reindex or -reindex-chainstate to recover.\n\nThe database isn't corrupt, with this change the same sequence restarts and background\nvalidation completes. On a snapshot chainstate `VerifyDB()` stops its walk at the first block\nwith no data, which is normally the base, so it never gets that far. Here the base does have\ndata, and no undo data because the snapshot chainstate never connected it, so the walk goes into\nit and the disconnect fails. The `WriteBlockUndo()` assertion I mentioned in May is the other\npath, when the node keeps running rather than restarting.\n\nI've kept this as fixing the state rather than rejecting it at activation, since rejecting\nwouldn't help a node that has already reached it.\n\nRebased."
  }
 ],
 "labels_log": [
  {
   "t": "2026-05-17T15:57:55Z",
   "action": "labeled",
   "label": "Block storage",
   "who": "DrahtBot"
  },
  {
   "t": "2026-05-21T14:01:03Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-05-22T08:02:51Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-06-08T19:31:22Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-06-09T16:00:24Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2026-05-17T20:19:12Z",
   "kind": "renamed",
   "who": "shuv-amp",
   "from": "blockstorage: keep assumeutxo base block in normal blockfile range",
   "to": "blockstorage: handle undo for assumeutxo base block already on disk"
  },
  {
   "t": "2026-05-17T20:26:20Z",
   "kind": "ready_for_review",
   "who": "shuv-amp"
  },
  {
   "t": "2026-05-19T19:28:51Z",
   "kind": "convert_to_draft",
   "who": "shuv-amp"
  },
  {
   "t": "2026-05-21T13:43:31Z",
   "kind": "renamed",
   "who": "shuv-amp",
   "from": "blockstorage: handle undo for assumeutxo base block already on disk",
   "to": "blockstorage: keep snapshot base in normal blockfile range"
  },
  {
   "t": "2026-05-21T16:02:59Z",
   "kind": "ready_for_review",
   "who": "shuv-amp"
  }
 ],
 "text_chars": 6706,
 "text_tokens_estimate": 1676,
 "changed_paths": [
  "src/node/blockstorage.cpp",
  "src/node/blockstorage.h",
  "src/validation.cpp",
  "test/functional/feature_assumeutxo.py",
  "test/functional/wallet_assumeutxo.py"
 ],
 "files": [
  {
   "path": "src/node/blockstorage.cpp",
   "add": 18,
   "del": 6
  },
  {
   "path": "src/node/blockstorage.h",
   "add": 1,
   "del": 1
  },
  {
   "path": "src/validation.cpp",
   "add": 13,
   "del": 2
  },
  {
   "path": "test/functional/feature_assumeutxo.py",
   "add": 44,
   "del": 11
  },
  {
   "path": "test/functional/wallet_assumeutxo.py",
   "add": 9,
   "del": 6
  }
 ],
 "test_lines": 70,
 "git": {
  "head": "d07900cf8cc724d87a6acfd6111def0bcff15a36",
  "head_matches_backup": true,
  "base": "367b2202a496c866c485d940864171ca97c514f2",
  "commits": [
   {
    "sha": "d07900cf8c",
    "subject": "blockstorage: keep snapshot base in normal blockfile range",
    "files": 5,
    "add": 85,
    "del": 26
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "8ab6fe66c24e19ec",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}