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

pull yuvicc wants to merge 3 commits into bitcoin:master from yuvicc:2026-06-remove_enum_ie changing 29 files +337 −363
  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 ACK optout21

    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:

    • #35675 (mining: add block template manager by ismaelsadeeq)
    • #35581 (node: add block template manager and track waitNext fee inflow by ismaelsadeeq)
    • #35570 (refactor: Change some validation.cpp methods to return BlockValidationState by optout21)
    • #35502 (refactor: extract per-message helpers from ProcessMessage (move-only) by w0xlt)
    • #35307 (blockstorage: keep snapshot base in normal blockfile range by shuv-amp)
    • #35295 (validation: fetch block input prevouts in parallel during ConnectBlock by andrewtoth)
    • #34729 (Reduce log noise by ajtowns)
    • #34672 (mining: add reason/debug to submitSolution and unify with submitBlock by w0xlt)
    • #34075 (fees: Introduce Mempool Based Fee Estimation to reduce overestimation by ismaelsadeeq)
    • #33922 (mining: add getMemoryLoad() and track template non-mempool memory footprint by Sjors)
    • #32317 (kernel: Separate UTXO set access from validation functions by sedited)

    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-->

    LLM Linter (✨ experimental)

    Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

    • AcceptBlock(new_block, &new_block_index, true, nullptr, nullptr, true) in src/test/coinstatsindex_tests.cpp
    • AcceptBlock(pblockone, &pindex, true, nullptr, &newblock, true) in src/test/validation_chainstate_tests.cpp
    • AcceptBlock(pblock, nullptr, true, dbp, nullptr, true) in src/validation.cpp
    • AcceptBlock(pblockrecursive, nullptr, true, &it->second, nullptr, true) in src/validation.cpp
    • ProcessNewBlock(shared_pblock, true, true, nullptr) in src/test/util/setup_common.cpp
    • ProcessNewBlock(block, true, true, &new_block) in src/test/util/mining.cpp
    • ProcessNewBlock(std::make_shared<CBlock>(Params().GenesisBlock()), true, true, &ignored) in src/test/validation_block_tests.cpp
    • ProcessNewBlock(block, true, true, &ignored) in src/test/validation_block_tests.cpp

    <sup>2026-07-06 16:26:32</sup>

  3. DrahtBot added the label Needs rebase on Jul 6, 2026
  4. validation: route fatal errors through util::Expected
    Validation functions previously signalled fatal/internal error by
    setting an `M_ERROR` mode on the BlockValidationState out-parameter, which
    tangled three outcomes (valid / invalid / fatal) in a single object
    and forced callers to unwrap them.
    
    Convert the block processing chain to return `util::Expected<T,
    std::string>`, where the error channel carries the fatal message and the
    value channel carries the `BlockValidationState` (valid/invalid) or void.
    `FatalError()` now fires the fatalError notification and returns the
    message as a `util::Unexpected` for propagation up the stack.
    
    The BlockChecked validation interface signal is now fired only for
    valid/invalid outcomes, never on a fatal error. All callers across
    net_processing, rpc, node, kernel, tests and bench are updated to read
    the two channels explicitly.
    7d0ed51ec4
  5. consensus: remove `Error()`/`IsError()` from ValidationState
    Now that fatal errors are propagated through util::Expected's error
    channel, no validation code sets or reads the `M_ERROR` mode. Drop
    `Error()`, `IsError()` and the `M_ERROR` enumerator so that a `ValidationState`
    can only be valid or invalid.
    25eb30ba35
  6. kernel: remove `INTERNAL_ERROR` from `btck_ValidationMode` bf144746c2
  7. yuvicc force-pushed on Jul 6, 2026
  8. 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.

  9. DrahtBot removed the label Needs rebase on Jul 6, 2026
  10. 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?

  11. 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

  12. 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)

  13. 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)

  14. 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).
  15. 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>

  16. 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?
  17. willcl-ark added the label Brainstorming on Jul 8, 2026
  18. willcl-ark added the label Validation on Jul 8, 2026
  19. DrahtBot commented at 2:40 AM on July 9, 2026: contributor

    <!--cf906140f33d8803c4a75a2196329ecb-->

    🐙 This pull request conflicts with the target branch and needs rebase.

  20. DrahtBot added the label Needs rebase on Jul 9, 2026

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-07-09 06:47 UTC