{
 "number": 34254,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/34254",
 "title": "validation: Prevent duplicate logging and looping in invalid block handling",
 "author": "mzumsande",
 "author_association": "MEMBER",
 "created_at": "2026-01-11T16:59:21Z",
 "updated_at": "2026-09-17T06:34:12Z",
 "age_days": 248,
 "draft": false,
 "labels": [
  "Validation"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
 "head_ref": "202601_invalidchain",
 "head_repo": "mzumsande/bitcoin",
 "head_history": [
  {
   "t": "2026-02-01T06:23:53Z",
   "sha": "f789d4fa160ee53145555c6d068b7dd3a23e88be"
  },
  {
   "t": "2026-08-19T15:35:40Z",
   "sha": "ff06c05043bf2324085dd0a1969239a9fd19eda5"
  }
 ],
 "additions": 64,
 "deletions": 30,
 "changed_files": 6,
 "commit_count": 3,
 "size_bucket": "S",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "Crypt-iQ",
      "url": "https://github.com/bitcoin/bitcoin/pull/34254#issuecomment-5444370723"
     }
    ],
    "concept_ack": [
     {
      "login": "frankomosh",
      "url": "https://github.com/bitcoin/bitcoin/pull/34254#pullrequestreview-3697084304"
     },
     {
      "login": "stratospher",
      "url": "https://github.com/bitcoin/bitcoin/pull/34254#pullrequestreview-3997887968"
     }
    ],
    "stale_ack": [
     {
      "login": "bensig",
      "url": "https://github.com/bitcoin/bitcoin/pull/34254#issuecomment-3740651522"
     }
    ]
   },
   "conflicts": [
    {
     "number": 35646,
     "title": "RFC: Separate out runtime errors from BlockValidationState using `util::Expected`",
     "author": "yuvicc"
    }
   ]
  }
 },
 "acks_parsed": {
  "bensig": {
   "kind": "ack",
   "hash": "73e0bab7adb0b16b2ac4d13c9d601a04b7184749",
   "t": "2026-01-12T21:48:39Z",
   "stale": true
  },
  "frankomosh": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-01-23T11:42:36Z",
   "stale": false
  },
  "stratospher": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-03-24T10:10:06Z",
   "stale": false
  },
  "Crypt-iQ": {
   "kind": "ack",
   "hash": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "t": "2026-08-27T19:43:45Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 1,
  "stale_ack": 1,
  "concept_ack": 2,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "Crypt-iQ",
   "bensig",
   "frankomosh",
   "sedited",
   "stratospher"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-08-19T15:56:27Z",
  "last_reviewer_activity": "2026-08-27T19:43:45Z",
  "last_reviewer": "Crypt-iQ",
  "author_silent_days": 29,
  "waiting_on_author_days": 20,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": [
   35646
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/validation.cpp"
 ],
 "body": "`InvalidChainFound()` is called from 3 spots:\n1. from `InvalidBlockFound()`\n2. from `ActivateBestChain()`, with the highest connectable block of the chain\nas an arg (after having called `InvalidBlockFound()` already for the block that failed in `ConnectTip()`)\n3. from `InvalidateBlock()` (rpc-only)\n\nMost of the logic in `InvalidChainFound()` is not required for 2) and 3):\nIn `ActivateBestChain()`, the previous call to `InvalidChainFound()` before via `InvalidBlockFound` did the flag updates, best header calculation and logging, so only `m_best_invalid` could need additional updating.\nIn `InvalidateBlock()` (which doesn't call `InvalidBlockFound()`), we do most of the accounting manually.\n\nTherefore move of all logic but the `m_best_invalid` update into `InvalidBlockFound()`, avoiding duplicate logging and unnecessary work (the loops over the block index in `SetBlockFailureFlags()` and `RecalculateBestHeader()`\nwere done twice before).\n\nThe second commit renames `InvalidChainFound` to `UpdateBestInvalid` to adjust to its remaining role.\n\nThis addresses past discussions about repeated work in `InvalidateBlock()` (https://github.com/bitcoin/bitcoin/pull/31405#discussion_r2135717500) and `ActivateBestChain()` (https://github.com/bitcoin/bitcoin/pull/31533#discussion_r2607189790)",
 "commits": [
  {
   "sha": "3c16dc54e9697df8fdebbcbbe75f6b171ef76c76",
   "date": "2026-08-19T14:51:43Z",
   "message": "test: functional test for invalidateblock of blocks not in chain"
  },
  {
   "sha": "b335786873cfffd75a98163cf36cfaadd9419b53",
   "date": "2026-08-19T15:09:09Z",
   "message": "validation: Move most logic from InvalidChainFound into InvalidBlockFound\n\nInvalidChainFound is called from 3 spots:\n1. from InvalidBlockFound\n2. from ActivateBestChain, with the highest connectable block of the chain\nas an arg (after having called InvalidBlockFound already for the block that\nactually failed)\n3. from InvalidateBlock\n\nMost of the logic in InvalidChainFound is not required for 2):\nIn ActivateBestChain, we already called InvalidChainFound before via\nInvalidBlockFound, so only m_best_invalid needs updating. This would\nalso result in duplicate log entries.\n\nTherefore move of all logic but the m_best_invalid update into\nInvalidBlockFound, avoiding duplicate logging and unnecessary work\n(the loops over the block index in SetBlockFailureFlags and RecalculateBestHeader\nwere done twice before).\nAdd it back to InvalidateBlock, which is only reachable via rpc.\n\nAlso use a log message specific to invalidateblock there, instead of the\none for invalid block discovery."
  },
  {
   "sha": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "date": "2026-08-19T15:09:09Z",
   "message": "validation: rename InvalidChainFound to UpdateBestInvalid\n\nIt's a more descriptive name after most of the logic was moved\ninto InvalidBlockFound."
  }
 ],
 "timeline": [
  {
   "t": "2026-01-12T21:48:39Z",
   "kind": "comment",
   "who": "bensig",
   "assoc": "CONTRIBUTOR",
   "text": "ACK 73e0bab7adb0b16b2ac4d13c9d601a04b7184749"
  },
  {
   "t": "2026-01-20T20:49:32Z",
   "kind": "review_comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "73e0bab7adb0b16b2ac4d13c9d601a04b7184749",
   "in_reply_to": null,
   "text": "nit: both `LogInfo` calls could be formatted so the lines here have spaces?"
  },
  {
   "t": "2026-01-20T21:01:10Z",
   "kind": "review",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "73e0bab7adb0b16b2ac4d13c9d601a04b7184749",
   "text": "This looks good, going to think about it more. I think the noticeable changes here are that:\n- ~~`InvalidateBlock` won't have warnings anymore since `CheckForkWarningConditions` is no longer called (this seems fine, the warnings mention falling out of consensus which is irrelevant here imo)~~\n- `ActivateBestChainStep` does not duplicate logging / work (also fine)"
  },
  {
   "t": "2026-01-20T21:16:26Z",
   "kind": "review_comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": null,
   "text": "I think this can fail to set `m_best_invalid` properly since `to_mark_failed` walks backwards (if `pindex` is in the main chain) and then `UpdateBestInvalid` is called after looping?"
  },
  {
   "t": "2026-01-23T11:34:31Z",
   "kind": "review_comment",
   "who": "frankomosh",
   "assoc": "CONTRIBUTOR",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": null,
   "text": "I think this update is already handled by `InvalidBlockFound()`, called from within `ConnectTip()`, so this second call is maybe unnecessary?"
  },
  {
   "t": "2026-01-23T11:42:36Z",
   "kind": "review",
   "who": "frankomosh",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "73e0bab7adb0b16b2ac4d13c9d601a04b7184749",
   "text": "Concept ACK on the overall refactoring approach."
  },
  {
   "t": "2026-01-23T12:46:34Z",
   "kind": "review_comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2720846535,
   "text": "It's slightly different, this second call might update `m_best_invalid` to the last descendant in the chain. `vpindexToConnect` is iterated in reverse order, and this call is only a no-op if the last descendant is the one that failed in `ConnectTip`."
  },
  {
   "t": "2026-01-24T06:54:10Z",
   "kind": "review_comment",
   "who": "frankomosh",
   "assoc": "CONTRIBUTOR",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2720846535,
   "text": "Ah, I see and understand it now. Thanks for clarifying"
  },
  {
   "t": "2026-02-01T06:11:09Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2710102417,
   "text": "good observation, I think that's correct - it should be a preexisting issue though that is not changed by this PR.\nIt should be possible to fix this by calling UpdateBestInvalid also earlier in the process (which we could do more easily after this PR, because unlike old `InvalidChainFound`, we no longer iterate over the block index).\n\nOn the other hand, `m_best_invalid` is only used for logging of out-of-consensus warnings, and I'm not sure if these logged warnings are really helpful if the consensus failure is self-inflicted through RPC. What do you think?"
  },
  {
   "t": "2026-02-01T06:23:53Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "f789d4fa160ee53145555c6d068b7dd3a23e88be"
  },
  {
   "t": "2026-02-01T06:24:33Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "73e0bab7adb0b16b2ac4d13c9d601a04b7184749",
   "in_reply_to": 2710023324,
   "text": "done - applied clang-format to the commit."
  },
  {
   "t": "2026-02-01T06:25:01Z",
   "kind": "comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "text": "[73e0bab](https://github.com/bitcoin/bitcoin/commit/73e0bab7adb0b16b2ac4d13c9d601a04b7184749) to [f789d4f](https://github.com/bitcoin/bitcoin/commit/f789d4fa160ee53145555c6d068b7dd3a23e88be): applied clang-format"
  },
  {
   "t": "2026-02-02T20:31:31Z",
   "kind": "review_comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2710102417,
   "text": "Brain-dump of what I think happens (haven't written a test) with InvalidateBlock calling InvalidChainFound in master:\n\nInvalidChainFound will:\n- update m_best_invalid\n- call SetBlockFailureFlags which updates children to set BLOCK_FAILED_CHILD\n- call RecalculateBestHeader\n- call CheckForkWarningConditions\n\nto_mark_failed can either be in-chain or out-of-chain. If it's in-chain, then it will be set to the\nlast disconnected block. If it's out-of-chain, it will be unchanged.\n\nIf to_mark_failed was in-chain:\n- m_best_invalid might be changed to to_mark_failed. Since to_mark_failed was changed, m_best_invalid might not change when it should (what I mentioned above in the previous comment).\n- SetBlockFailureFlags: Unnecessary because all of the children have been marked\n  BLOCK_FAILED_CHILD in the loop.\n- RecalculateBestHeader: Unnecessary. If the best header was in-chain, then the\n  loop already updated it. If the best header is out-of-chain, it was not invalidated.\n- CheckForkWarningConditions: Necessary because the tip definitely changed and\n  m_best_invalid may or may not have changed. Either way, the fork condition may be hit.\n\nElse (to_mark_failed is out-of-chain):\n- m_best_invalid might be changed to to_mark_failed. Even here, there may be children that could be\n  marked as m_best_invalid instead of to_mark_failed, depending on what block the user calls InvalidateBlock on.\n- SetBlockFailureFlags: Necessary because if there are children, they need to be marked BLOCK_FAILED_CHILD.\n- RecalculateBestHeader: Necessary because the loop above did not run.\n- CheckForkWarningConditions: Necessary because m_best_invalid may have changed.\n\nThis PR changes things by only updating m_best_invalid and calling CheckForkWarningConditions. This is\nfine for the in-chain case, but for the case where to_mark_failed is out-of-chain, I think it's missing\nSetBlockFailureFlags & RecalculateBestHeader?\n\n[quoted text omitted]\nFor the in-chain case of setting m_best_invalid improperly, we could call UpdateBestInvalid where it is and change the argument such that it's either the old tip or to_mark_failed, depending on pindex_was_in_chain = true?\n\n[quoted text omitted]\nIf the logged warnings aren't helpful, should they be removed? I don't have a strong opinion here, I did find it a little confusing that m_best_invalid was being updated improperly in master, maybe a comment could suffice that it's not being set to the actual best invalid? I didn't mean to imply that this PR introduced that either btw."
  },
  {
   "t": "2026-02-12T11:58:06Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2710102417,
   "text": "[quoted text omitted]\nfine for the in-chain case, but for the case where to_mark_failed is out-of-chain, I think it's missing\nSetBlockFailureFlags & RecalculateBestHeader?\n\nGood catch! I think it should be possible to add these back to the `!pindex_was_in_chain ` conditional, which already has a bunch of extra logic for this case. I will do that soon, but I also want to add a scenario for this invalidateblock on out-of-chain blocks scenario to `rpc_invalidateblock`, because since https://github.com/bitcoin/bitcoin/commit/ed764ea2b4edb3cf1925a4bff5f39567a8be54ac,  `CheckBlockIndex` has a check to fail in these situations.\n\nWill update the PR in a couple of day, and also think about the logging a bit more."
  },
  {
   "t": "2026-03-24T10:10:06Z",
   "kind": "review",
   "who": "stratospher",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "f789d4fa160ee53145555c6d068b7dd3a23e88be",
   "text": "Concept ACK. nice cleanup!\n\n[quoted text omitted]\nwondering if it\u2019s also possible to avoid looping over the block index twice and instead combine this into a single pass?\n\n1. 1st iteration through block index to set\u00a0`BLOCK_FAILED_VALID`\n2. 2nd iteration through block index to update\u00a0`m_best_header`\n\ni guess doing it this way would make `m_best_header` temporarily inconsistent (for this chain: 1 \u21902 \u21903 \u2190 4 \u2190 5, intermediate states during the loop could have 3 still marked as invalid and 5 as the `m_best_header`) but after the loop finishes it should be consistent.\n\nthinking of something like:\n```\nvoid Chainstate::SetBlockFailureFlagsAndBestHeader(CBlockIndex* invalid_block, bool recalculate_best_header)\n{\n    AssertLockHeld(cs_main);\n\n    if (recalculate_best_header) {\n        m_chainman.m_best_header = m_chain.Tip();\n    }\n    for (auto& [_, block_index] : m_blockman.m_block_index) {\n        if (invalid_block != &block_index && block_index.GetAncestor(invalid_block->nHeight) == invalid_block) {\n            block_index.nStatus |= BLOCK_FAILED_VALID;\n            m_blockman.m_dirty_blockindex.insert(&block_index);\n        }\n        if (recalculate_best_header && !(block_index.nStatus & BLOCK_FAILED_VALID) && m_chainman.m_best_header->nChainWork < block_index.nChainWork) {\n            m_chainman.m_best_header = &block_index;\n        }\n    }\n}\n```\n\nalso since`RecalculateBestHeader` originally existed for  reconsiderblock RPC before https://github.com/bitcoin/bitcoin/pull/30666, we should update the comment in validation.h that this is no longer the case!\n\nEDIT: In case anyone else also found the block invalidity handling a bit tricky to follow - different operations (marking BLOCK_FAILED_VALID, sometimes updating `m_best_header`, sometimes `m_best_invalid`, and sometimes removing from `setBlockIndexCandidates`) being performed from different places across the code, I have a [summary sheet here](https://gist.github.com/stratospher/6e4a1ef0a7ceec3319533e36fdf09e5e) - hopefully it 's not too hard to follow and makes things easier to reason about."
  },
  {
   "t": "2026-05-18T07:06:36Z",
   "kind": "review_comment",
   "who": "frankomosh",
   "assoc": "CONTRIBUTOR",
   "path": "src/validation.cpp",
   "commit": "f789d4fa160ee53145555c6d068b7dd3a23e88be",
   "in_reply_to": null,
   "text": "Ran both `master` and this branch on regtest with `-debug=validation` to confirm what changed in the `invalidateblock` logging.\n\n`master`:\n\n```text\nInvalidChainFound: invalid block=4579db92... height=5 log2_work=3.584963 date=2026-05-18T06:30:51Z\nInvalidChainFound:  current best=763f0c07... height=4 log2_work=3.321928 date=2026-05-18T06:30:51Z\n```\n\n`This branch`:\n\n```text\nInvalidated block: hash=5fe0ec84... height=5\n```\n\n`log2_work`, `date`, and the `current best` line are gone. Also, the prefix changes from `InvalidChainFound:` to `Invalidated block:`, which to my thinking, might silently break any log monitoring on the old string.\n\nNot sure if this has been raised elsewhere, but just posting it in case it\u2019s useful context."
  },
  {
   "t": "2026-08-10T19:33:58Z",
   "kind": "comment",
   "who": "sedited",
   "assoc": "MEMBER",
   "text": "Ping @mzumsande for a rebase and some unaddressed review."
  },
  {
   "t": "2026-08-11T16:06:07Z",
   "kind": "comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "text": "Thanks for the reminder, I'll update  soon!"
  },
  {
   "t": "2026-08-19T15:35:40Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5"
  },
  {
   "t": "2026-08-19T15:39:16Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "f789d4fa160ee53145555c6d068b7dd3a23e88be",
   "in_reply_to": 3256954484,
   "text": "thanks - I mentioned the log change in the commit message. I think it's better to have a separate logging for a rpc, so that the two situations can be distinguished. Since logs are not a guaranteed stable interface, I don't think log monitoring is an issue - especially here, because this is only ever triggered by a user."
  },
  {
   "t": "2026-08-19T15:42:22Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2710102417,
   "text": "I updated this now and added the missing calls back (plus added a test in the first commit, that would have failed on my earlier branch). I think it's safer to not make these conditional on `!pindex_was_in_chain` since during the earlier chain disconnecting we release `cs_main`, and if any blocks would have been added by another thread, these could have been missed otherwise."
  },
  {
   "t": "2026-08-19T15:56:27Z",
   "kind": "comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "text": "[f789d4f](https://github.com/bitcoin/bitcoin/commit/f789d4fa160ee53145555c6d068b7dd3a23e88be) to [ff06c05](https://github.com/bitcoin/bitcoin/commit/ff06c05043bf2324085dd0a1969239a9fd19eda5):\nrebased and addressed comments - including adding a test for the case I missed in the earlier version. Sorry it took so long, I'll be much faster from now on.\n\n[quoted text omitted]\nI didn't do that here - I feel like it's not really necessary to optimize too much, since this can all only be triggered by creating an invalid block with valid PoW which is very costly (or using a hidden rpc not meant to be used regularly), so the few milliseconds we'd save likely won't matter much."
  },
  {
   "t": "2026-08-27T19:21:55Z",
   "kind": "review_comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "path": "src/validation.cpp",
   "commit": "ff06c05043bf2324085dd0a1969239a9fd19eda5",
   "in_reply_to": 2710102417,
   "text": "[quoted text omitted]\n\nGood point. I mistakenly thought locking `m_chainstate_mutex` prevented another block from being added, but that's not right. I think examining the guarantees this function has while another thread simultaneously adds blocks to the index is probably worthwhile..."
  },
  {
   "t": "2026-08-27T19:43:45Z",
   "kind": "comment",
   "who": "Crypt-iQ",
   "assoc": "MEMBER",
   "text": "crACK ff06c05043bf2324085dd0a1969239a9fd19eda5\n\nI still am confused by how `InvalidateBlock` behaves in multi-threaded scenarios. I also think `m_best_invalid` could be updated properly in a follow-up PR. This PR by itself is a good improvement to `ActivateBestChain`."
  }
 ],
 "labels_log": [
  {
   "t": "2026-01-11T16:59:23Z",
   "action": "labeled",
   "label": "Validation",
   "who": "DrahtBot"
  },
  {
   "t": "2026-02-18T17:06:13Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-19T17:02:50Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 12631,
 "text_tokens_estimate": 3157,
 "changed_paths": [],
 "files": [],
 "test_lines": null,
 "git": null,
 "input_hash": "cc2c7422cd3687d2",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}