RFC: Separate out runtime errors from BlockValidationState using `util::Expected` #35646

pull yuvicc wants to merge 16 commits into bitcoin:master from yuvicc:2026-06-remove_enum_ie changing 40 files +575 −369
  1. yuvicc commented at 4:51 AM on July 3, 2026: contributor

    BlockValidationState currently carries two unrelated kinds of failure: consensus/policy invalidity which is the actual purpose and runtime errors via a M_ERROR mode in ModeState enum inside ValidationState class. This PR removes M_ERROR and routes runtime errors through util::Expected<T, std::string> instead, making the two failure modes distinct.

    Motivation

    BlockValidationState exists to describe why a block or transaction is invalid. It holds a consensus/policy reject reason. A disk write or system runtime error is not that, yet it was folded into the same object as a third state i.e. M_ERROR.

    This conflation has two costs:

    • Wrong abstraction for non-validating functions. FlushStateToDisk, DisconnectTip, ActivateBestChain, PreciousBlock, and InvalidateBlock do no block validation, but each takes a BlockValidationState out-param to report any runtime error.

    • Three outcomes in one object. Functions that can both validate and hit a runtime error (ConnectBlock, AcceptBlock, ProcessNewBlock) holds valid, invalid, and fatal into a single BlockValidationState, forcing every caller to unwrap.

    As noted in the original discussion here:

    Functions like FlushStateToDisk, ActivateBestChain aren't exactly about validating a certain block, yet they take a BlockValidationState& out-param just to absorb runtime errors, which feels like the wrong abstraction.

    And also discussion here to remove M_ERROR value.

    • This would also pave a long term fix for #35570, which returns BlockValidationState from validation methods instead of boolean value.

    util::Expected is a good option for this reason, it keeps the error handling in the same return value style (system/runtime errors and ValidationState for consensus correctness) and separates-out runtime errors from ValidationState.

    Runtime errors could be signaled either by a return value or by throwing. This PR keeps them as return values as it's a minimal change. M_ERROR was already a return-value mechanism included in ValidationState with consensus verdict. This PR doesn't open the exceptions vs returns question, it keeps the existing return-value style and just moves the fatal error into a proper Expected channel.

  2. DrahtBot commented at 4:51 AM on July 3, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35646.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept NACK purpleKarrot
    Concept ACK optout21, hodlinator, arejula27

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36326 (kernel: Use typed errors for fatal and flush error notifications by arejula27)
    • #36318 (validation: Ensure Invalid ValidationState has result by optout21)
    • #36066 (validation: Separate check-only version of ConnectBlock by optout21)
    • #36000 (validation: prefetch blocks while connecting by l0rinc)
    • #35751 (validation: use parallel input prevout fetching in TestBlockValidity by andrewtoth)
    • #35570 (refactor: Change some validation.cpp methods to return BlockValidationState by optout21)
    • #35524 (validation: Avoid rewriting the genesis block during index recovery by winterrdog)
    • #35502 (refactor: extract per-message helpers from ProcessMessage (move-only) by w0xlt)
    • #35307 (blockstorage: keep snapshot base in normal blockfile range by shuv-amp)
    • #34729 (Reduce log noise by ajtowns)
    • #34254 (validation: Prevent duplicate logging and looping in invalid block handling by mzumsande)
    • #33922 (mining: add getMemoryLoad() and track template non-mempool memory footprint by Sjors)
    • #32554 (bench: replace embedded raw block with configurable block generator by l0rinc)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  3. DrahtBot added the label Needs rebase on Jul 6, 2026
  4. yuvicc force-pushed on Jul 6, 2026
  5. yuvicc commented at 4:28 PM on July 6, 2026: contributor

    Rebased on master to resolve conflicts with #35621. Adopted its "ignore the flush error" behavior in the new util::Expected API by dropping the flush-error propagation from AcceptBlock.

  6. DrahtBot removed the label Needs rebase on Jul 6, 2026
  7. maflcko commented at 5:13 PM on July 7, 2026: member

    Hmm, it could make sense to be more type-safe here, but looking at the code, there are places that flatten this back down to a boolean, so I wonder what the overall benefit is?

    I think it could make sense to think whether any places that flatten this down again to a boolean need a different handling? If yes, fixing that handling should probably be done early in a pull request changing the behavior.

    Moreover, I presume all of the runtime-errors are fatal, so I presume they must all call the fatal error function. Maybe this can be enforced at compile-time, so that all those fatal errors ensure that the fatal error function is called exactly once?

  8. in src/validation.cpp:2308 in 7d0ed51ec4
    2304 | @@ -2307,6 +2305,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,
    2305 |      uint256 block_hash{block.GetHash()};
    2306 |      assert(*pindex->phashBlock == block_hash);
    2307 |  
    2308 | +    BlockValidationState state;
    


    optout21 commented at 3:36 AM on July 8, 2026:

    7d0ed51 validation: route fatal errors through util::Expected:

    Could get rid of this local variable if CheckBlock signature is also changed (to return state).


    yuvicc commented at 6:55 AM on July 8, 2026:

    I think this could be done in a follow-up to reduce the review burden here? #35570

  9. in src/validation.cpp:4193 in 7d0ed51ec4
    4186 | @@ -4193,9 +4187,10 @@ static bool ContextualCheckBlock(const CBlock& block, BlockValidationState& stat
    4187 |      return true;
    4188 |  }
    4189 |  
    4190 | -bool ChainstateManager::AcceptBlockHeader(const CBlockHeader& block, BlockValidationState& state, CBlockIndex** ppindex, bool min_pow_checked)
    4191 | +BlockValidationState ChainstateManager::AcceptBlockHeader(const CBlockHeader& block, CBlockIndex** ppindex, bool min_pow_checked)
    4192 |  {
    4193 |      AssertLockHeld(cs_main);
    4194 | +    BlockValidationState state;
    


    optout21 commented at 3:40 AM on July 8, 2026:

    7d0ed51 validation: route fatal errors through util::Expected:

    The scope of this local var. could be reduced, or maybe omitted completely, if CheckBlockHeader signature is also changed.


    yuvicc commented at 6:56 AM on July 8, 2026:

    same as above #35646 (review)

  10. in src/validation.cpp:4253 in 7d0ed51ec4
    4257 |  // Exposed wrapper for AcceptBlockHeader
    4258 | -bool ChainstateManager::ProcessNewBlockHeaders(std::span<const CBlockHeader> headers, bool min_pow_checked, BlockValidationState& state, const CBlockIndex** ppindex)
    4259 | +BlockValidationState ChainstateManager::ProcessNewBlockHeaders(std::span<const CBlockHeader> headers, bool min_pow_checked, const CBlockIndex** ppindex)
    4260 |  {
    4261 |      AssertLockNotHeld(cs_main);
    4262 | +    BlockValidationState state;
    


    optout21 commented at 3:41 AM on July 8, 2026:

    7d0ed51 validation: route fatal errors through util::Expected:

    The scope of this local variable could be reduced.


    yuvicc commented at 6:56 AM on July 8, 2026:

    same as above #35646 (review)

  11. optout21 commented at 3:46 AM on July 8, 2026: contributor

    Concept ACK

    The advantages of this change are:

    • enforcement of returned errors -- some methods can return only runtime errors;
    • reduced risk of accidental mix-up of validation and runtime errors.

    At first it looked a bit strange that some errors (runtime errors) are treated as exceptional cases, while validation errors are treated as return values, as previously both were treated the same. However, this makes sense; validation status can be regarded as the non-exceptional output of the checker algotihms, while runtime errors are proper exceptional errors.

    Some minor observations:

    • Maybe the big change could be broken up (e.g. in two, first the void return value changes, then the BlockValidationState changes)
    • CheckBlock, CheckBlockHeader could be also be changed to the return-state pattern
    • [[nodiscard]] could be added touched signatures, to reduce risk of ignored errors.
    • In some places where a local state variable is used, its scope could be reduced, or omitted entirely. Preferably error from a call should be handled right away, there is no need for method-wide state/error variable (left comments in a few places).
  12. yuvicc commented at 6:00 AM on July 8, 2026: contributor

    Hmm, it could make sense to be more type-safe here, but looking at the code, there are places that flatten this back down to a boolean, so I wonder what the overall benefit is?

    You're right that the top-level callers collapses the result back to yes/no, wouldn't that be the right place for it to collapse? e.g. ConnectBlock -> ConnectTip -> ActivateBestChainStep -> ActivateBestChain, where M_ERROR used to be inside ValidationState next to consensus verdict and every caller had to unwrap to distinguish b/w consensus failure v/s fatal error.

    I think it could make sense to think whether any places that flatten this down again to a boolean need a different handling?

    I've checked, every fatal error already fires at the origin(FatalError()), so by the time it reaches the top-level caller, the node might be shutting down regardless of what the caller does with the string, none of the callers branch on fatal-vs-non-fatal; they just surface the message. If other reviewers also feels the same that the caller needs to programmatically tell the two apart, we could have a distinct type for fatal and non-fatal type.

    Moreover, I presume all of the runtime-errors are fatal, so I presume they must all call the fatal error function. Maybe this can be enforced at compile-time, so that all those fatal errors ensure that the fatal error function is called exactly once?

    This is mostly true, except for invalidateblock/reconsiderblock rpc whose error returns are non-fatal and fire nothing. I think we can get a compile-time guarantee for the functions where every error is fatal, by constructing FatalError like below?

    <details>

    class FatalError
    {
    public:
        //! Fire the fatalError notification.
        [[nodiscard]] static util::Unexpected<FatalError> Raise(
            kernel::Notifications& notifications, const bilingual_str& message);
    
        //! Untranslated message, for logging or surfacing through an RPC error.
        const std::string& message() const LIFETIMEBOUND { return m_message; }
    
    private:
        explicit FatalError(std::string message) : m_message{std::move(message)} {}
        std::string m_message;
    };
    
    util::Unexpected<FatalError> FatalError::Raise(Notifications& notifications, const bilingual_str& message)
    {
        notifications.fatalError(message);                 // fire (→ AbortNode → shutdown)
        return util::Unexpected{FatalError{message.original}};  // then mint the value
    }
    

    </details>

  13. maflcko commented at 7:36 AM on July 8, 2026: member

    Hmm, it could make sense to be more type-safe here, but looking at the code, there are places that flatten this back down to a boolean, so I wonder what the overall benefit is?

    You're right that the top-level callers collapses the result back to yes/no, wouldn't that be the right place for it to collapse?

    Yeah, I am mostly wondering aloud. Because currently, your patch will map both to RPC_INTERNAL_ERROR in GenerateBlock. However, in generateblock, they are mapped to RPC_INTERNAL_ERROR or RPC_VERIFY_ERROR. And in submitblock, they are mapped to a new imaginary/undocumented? "validation-error" string?

    That doesn't seem consistent or worthwhile. I'd say:

    • Either we can distinguish the two cases clearly, in which case a separate type and handling makes sense (and they shouldn't be flattened down).
    • Or, we can not distinguish them, in which case splitting them up and then flattening them down again seems pointless?
  14. willcl-ark added the label Brainstorming on Jul 8, 2026
  15. willcl-ark added the label Validation on Jul 8, 2026
  16. DrahtBot added the label Needs rebase on Jul 9, 2026
  17. yuvicc force-pushed on Jul 15, 2026
  18. DrahtBot removed the label Needs rebase on Jul 15, 2026
  19. yuvicc commented at 5:13 AM on July 16, 2026: contributor

    That doesn't seem consistent or worthwhile. I'd say:

    Either we can distinguish the two cases clearly, in which case a separate type and handling makes sense (and they shouldn't be flattened down).

    Or, we can not distinguish them, in which case splitting them up and then flattening them down again seems pointless?

    Thanks, I agree. I went with the first option. Fatal/system failures are now represented separately as kernel::FatalError and propagated via util::Expected, while BlockValidationState now only represents valid vs. invalid consensus validation.

    Also, the existing RPC behavior is preserved.

  20. DrahtBot added the label Needs rebase on Jul 23, 2026
  21. yuvicc force-pushed on Jul 27, 2026
  22. yuvicc commented at 6:38 AM on July 27, 2026: contributor

    Rebased to master.

  23. DrahtBot removed the label Needs rebase on Jul 27, 2026
  24. DrahtBot added the label Needs rebase on Aug 14, 2026
  25. yuvicc force-pushed on Aug 27, 2026
  26. yuvicc commented at 7:15 AM on August 27, 2026: contributor

    Rebased on master

  27. DrahtBot removed the label Needs rebase on Aug 27, 2026
  28. yuvicc marked this as ready for review on Sep 8, 2026
  29. yuvicc commented at 8:13 AM on September 8, 2026: contributor

    Ready for review.

  30. in src/validation.cpp:3532 in 9f3ae31ec4
    3528 | @@ -3534,10 +3529,10 @@ bool Chainstate::PreciousBlock(BlockValidationState& state, CBlockIndex* pindex)
    3529 |          }
    3530 |      }
    3531 |  
    3532 | -    return ActivateBestChain(state, std::shared_ptr<const CBlock>());
    3533 | +    return ActivateBestChain();
    


    optout21 commented at 12:16 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    It's unclear why the 'empty' CBlock parameter was removed (it's also unclear why it was there in the first place).


    yuvicc commented at 4:47 AM on September 10, 2026:

    Hmm, this means no block data is supplied and read from disk. So a null shared ptr not empty CBlock. Will add a comment mentioning this.

  31. in src/validation.cpp:4269 in 9f3ae31ec4
    4265 | @@ -4267,10 +4266,7 @@ bool ChainstateManager::ProcessNewBlockHeaders(std::span<const CBlockHeader> hea
    4266 |              CBlockIndex *pindex = nullptr; // Use a temp pindex instead of ppindex to avoid a const_cast
    4267 |              bool accepted{AcceptBlockHeader(header, state, &pindex, min_pow_checked)};
    4268 |              CheckBlockIndex();
    4269 | -
    4270 | -            if (!accepted) {
    4271 | -                return false;
    4272 | -            }
    4273 | +            if (!accepted) return false;
    


    optout21 commented at 12:19 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    Nit: no need to touch this, but can stay.

  32. in src/validation.cpp:4390 in 9f3ae31ec4 outdated
    4385 | @@ -4389,15 +4386,15 @@ bool ChainstateManager::AcceptBlock(const std::shared_ptr<const CBlock>& pblock,
    4386 |              blockPos = *dbp;
    4387 |              m_blockman.UpdateBlockInfo(block, pindex->nHeight, blockPos);
    4388 |          } else {
    4389 | -            blockPos = m_blockman.WriteBlock(block, pindex->nHeight);
    4390 | -            if (blockPos.IsNull()) {
    4391 | -                state.Error(strprintf("%s: Failed to find position to write new block to disk", __func__));
    4392 | -                return false;
    4393 | +            auto res{m_blockman.WriteBlock(block, pindex->nHeight)};
    4394 | +            if (!res) {
    


    optout21 commented at 12:21 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    It seems to me, that the (blockPos.IsNull()) branch with the specific error return should be kept.

  33. in src/validation.cpp:4559 in 9f3ae31ec4 outdated
    4563 | -
    4564 | -    // Ensure no check returned successfully while also setting an invalid state.
    4565 | -    if (!state.IsValid()) NONFATAL_UNREACHABLE();
    4566 | -
    4567 | -    return state;
    4568 | +    return chainstate.ConnectBlock(block, &index_dummy, view_dummy, /*fJustCheck=*/true);
    


    optout21 commented at 12:24 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    Why not keeping the NONFATAL_UNREACHABLE checks?

  34. in src/node/miner.cpp:419 in 9f3ae31ec4
     416 |      CHECK_NONFATAL(chainman.m_options.signals)->UnregisterSharedValidationInterface(sc);
     417 |  
     418 | -    if (!new_block && accepted) {
     419 | +    if (!res) {
     420 | +        reason = res.error().message();
     421 | +    } else if (!new_block && accepted) {
    


    optout21 commented at 2:53 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    It's not clear to me that this does not introduce new error reasons. I have the impression that the Error case previously resulted in "inconclusive" (but I'm not sure). If that's the case, this needs to be changed.


    optout21 commented at 2:26 PM on September 22, 2026:

    I think this is still a valid concern (in 22af5819cd2d29df7a937909d6dd11f1f8abe2b2). The reason can end up here e.g. with "Failed to write undo data." or "Failed to read block.". I think previously it could only assume "inconclusive". Could you unresolve this?

  35. in src/rpc/blockchain.cpp:1734 in 9f3ae31ec4 outdated
    1736 | -
    1737 | -    if (!state.IsValid()) {
    1738 | -        throw JSONRPCError(RPC_DATABASE_ERROR, state.ToString());
    1739 | +    if (auto res{chainman.ActiveChainstate().ActivateBestChain()}; !res) {
    1740 | +        throw JSONRPCError(RPC_DATABASE_ERROR, res.error().message());
    1741 |      }
    


    optout21 commented at 2:55 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    Note: pre-change error from ActivateBestChain was ignored.

  36. in src/rpc/mining.cpp:624 in 9f3ae31ec4 outdated
     627 | @@ -620,8 +628,6 @@ static UniValue BIP22ValidationResult(const BlockValidationState& state)
     628 |      if (state.IsValid())
     629 |          return UniValue::VNULL;
     630 |  
     631 | -    if (state.IsError())
     632 | -        throw JSONRPCError(RPC_VERIFY_ERROR, state.ToString());
    


    optout21 commented at 2:58 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    Technically, in this commit BlockValidationState can still be Error, so this error check should be kept, removed in the later commit.

  37. in src/rpc/mining.cpp:1185 in 9f3ae31ec4 outdated
    1181 | @@ -1167,11 +1182,7 @@ static RPCMethod submitheader()
    1182 |      }
    1183 |  
    1184 |      BlockValidationState state;
    1185 | -    chainman.ProcessNewBlockHeaders({{h}}, /*min_pow_checked=*/true, state);
    1186 | -    if (state.IsValid()) return UniValue::VNULL;
    1187 | -    if (state.IsError()) {
    1188 | -        throw JSONRPCError(RPC_VERIFY_ERROR, state.ToString());
    1189 | -    }
    1190 | +    if (chainman.ProcessNewBlockHeaders({{h}}, /*min_pow_checked=*/true, state)) return UniValue::VNULL;
    


    optout21 commented at 3:06 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    It's not clear to me that the checks for the returned state can be omitted.


    hodlinator commented at 3:15 PM on September 16, 2026:

    Agree, would keep it as:

        chainman.ProcessNewBlockHeaders({{h}}, /*min_pow_checked=*/true, state);
        if (state.IsValid()) return UniValue::VNULL;
    
  38. in src/validation.cpp:3047 in 9f3ae31ec4
    3041 | @@ -3052,15 +3042,16 @@ bool Chainstate::ConnectTip(
    3042 |      {
    3043 |          CoinsViewOverlay& view{*m_coins_views->m_connect_block_view};
    3044 |          const auto reset_guard{view.StartFetching(*block_to_connect)};
    3045 | -        bool rv = ConnectBlock(*block_to_connect, state, pindexNew, view);
    3046 | +        auto res{ConnectBlock(*block_to_connect, pindexNew, view)};
    3047 | +        if (!res) return util::Unexpected(std::move(res).error());
    3048 | +        state = std::move(*res);
    


    optout21 commented at 3:14 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    I think there is a behavior change here in case of Error: previously BlockChecked was called, and now it isn't.


    hodlinator commented at 9:33 AM on September 16, 2026:

    thread #35646 (review):

    Noticed this too.

    In light of this I don't understand how you could A-C-K this? I guess you mean Concept A-C-K of this as an RFC. Seems potentially dangerous but I'm not super-familiar with the signals.

    Edit 2026-09-22: My view of this changed somewhat during the review (https://github.com/bitcoin/bitcoin/pull/35646#discussion_r4034759979, #35646 (review)). Deferring to the notification system to report fatal errors seems promising.


    optout21 commented at 2:33 PM on September 22, 2026:

    I think this still applies! (in commit 22af5819cd2d29df7a937909d6dd11f1f8abe2b2) Could you unresolve?


    hodlinator commented at 7:02 PM on September 22, 2026:

    Agree we need to be careful around this but I think the current approach has promise. Should call out that BlockChecked is only called for Valid/Invalid blocks now in the PR description.

  39. in src/validation.cpp:4444 in 9f3ae31ec4 outdated
    4442 |              // Store to disk
    4443 | -            ret = AcceptBlock(block, state, &pindex, force_processing, nullptr, new_block, min_pow_checked);
    4444 | +            auto res{AcceptBlock(block, &pindex, force_processing, nullptr, new_block, min_pow_checked)};
    4445 | +            if (!res) return util::Unexpected(std::move(res).error());
    4446 | +            state = std::move(*res);
    4447 | +            accepted = state.IsValid();
    


    optout21 commented at 3:16 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    I think there is a behavior change here: in case of error from AcceptBlock previously BlockChecked was called before return, now it's not.


    hodlinator commented at 4:42 PM on September 16, 2026:

    Good catch! Missed this one. Although we have hit a fatal error, so not signalling BlockChecked might be okay. Then again some subscriber of BlockChecked might be checking state for errors... but that would show up in this PR when we remove error support from state.

  40. in src/node/interfaces.cpp:1024 in 9f3ae31ec4
    1023 | -        reason = state.GetRejectReason();
    1024 | -        debug = state.GetDebugMessage();
    1025 | -        return state.IsValid();
    1026 | +        auto res{TestBlockValidity(chainman().ActiveChainstate(), block, /*check_pow=*/options.check_pow, /*check_merkle_root=*/options.check_merkle_root)};
    1027 | +        if (!res) {
    1028 | +            // fatal error occured
    


    optout21 commented at 3:20 PM on September 8, 2026:

    9f3ae31 validation: return fatal errors with util::Expected:

    Nit: typo: occured -> occurred

  41. in src/kernel/bitcoinkernel.h:398 in a75d25d9bc outdated
     394 | + * Whether a validated data structure is valid or invalid.
     395 |   */
     396 |  typedef uint8_t btck_ValidationMode;
     397 |  #define btck_ValidationMode_VALID ((btck_ValidationMode)(0))
     398 |  #define btck_ValidationMode_INVALID ((btck_ValidationMode)(1))
     399 | -#define btck_ValidationMode_INTERNAL_ERROR ((btck_ValidationMode)(2))
    


    optout21 commented at 3:22 PM on September 8, 2026:

    a75d25d kernel: remove INTERNAL_ERROR validation mode:

    Isn't this an API change technically? If so, consider a release note for it.


    yuvicc commented at 4:25 AM on September 10, 2026:

    bitcoinkernel hasn't released yet.

  42. optout21 commented at 3:29 PM on September 8, 2026: contributor

    Concept ACK 4b183f4e077eb1e7d48cf5d53036c8591abf9478

    EDIT (Sept 17): Edited the above line, inserted a space between concept and a, to show as intended.

    Reviewed again, generally looks good. I've left some comments; most are rather minor, but there are some that should be checked. Therefore not upgrading my Concept A to a proper acknowledgement as of now. This PR is related to #35570 (of mine), and my assessment is that both PR's stand on their own, but, despite an overlap, they also complement each other.

    I uphold my comment from last review:

    • I would be happy to see the second large commit broken up (possible ways: first the void return value changes, then the BlockValidationState changes; first internal validation method changes, then external ones; etc.)
  43. in src/kernel/fatal_error.h:32 in 4b183f4e07 outdated
      27 | +    //! Fire the fatalError notification and return the error for propagation
      28 | +    //! through util::Expected.
      29 | +    [[nodiscard]] static util::Unexpected<FatalError> Raise(Notifications& notifications, const bilingual_str& message)
      30 | +    {
      31 | +        notifications.fatalError(message);
      32 | +        return util::Unexpected{FatalError{message.original}};
    


    hodlinator commented at 1:27 PM on September 15, 2026:

    nit: Could take as sink-argument and move-construct to avoid some copying/heap activity.

        [[nodiscard]] static util::Unexpected<FatalError> Raise(Notifications& notifications, bilingual_str message)
        {
            notifications.fatalError(message);
            return util::Unexpected{FatalError{std::move(message.original)}};
    
  44. in src/kernel/fatal_error.h:1 in 4b183f4e07 outdated


    hodlinator commented at 1:30 PM on September 15, 2026:

    Could put the contents of fatal_error.h inside notifications_interface.h since it's fairly tightly coupled with it?


    hodlinator commented at 8:25 AM on September 16, 2026:

    These renames from dummy to state seem net-negative.


    hodlinator commented at 4:50 PM on September 16, 2026:

    nit: Technically this would be more correct:

                LogError("%s: CheckBlock or AcceptBlock FAILED (%s)", __func__, state.ToString());
    

    optout21 commented at 9:22 AM on September 22, 2026:

    0136c27 refactor: Make DisconnectTip() use FatalError type:

    I think setting of state to *res in case of Invalid (res && res.IsInvalid()) is missing here, state is being used without being affected by the DisconnectTip call. (I realize this finding becomes irrelevant in a later commit.)


    optout21 commented at 9:48 AM on September 22, 2026:

    c11ac78 refactor: Make ActivateBestChain() use FatalError type:

    This is the only place where ActivateBestChain returns false. Maybe this could be turned into a FatalError (in a new commit), as it is Assume'd already. Then the return value could be changed to void from bool, and that would simplify call sites (there are duplicate branches for false and error).


    hodlinator commented at 11:31 AM on September 22, 2026:

    re #35646 (review):

    Yes, returning FatalError rather than false here seems like it would decrease incidental complexity.


    yuvicc commented at 6:05 AM on September 23, 2026:

    Hmm, ProcessNewBlock() gets bg_chain from HistoricalChainstate() under cs_main, then releases the lock before calling bg_chain->ActivateBestChain(). If another ProcessNewBlock() caller (msghand, submitblock RPC, or the loadblk thread) is inside the background chainstate's ActivateBestChain() at that moment, it can reach the snapshot base block. MaybeValidateSnapshot() then sets m_target_utxohash, and the first thread hits this check as soon as it gets m_chainstate_mutex. That window is narrow but real, making it a FatalError would shut down nodes, and also that behavior change doesn't belong in a refactor PR ?

  45. in src/kernel/bitcoinkernel_wrapper.h:67 in 4b183f4e07 outdated
      63 | @@ -64,8 +64,7 @@ enum class Warning : btck_Warning {
      64 |  
      65 |  enum class ValidationMode : btck_ValidationMode {
      66 |      VALID = btck_ValidationMode_VALID,
      67 | -    INVALID = btck_ValidationMode_INVALID,
      68 | -    INTERNAL_ERROR = btck_ValidationMode_INTERNAL_ERROR
      69 | +    INVALID = btck_ValidationMode_INVALID
    


    hodlinator commented at 7:44 AM on September 16, 2026:

    nit: Cleaner diff if we retain the optional ending comma:

        INVALID = btck_ValidationMode_INVALID,
    
  46. in src/test/validation_tests.cpp:27 in 4b183f4e07 outdated
      22 |  
      23 |  #include <boost/test/unit_test.hpp>
      24 |  
      25 |  BOOST_FIXTURE_TEST_SUITE(validation_tests, BasicTestingSetup)
      26 |  
      27 | +BOOST_AUTO_TEST_CASE(raised_fatal_error_token)
    


    hodlinator commented at 7:47 AM on September 16, 2026:

    Would prefer adding the test together with the commit adding the FatalError type since the test is so tightly coupled to the existence of the type.


    yuvicc commented at 6:35 AM on September 18, 2026:

    Done.

  47. in src/node/blockstorage.h:229 in 4b183f4e07 outdated
     223 | @@ -223,9 +224,9 @@ class BlockManager
     224 |       * The nAddSize argument passed to this function should include not just the size of the serialized CBlock, but also the size of
     225 |       * separator fields (STORAGE_HEADER_BYTES).
     226 |       */
     227 | -    [[nodiscard]] FlatFilePos FindNextBlockPos(unsigned int nAddSize, unsigned int nHeight, uint64_t nTime) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
     228 | +    [[nodiscard]] util::Expected<FlatFilePos, kernel::FatalError> FindNextBlockPos(unsigned int nAddSize, unsigned int nHeight, uint64_t nTime) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
     229 |      [[nodiscard]] bool FlushChainstateBlockFile(int tip_height) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
     230 | -    [[nodiscard]] bool FindUndoPos(BlockValidationState& state, int nFile, FlatFilePos& pos, unsigned int nAddSize) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
     231 | +    [[nodiscard]] util::Expected<void, kernel::FatalError> FindUndoPos(int nFile, FlatFilePos& pos, unsigned int nAddSize) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
    


    hodlinator commented at 8:43 AM on September 16, 2026:

    Might as well return FlatFilePos instead of keeping it as an out-parameter:

        [[nodiscard]] util::Expected<FlatFilePos, kernel::FatalError> FindUndoPos(int nFile, unsigned int nAddSize) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);
    
  48. in src/validation.cpp:3214 in 4b183f4e07 outdated
    3209 | @@ -3219,16 +3210,20 @@ bool Chainstate::ActivateBestChainStep(BlockValidationState& state, CBlockIndex&
    3210 |      bool fBlocksDisconnected = false;
    3211 |      DisconnectedBlockTransactions disconnectpool{MAX_DISCONNECTED_TX_POOL_BYTES};
    3212 |      while (m_chain.Tip() && m_chain.Tip() != pindexFork) {
    3213 | -        if (!DisconnectTip(state, &disconnectpool)) {
    3214 | +        auto res{DisconnectTip(&disconnectpool)};
    3215 | +        if (!res || !*res) {
    


    hodlinator commented at 10:09 AM on September 16, 2026:

    nit: Slightly simpler?

            if (!res.value_or(false)) {
    
  49. in src/test/validation_block_tests.cpp:124 in 4b183f4e07 outdated
     119 | @@ -120,8 +120,8 @@ std::shared_ptr<CBlock> MinerTestingSetup::FinalizeBlock(std::shared_ptr<CBlock>
     120 |  
     121 |      // submit block header, so that miner can get the block height from the
     122 |      // global state and the node has the topology of the chain
     123 | -    BlockValidationState ignored;
     124 | -    BOOST_CHECK(Assert(m_node.chainman)->ProcessNewBlockHeaders({{*pblock}}, true, ignored));
     125 | +    BlockValidationState state;
     126 | +    BOOST_CHECK(Assert(m_node.chainman)->ProcessNewBlockHeaders({{*pblock}}, true, state));
    


    hodlinator commented at 4:13 PM on September 16, 2026:

    Seems like naming becomes worse here.

  50. in src/validation.cpp:2640 in 4b183f4e07 outdated
    2638 |      }
    2639 |  
    2640 | -    if (!m_blockman.WriteBlockUndo(blockundo, state, *pindex)) {
    2641 | -        return false;
    2642 | +    if (auto res{m_blockman.WriteBlockUndo(blockundo, *pindex)}; !res) {
    2643 | +        return util::Unexpected(std::move(res).error());
    


    hodlinator commented at 4:32 PM on September 16, 2026:

    nanonit: While util::Expected does have a E&& error() &&-overload, I think it's less surprising to do:

            return util::Unexpected(std::move(res.error()));
    
  51. in src/validation.cpp:4463 in 4b183f4e07 outdated
    4461 | +    if (auto res{ActiveChainstate().ActivateBestChain(block)}; !res || !*res) {
    4462 | +        if (!res) {
    4463 | +            LogError("%s: ActivateBestChain failed (%s)\n", __func__, res.error().message());
    4464 | +            return util::Unexpected(std::move(res).error());
    4465 | +        }
    4466 | +        LogError("%s: ActivateBestChain failed\n", __func__);
    


    hodlinator commented at 4:51 PM on September 16, 2026:

    Could shorten to:

        if (auto res{ActiveChainstate().ActivateBestChain(block)}; !res.value_or(false)) {
            LogError("%s: ActivateBestChain failed (%s)", __func__, res ? "" : res.error().message());
    

    Same below.

  52. in src/validation.cpp:4759 in 4b183f4e07
    4752 | @@ -4755,8 +4753,8 @@ VerifyDBResult CVerifyDB::VerifyDB(
    4753 |                  LogError("Verification error: ReadBlock failed at %d, hash=%s", pindex->nHeight, pindex->GetBlockHash().ToString());
    4754 |                  return VerifyDBResult::CORRUPTED_BLOCK_DB;
    4755 |              }
    4756 | -            if (!chainstate.ConnectBlock(block, state, pindex, coins)) {
    4757 | -                LogError("Verification error: found unconnectable block at %d, hash=%s (%s)", pindex->nHeight, pindex->GetBlockHash().ToString(), state.ToString());
    4758 | +            if (auto res{chainstate.ConnectBlock(block, pindex, coins)}; !res || res->IsInvalid()) {
    4759 | +                LogError("Verification error: found unconnectable block at %d, hash=%s (%s)", pindex->nHeight, pindex->GetBlockHash().ToString(), res ? res->ToString() : res.error().message());
    4760 |                  return VerifyDBResult::CORRUPTED_BLOCK_DB;
    4761 |              }
    


    hodlinator commented at 4:57 PM on September 16, 2026:

    nit: state is not technically used after this if-block, but would still be more correct to assign to it.

                auto res{chainstate.ConnectBlock(block, pindex, coins)};
                if (!res || !res->IsValid()) {
                    LogError("Verification error: found unconnectable block at %d, hash=%s (%s)", pindex->nHeight, pindex->GetBlockHash().ToString(), res ? res->ToString() : res.error().message());
                    return VerifyDBResult::CORRUPTED_BLOCK_DB;
                }
                state = *res;
    
  53. in src/validation.cpp:5064 in 4b183f4e07 outdated
    5059 | @@ -5062,13 +5060,11 @@ void ChainstateManager::LoadExternalBlockFile(
    5060 |                          blkdat >> TX_WITH_WITNESS(*pblock);
    5061 |                          nRewind = blkdat.GetPos();
    5062 |  
    5063 | -                        BlockValidationState state;
    5064 | -                        if (AcceptBlock(pblock, state, nullptr, true, dbp, nullptr, true)) {
    5065 | +                        auto res{AcceptBlock(pblock, nullptr, true, dbp, nullptr, true)};
    5066 | +                        if (!res) break;
    


    hodlinator commented at 5:05 PM on September 16, 2026:

    nit: I think this code is important enough to keep calling out control flow a bit clearer:

                            if (!res) {
                                break;
                            }
    
  54. yuvicc force-pushed on Sep 17, 2026
  55. DrahtBot added the label CI failed on Sep 17, 2026
  56. DrahtBot commented at 6:57 AM on September 17, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task macOS-cross to arm64: https://github.com/bitcoin/bitcoin/actions/runs/35190448674/job/105101625071</sub> <sub>LLM reason (✨ experimental): CI failed due to a C++ build error: Chainstate().InvalidateBlock is declared to take a single CBlockIndex* argument, but wallet_tests.cpp calls it with two arguments.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  57. yuvicc force-pushed on Sep 17, 2026
  58. in src/validationinterface.h:160 in 66a8476158
     157 |       * If the provided BlockValidationState IsValid, the provided block
     158 |       * is guaranteed to be the current best block at the time the
     159 |       * callback was generated (not necessarily now).
     160 |       */
     161 | -    virtual void BlockChecked(const std::shared_ptr<const CBlock>&, const BlockValidationState&) {}
     162 | +    virtual void BlockChecked(const std::shared_ptr<const CBlock>&, const util::Expected<BlockValidationState, kernel::FatalError>&) {}
    


    hodlinator commented at 8:36 AM on September 17, 2026:

    If the error is truly fatal, like the disk being unusable, should we still trigger signals like this one?

    Maybe it's not worth changing the validation interface further than removing error from BlockValidationState.

    Could add a separate FatalError signal if we really deem it worth handling?

    Makes me wish BlockValidationState instead of IsValid()+IsInvalid()+IsError() just had one GetMode() which all consumers would put in switch blocks with exhaustive cases so that we could refactor with higher confidence.

  59. in src/node/miner.cpp:430 in 66a8476158
     426 |      CHECK_NONFATAL(chainman.m_options.signals)->UnregisterSharedValidationInterface(sc);
     427 |  
     428 |      if (!new_block && accepted) {
     429 |          reason = "duplicate";
     430 | +    } else if (sc->m_error) {
     431 | +        reason = *sc->m_error;
    


    hodlinator commented at 8:39 AM on September 17, 2026:

    Why do this roundabout error handling rather than checking the return value of ProcessNewBlock()? Is it a way of preserving the internal error string instead of getting the replaced one?


    yuvicc commented at 6:39 AM on September 18, 2026:

    Correct. That was leftover from when ProcessNewBlock() returned only bool. Since it now returns util::Expected, we can use res.error().message() directly and remove sc->m_error.

  60. in src/kernel/fatal_error.h:44 in 66a8476158
      39 | +
      40 | +    //! The untranslated error message for logging.
      41 | +    const std::string& message() const LIFETIMEBOUND { return m_message; }
      42 | +
      43 | +    //! Replace the caller-facing diagnostic without raising another notification.
      44 | +    FatalError WithMessage(std::string message) &&
    


    hodlinator commented at 9:19 AM on September 17, 2026:

    nanonit: More honest/direct name?

        FatalError ReplaceMessage(std::string message) &&
    
  61. hodlinator commented at 9:37 AM on September 17, 2026: contributor

    Concept ACK the earlier push 4b183f4e077eb1e7d48cf5d53036c8591abf9478

    Extracting the error-part of BlockValidationState by using util::Expected is a promising direction.

    As the PRs stand today, something like this should be merged before #35570 is considered.

    This PR would make review easier if broken up into more commits so that the transforms are applied to one function at a time.

    See my suggestions branch: https://github.com/hodlinator/bitcoin/tree/pr/35646_piecemeal

  62. optout21 commented at 10:30 AM on September 17, 2026: contributor

    not upgrading my Concept A to a proper acknowledgement @DrahtBot note: As I've noticed during the discussion with @hodlinator , my previous concept A was interpreted as a plain ack. I think this is because there was no space between the "Concept" and "A", and there was a following commit ID. This was against my intention. I edit it to reflect this (insert a space). I should have noticed this, my mistake. Screenshots follow.

    <img width="1358" height="254" alt="image" src="https://github.com/user-attachments/assets/b2d5ee14-58c8-4b31-8942-28bc4de0ec67" /> <img width="1302" height="240" alt="image" src="https://github.com/user-attachments/assets/2ef85424-994f-46d8-a03c-30489fc5b821" />

  63. maflcko commented at 11:26 AM on September 17, 2026: member
  64. optout21 commented at 10:52 PM on September 17, 2026: contributor

    Sure, fixed in maflcko/DrahtBot@7554887.

    That was fast, thanks! 🙏

  65. yuvicc force-pushed on Sep 18, 2026
  66. maflcko removed the label CI failed on Sep 18, 2026
  67. DrahtBot added the label CI failed on Sep 18, 2026
  68. DrahtBot commented at 3:39 PM on September 18, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/35315445149/job/105651757101</sub> <sub>LLM reason (✨ experimental): CTest failed because the miner_tests test suite failed (ctest returned non-zero exit status 8).</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  69. yuvicc force-pushed on Sep 19, 2026
  70. yuvicc commented at 6:38 AM on September 19, 2026: contributor

    Thanks for the review @hodlinator and @optout21, I have split the commits more for easier review and addressed other comments:

    • Added the move-only kernel::FatalError type and co-located it with Notifications.

    • Changed FatalError::Raise() to take ownership of the translated message and move the original string into the error.

    • Renamed WithMessage() to ReplaceMessage().

    • Converted block storage, flushing, block connection, block acceptance, chain activation, invalidation, precious-block handling, and block submission to propagate fatal failures through util::Expected.

    • Changed FindUndoPos() to return FlatFilePos directly instead of using an output parameter.

    • Simplified util::Expected checks with value_or(false) where appropriate.

    • Preserved explicit validation-state checks where the returned BlockValidationState remains meaningful, including submitheader.

    • Removed the obsolete validation-state error mode and the kernel INTERNAL_ERROR mode.

    • Kept BlockChecked for ordinary validation results and handled fatal errors through the direct return value and fatalError notification.

    • Addressed the behavior change where fatal AcceptBlock failures no longer emit BlockChecked, peer block-source cleanup now happens directly when ProcessNewBlock() returns a fatal error.

    • Other nits and typos.

  71. DrahtBot removed the label CI failed on Sep 19, 2026
  72. yuvicc requested review from hodlinator on Sep 20, 2026
  73. in src/node/blockstorage.cpp:990 in 80a841992a
     996 | -            LogError("FindUndoPos failed for %s while writing block undo", pos.ToString());
     997 | -            return false;
     998 | +        auto res{FindUndoPos(block.nFile, blockundo_size + UNDO_DATA_DISK_OVERHEAD)};
     999 | +        if (!res) {
    1000 | +            LogError("FindUndoPos failed for file %d while writing block undo", block.nFile);
    1001 | +            return util::Unexpected(std::move(res.error()));
    


    optout21 commented at 8:34 AM on September 22, 2026:

    80a8419 refactor: Make BlockManager use FatalError type:

    Nit: res.error() could be also logged.


    yuvicc commented at 4:46 AM on September 23, 2026:

    Correct. Thanks.

  74. in src/node/blockstorage.cpp:1153 in 80a841992a
    1153 | -        LogError("FindNextBlockPos failed for %s while writing block", pos.ToString());
    1154 | -        return FlatFilePos();
    1155 | +    auto res{FindNextBlockPos(block_size + STORAGE_HEADER_BYTES, nHeight, block.GetBlockTime())};
    1156 | +    if (!res) {
    1157 | +        LogError("FindNextBlockPos failed while writing block");
    1158 | +        return res;
    


    optout21 commented at 8:35 AM on September 22, 2026:

    80a8419 refactor: Make BlockManager use FatalError type:

    Nit: 'res.error()` could be included in the log.


    yuvicc commented at 5:03 AM on September 23, 2026:

    Done, Thanks.

  75. in src/validation.cpp:2340 in 2b0630638c
    2336 | @@ -2336,7 +2337,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,
    2337 |      if (block_hash == params.GetConsensus().hashGenesisBlock) {
    2338 |          if (!fJustCheck)
    2339 |              view.SetBestBlock(pindex->GetBlockHash());
    2340 | -        return true;
    2341 | +        return state;
    


    optout21 commented at 8:48 AM on September 22, 2026:

    2b06306 refactor: Make ConnectBlock() use FatalError type:

    Nit: This always returns Valid state, could be return {} to better reflect that, and make less use of the local state variable.


    hodlinator commented at 11:14 AM on September 22, 2026:

    re #35646 (review):

    That is only true as long as the logic above doesn't change. Seems safest to use state as long as it exists.


    optout21 commented at 1:02 PM on September 22, 2026:

    2b06306 refactor: Make ConnectBlock() use FatalError type:

    That's true, but it could be improved by making state an if-local in the block where CheckBlock is called, and the declaration of method-local state variable could be moved further down. That way there would be no chance to represent an non-Valid state at this point. (My guiding principle here is to reduce the scope of variables.)


    yuvicc commented at 5:07 AM on September 23, 2026:

    Hmm. I think this can be also be applied the same to the fJustCheck early return and the final return, since those are also always valid at that point (invalid states are returned earlier) ?


    optout21 commented at 6:15 AM on September 23, 2026:

    I think this can be also be applied the same to the fJustCheck early return and the final return

    That seems to be the case: after the genesis block early return there is a large portion of code where validation checks are made, and state may be set to Invalid, without immediate return. But at the end, there is a return on Invalid. So by the time execution gets to the fJustCheck early return, state must be Valid. I would put an Assume(state.IsValid()) there to show and enforce. The early return and the final return can be {} then. But on a second thought I lean to leaving as-is, seems a bit risky. With this change the scope of the state local variable would not be reduces, so I don't think it's worth it.

  76. in src/validation.cpp:3058 in 2b0630638c outdated
    3057 | +        }
    3058 | +        if (state.IsInvalid()) {
    3059 | +            InvalidBlockFound(pindexNew, state);
    3060 | +            LogInfo("%s: ConnectBlock %s failed, %s\n", __func__, pindexNew->GetBlockHash().ToString(), state.ToString());
    3061 |              return false;
    3062 |          }
    


    optout21 commented at 9:01 AM on September 22, 2026:

    2b06306 refactor: Make ConnectBlock() use FatalError type:

    This looks correct, but it could be a bit simplified and kept more similar to the original, if using !state.IsValid() as condition (it corresponds to state.IsInvalid() || state.IsError()). Full code snippet:

            if (!state.IsValid()) {
                if (state.IsInvalid()) {
                    InvalidBlockFound(pindexNew, state);
                LogInfo("%s: ConnectBlock %s failed, %s\n", __func__, pindexNew->GetBlockHash().ToString(), state.ToString());
                return false;
            }
    

    hodlinator commented at 11:16 AM on September 22, 2026:

    re #35646 (review):

    Couldn't we just remove the check for IsInvalid() once we remove the Error-mode?

            if (!state.IsValid()) {
                InvalidBlockFound(pindexNew, state);
                LogInfo("%s: ConnectBlock %s failed, %s\n", __func__, pindexNew->GetBlockHash().ToString(), state.ToString());
                return false;
            }
    

    yuvicc commented at 5:12 AM on September 23, 2026:

    This looks correct, but it could be a bit simplified.....

    Good catch, restructured to !state.IsValid() with the nested IsInvalid() check, keeping LogError as in the original.


    yuvicc commented at 5:13 AM on September 23, 2026:

    Couldn't we just remove the check for IsInvalid() once we remove the Error-mode?

    Agreed, dropped the IsInvalid() check in "consensus: remove ValidationState error mode", since !IsValid() is equivalent once Error mode is gone.

  77. in src/validation.cpp:4393 in a43b082609
    4392 | -                return false;
    4393 | +                // Preserve the diagnostic previously returned for a null block position.
    4394 | +                return util::Unexpected(std::move(res.error()).ReplaceMessage(
    4395 | +                    strprintf("%s: Failed to find position to write new block to disk", __func__)));
    4396 |              }
    4397 | -            assert(!res->IsNull());
    


    optout21 commented at 9:12 AM on September 22, 2026:

    a43b082 refactor: Make AcceptBlock() use FatalError type:

    Why remove this assert here, if it was introduced earlier? I can't see any relevant change in this commit.


    yuvicc commented at 5:39 AM on September 23, 2026:

    Good catch, that was unintentional.

  78. in src/validation.cpp:4448 in a43b082609
    4444 | +            auto accept_ret{AcceptBlock(block, &pindex, force_processing, nullptr, new_block, min_pow_checked)};
    4445 | +            if (!accept_ret) {
    4446 | +                state.Error(accept_ret.error().message());
    4447 | +            } else {
    4448 | +                state = std::move(*accept_ret);
    4449 | +            }
    


    optout21 commented at 9:14 AM on September 22, 2026:

    a43b082 refactor: Make AcceptBlock() use FatalError type:

    Nit: This if occurs in a few places, would it make sense to factor it out somewhere?


    hodlinator commented at 11:20 AM on September 22, 2026:

    re #35646 (review):

    state.Error() is removed in a later commit so I would defer considerations of factoring it out until later.


    optout21 commented at 6:01 AM on September 23, 2026:

    OK, I retract this, feel free to Resolve.

  79. in src/test/validation_chainstatemanager_tests.cpp:746 in 0136c27806
     740 | @@ -741,10 +741,9 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup)
     741 |      //
     742 |      // Note that this is not a realistic use of DisconnectTip().
     743 |      DisconnectedBlockTransactions unused_pool{MAX_DISCONNECTED_TX_POOL_BYTES};
     744 | -    BlockValidationState unused_state;
     745 |      {
     746 |          LOCK2(::cs_main, bg_chainstate.MempoolMutex());
     747 | -        BOOST_CHECK(bg_chainstate.DisconnectTip(unused_state, &unused_pool));
     748 | +        BOOST_CHECK(bg_chainstate.DisconnectTip(&unused_pool).has_value());
    


    optout21 commented at 9:38 AM on September 22, 2026:

    0136c27 refactor: Make DisconnectTip() use FatalError type:

    Should check that it has value, and it is Valid.


    yuvicc commented at 5:41 AM on September 23, 2026:

    Thanks, done.

  80. in src/validation.cpp:3604 in 876a2f999c
    3600 | @@ -3601,8 +3601,9 @@ bool Chainstate::InvalidateBlock(BlockValidationState& state, CBlockIndex* const
    3601 |          // transactions back to the mempool if disconnecting was successful,
    3602 |          // and we're not doing a very deep invalidation (in which case
    3603 |          // keeping the mempool up to date is probably futile anyway).
    3604 | -        MaybeUpdateMempoolForReorg(disconnectpool, /* fAddToMempool = */ (++disconnected <= 10) && ret.value_or(false));
    3605 | -        if (!ret.value_or(false)) return false;
    3606 | +        MaybeUpdateMempoolForReorg(disconnectpool, /* fAddToMempool = */ (++disconnected <= 10) && ret && *ret);
    


    optout21 commented at 9:54 AM on September 22, 2026:

    876a2f9 refactor: Make InvalidateBlock() use FatalError type:

    I think this line can stay untouched, with ret.value_or(false).


    yuvicc commented at 5:42 AM on September 23, 2026:

    Agree!

  81. optout21 commented at 10:05 AM on September 22, 2026: contributor

    Reviewed 22af5819cd2d29df7a937909d6dd11f1f8abe2b2, looks good! Left some comments.

  82. hodlinator commented at 11:32 AM on September 22, 2026: contributor

    Might take a while until I'm able to do a full review but I see you've incorporated my feedback from #35646#pullrequestreview-5210572199, thanks!

  83. yuvicc force-pushed on Sep 23, 2026
  84. yuvicc commented at 2:35 PM on September 23, 2026: contributor

    Thanks for review @optout21. Addressed comments:

    • Included the FatalError message in the FindUndoPos and FindNextBlockPos failure logs. Restored the assert(!res->IsNull()) that was unintentionally dropped in the AcceptBlock commit.
    • Return {} instead of state on paths where the state is always valid.
    • the MaybeUpdateMempoolForReorg(...) line is now untouched and keeps ret.value_or(false).
    • dropped the now-redundant IsInvalid() check before InvalidBlockFound() in ConnectTip()
  85. purpleKarrot commented at 7:30 PM on September 23, 2026: contributor

    I agree with the full motivation but disagree (NACK) conceptually with using util::Expected/std::expected.

    std::expected-style error handling trades runtime efficiency for runtime deterministic performance, which is a valid trade-off for systems with hard-realtime-requirements. I explained it on multiple occasions that for Bitcoin, runtime efficiency is more important than runtime deterministic performance, because we need IBD to be as fast as possible and new blocks are added on an extremely irregular cadence (approximately ten minutes on average is about as far away from a hard realtime system as you can possibly imagine). Hence, for Bitcoin, throwing exceptions is the more appropriate mechanism for error handling.

  86. l0rinc commented at 7:51 PM on September 23, 2026: contributor

    I explained it on multiple occasions that for Bitcoin, runtime efficiency is more important than runtime deterministic performance

    And you received pushback every single time (e.g. #36101): exceptions are hard-to-predict invisible control flow, which is why many newer languages are moving away from them and towards explicit, value-based error handling to minimize surprises. You're presenting your opinion as facts here, ignoring the project's specific needs and history.

    because we need IBD to be as fast as possible

    I'm glad you're worried about this, so are we, it's why we've been working on this for years, we still have many open PRs optimizing this, you can help by reviewing them. That's also a good way to familiarize yourself with the project.

    Hence, for Bitcoin, throwing exceptions is the more appropriate mechanism for error handling.

    No, in mission-critical applications exceptions are often forbidden for good reason: they're hard to predict in exceptional (pun intended) circumstances.

  87. DrahtBot added the label Needs rebase on Sep 24, 2026
  88. hodlinator commented at 5:51 PM on September 24, 2026: contributor

    @purpleKarrot I watched the talk you recommended by Khalil Estell. My impression was that [Ee]xpected performs poorly primarily when the involved types are large/complex. Types being able to be moved like the proposed FatalError probably helps.

    I think sacrificing a tiny bit of efficiency in exchange for making error handling explicit at each level is the prudent approach in this part of the code.

  89. kernel: add FatalError type and tests
    Keep the move-only error token with Notifications. Raise the notification once,
    move the diagnostic into the token, and allow callers to replace the message
    without firing another notification. Test construction and propagation.
    
    Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com>
    50b8ed8bbf
  90. refactor: Make BlockManager use FatalError type
    Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com>
    3e6d69ad7a
  91. refactor: Make FlushStateToDisk() use FatalError type 5382efd019
  92. refactor: Make ConnectBlock() use FatalError type df90b0e70a
  93. refactor: Make TestBlockValidity() use FatalError type 7605fabf62
  94. refactor: Make AcceptBlock() use FatalError type c3bf03971e
  95. refactor: Make ConnectTip() use FatalError type
    Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com>
    598c4f19ac
  96. refactor: Make DisconnectTip() use FatalError type 50449d2d23
  97. refactor: Make ActivateBestChainStep() use FatalError type 0521cb6820
  98. refactor: Make ActivateBestChain() use FatalError type 82c0872257
  99. refactor: Make InvalidateBlock() use FatalError type 5d80b74d27
  100. refactor: Make PreciousBlock() use FatalError type b0654f1fd7
  101. refactor: Make ProcessNewBlock() use FatalError type
    Read fatal errors directly from ProcessNewBlock in submission callers. Test
    block-write, undo-write, and post-validation flush failures, including the
    absence of BlockChecked signals for fatal validation failures.
    
    Since fatal AcceptBlock failures no longer emit BlockChecked, clean up
    the submitted block source in peer processing when ProcessNewBlock
    returns a fatal error.
    
    Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com>
    Co-authored-by: optout <13562139+optout21@users.noreply.github.com>
    f529ce0d92
  102. remove FatalError() function df030a5b48
  103. consensus: remove ValidationState error mode
    Runtime failures are now returned separately through `util::Expected`, so `ValidationState`
    only needs to represent valid and invalid validation outcomes.
    Remove `M_ERROR`, `Error()`, and `IsError()`.
    00158abcb4
  104. kernel: remove INTERNAL_ERROR validation mode
    Validation state no longer represents runtime failures, so remove INTERNAL_ERROR from the kernel C API and C++ wrapper.
    7a6ced08c6
  105. yuvicc commented at 5:08 AM on September 25, 2026: contributor

    std::expected-style error handling trades runtime efficiency for runtime deterministic performance, which is a valid trade-off for systems with hard-realtime-requirements.

    Thanks @purpleKarrot. I agree that IBD throughput matters and that exceptions might provide a cheaper success path. However, deterministic execution isn’t the reason for choosing util::Expected here. Its other benefit is making the distinction between a validation result and an operational failure explicit in the function’s interface. That is also part of the motivation for std::expected.

    Apart from that there is also existing control flow to preserve. For example, ActivateBestChainStep() updates the mempool after a failed connection or disconnection before propagating the failure. An exception based implementation could preserve this too, but would require deliberately arranging cleanup and catch boundaries, including at the kernel API. Keeping explicit propagation makes that separation a more easier to review change. Also opened a PR in benchcoin to check the IBD performance.

  106. yuvicc force-pushed on Sep 25, 2026
  107. yuvicc commented at 5:10 AM on September 25, 2026: contributor

    Rebased onto master (e8e7e91a11) and resolved conflicts with #35675.

  108. purpleKarrot commented at 5:46 AM on September 25, 2026: contributor

    making the distinction between a validation result and an operational failure explicit

    My point is that this distinction is not explicit enough when both are communicated over the same channel.

    When block validation returns a success value and throws on exception, client code will look like this:

    auto res = validate(block);
    if (res) {
      // block is valid
    } else {
      // block is invalid
    }
    

    Operational failure will take neither of the two branches.

    But when validate returns an expected object, operator bool will just tell whether a fatal error occurred. If user code uses conditions like res && res->IsValid(), it is very dangerous to have an else branch, because it may lead to the same situation that caused the 2013 chain split.

    And furthermore: If functions returning expected are not marked noexcept, all clients now have to deal with two error handling mechanisms. But if the function is marked noexcept, it will terminate when an actual exception is thrown.

  109. DrahtBot removed the label Needs rebase on Sep 25, 2026
  110. optout21 commented at 6:45 AM on September 25, 2026: contributor

    I agree with the full motivation but disagree (NACK) conceptually with using util::Expected/std::expected.

    Contrarian views have the potential to advance things. I've considered your point of view from different sides (including discussions in #36101). My personal conclusion is that in this particular instance (and probably in many others as well) returns are preferable over exceptions, because the latter is more brittle and error prone in face of future code changes. I say this considering the criticality of the validation code, and also the losly organized nature of the project. Your objection did raise my awareness of the cost aspects of passing objects as return values (structs, Expected, string fields, movability, etc.), but I dismiss your performance degradation point as minor in comparison with the risks of future mishandled errors.

  111. optout21 commented at 10:55 AM on September 25, 2026: contributor

    Triggered by the return-vs-throw discussion (@purpleKarrot), I've done some microbenchmarking of the different error styles. Styles compared:

    • 0 baseline: always return void, no state
    • 1 return bool, with BlockValidationState as parameter
    • 1.a out-only: set the state on success too (state = {})
    • 1.b in-out: do not touch the state on success (as in the codebase)
    • 2 return BlockValidationState
    • 3 return util::Expected<BlockValidationState, kernel::FatalError>
    • 4 return BlockValidationState (valid or invalid), throw on fatal error
    • 5 return util::Expected<void, std::variant<BlockValidationState, kernel::FatalError>>

    For 1&2 it is assumed that state can represent Error as well (not the case in this PR any more, but was before)

    The benchmark does 50M iterations of a method call, which itself calls into other methods, with branch-out factor of 2 for 2 levels (1+2+4 = 7 total calls).

    Every 1000th call returns invalid, every 1000th (not the same ones) return Error, the rest of the calls (998) return success.

    The benchmarks is available at: https://github.com/optout21/bitcoin/blob/error-style-bench/src/bench/validation_state_error_handling.cpp

    Sample results below.

    Some conclusions:

    • 1.b is the fastest, when the state parameter is not touched at all in the success branch. However, this is not very clean, as state from different methods can get mixed or overwritten, and we aim to make this cleaner (this PR, #35570)
    • util::Expected is generally not slower than out-param style. In fact in some earlier iteration it was even faster than the plain return (analysis found that the differences are due to the specifics of compiler branch optimizations).
    • return+throw is the slowest, due to the overhead of exceptions. With no exceptions, it's the same as the plain return.
    • Generally differences are very small, especially compared to time for the real work of the methods. Differences are on the micro-optimization level. A lot depends on how the state variable is set, in what order it is checked, etc.
    • An interesting idea out of this is that we could to optimize for the frequent success case, and do not use a BlockValidationState object in success case at all. This can be achieved e.g. by instead of:`

    util::Expected<BlockValidationState, kernel::FatalError>

    using:

    util::Expected<void, util::Expected<BlockValidationState, kernel::FatalError>> or util::Expected<void, std::variant<BlockValidationState, kernel::FatalError>>

    This way the success case is optimized, and this is confirmed by the benchmark. @yuvicc, give it a thought. However, I think it's a micro-optimization not worth doing.

    <details> <summary> Sample benchmark results </summary>

    |             ns/call |              call/s |    err% |     total | benchmark
    |--------------------:|--------------------:|--------:|----------:|:----------
    |                8.23 |      121,479,880.24 |    0.1% |      4.53 | `ErrorHandlingBaselineVoid`
    |                8.37 |      119,486,068.62 |    0.0% |      4.60 | `ErrorHandlingBoolInOutState`
    |               16.11 |       62,068,002.98 |    0.2% |      8.87 | `ErrorHandlingBoolOutState`
    |               14.93 |       66,989,455.90 |    0.1% |      8.27 | `ErrorHandlingReturnExpectedState`
    |               14.43 |       69,293,814.61 |    0.0% |      7.94 | `ErrorHandlingReturnState`
    |               21.06 |       47,483,573.19 |    0.1% |     11.59 | `ErrorHandlingReturnStateThrow`
    |               10.52 |       95,014,644.68 |    0.0% |      5.80 | `ErrorHandlingReturnVoidExpected`
    

    </details>

  112. in src/consensus/validation.h:88 in 00158abcb4
      84 | @@ -85,7 +85,6 @@ class ValidationState
      85 |      enum class ModeState {
      86 |          M_VALID,   //!< everything ok
      87 |          M_INVALID, //!< network rule violation (DoS value may be set)
      88 | -        M_ERROR,   //!< run-time error
      89 |      } m_mode{ModeState::M_VALID};
    


    optout21 commented at 11:02 AM on September 25, 2026:

    00158ab consensus: remove ValidationState error mode:

    Now that the mode is only binary, maybe consider simplifying to a bool?

  113. arejula27 commented at 11:58 AM on September 26, 2026: contributor

    Concept ACK

    I like the util::Expected direction, as I also asked for it in [my review on #35570](/bitcoin-bitcoin/35570/#pullrequestreview-4555262677), so consider this a supporting vote for it. I do not like exceptions, and this error handling is much easier to understand and follow.

    Since this PR and my own #36326 ("kernel: Use typed errors for fatal and flush error notifications") overlap, there are a couple of things I would like to mention.

    Adding a new type to the kernel here feels from my pov out of scope for this PR, separate from what it sets out to fix (the tri-state in BlockValidationState), since that is already achievable with plain strings. Adding a type is an improvement on top of that, not a requirement for it, and has more implications than what has been already discussed in this conversation: it pulls more core-owned dependencies into the kernel, which I think goes against the general direction of the libbitcoinkernel project, fixes the shape of the errors inside the kernel and also broadens kernel maintenance surface. The kernel is consensus-critical code, so the less responsibility it carries, the easier it is to maintain. Those feel like implications that deserve their own discussion. I have been exploring how to improve the error type and remove dependencies from the kernel for a few months now, until finally proposing #36326. My focus was extracting bilingual_str and improving the kernel API, unrelated to this PR's behavior, but our work ended up colliding.

    I think the two ideas are not in conflict, and can actually be combined: the typed kernel::FatalError variant from #36326 could be the payload util::Expected carries here instead of a wrapped-string. Notifying and building the message are already two separate steps in #36326: fatalError() fires right at the origin with the typed data, no string needed at that point, and the translated message gets built afterwards, downstream, on the node side. So swapping the payload would not delay or otherwise change when the fatal notification fires, only what it carries. Worth discussing which PR keeps which part before either one merges, since right now both define kernel::FatalError with the same name, different responsibilities, and incompatible shapes.

  114. yuvicc commented at 5:23 AM on September 29, 2026: contributor

    Thanks @optout21 for putting this together! I reproduced it locally ( with standalone at -O2, pinned core, GCC 14.2 / Clang 18.1). Results in ns per top-level call (7 nested calls):

    <details> <summary> benchmark results </summary> | Style | GCC | Clang | |-----------------------------|------|-------| | 0 baseline | 10.4 | 10.6 | | 1a out-param | 24.9 | 29.2 | | 1b in-out | 11.9 | 11.5 | | 2 return state | 24.0 | 23.6 | | 3 Expected<BVS, FatalError> | 26.4 | 32.4 | | 4 return + throw | 33.8 | 39.7 | | 5 Expected<void, variant> | 16.7 | 20.4 |

    </details>

    The ordering matches yours one. The main cost is building a BlockValidationState (two std::strings) on the success path, not the return mechanism. Agree that style no. 5 isn't worth it now. Moving Invalid into the error channel alongside FatalError would also blur the separation this PR aims for.

  115. optout21 commented at 6:36 AM on September 29, 2026: contributor

    Agree that style no. 5 isn't worth it now. Moving Invalid into the error channel alongside FatalError would also blur the separation this PR aims for.

    Thanks for looking into it! I think we are in agreement. The result return is not the hot-path of the chain validation, over-optimization doesn't worth it.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-09-30 15:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me