net: Disallow invalid HeadersSyncState due to lagging clock #35351

pull hodlinator wants to merge 3 commits into bitcoin:master from hodlinator:pr/35208_alt changing 6 files +99 −32
  1. hodlinator commented at 7:56 PM on May 21, 2026: contributor

    Problem

    Headers presync computes m_max_commitments from the elapsed time since the chain-start MTP plus MAX_FUTURE_BLOCK_TIME. When the local system clock is more than MAX_FUTURE_BLOCK_TIME behind the chain-start MTP, that elapsed value is negative, but it is used in arithmetic assigned to the unsigned commitment cap. This can turn the intended zero bound into a large cap, letting low-work headers presync continue instead of aborting when a reasonable commitment cap would have been exceeded.

    Fix

    Instead of allowing an invalid HeadersSyncState object to be created, emit an error and shut down the node.

    Typically, the node will detect that the system clock is set too far in the past when comparing it to the chain tip during chain state loading and shut down before we start syncing headers. So in practice this is very unlikely to make a difference (might be possible if the system clock jumps backwards after we loaded the chain state).

    Commits

    • 2 regression tests pinning the current behavior.
    • A refactor to extract the problematic computation into HeadersSyncState::ComputeMaxCommitments().
    • The fix, along with the corresponding test change.

    Replaces #35208 which was clamping m_max_commitments to zero and then letting the HeadersSyncState consume headers until the block height either reached the the next commitment_period point and aborted, or reached the minimum work threshold and succeeded (possible when having been offline for >144 blocks).

  2. DrahtBot added the label P2P on May 21, 2026
  3. DrahtBot commented at 7:56 PM on May 21, 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/35351.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK l0rinc
    Concept ACK w0xlt, dergoegge

    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:

    • #35820 (refactor: keep duration calculations typed by l0rinc)
    • #35642 (headersync: do parameter search at runtime by sipa)

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

  4. DrahtBot added the label CI failed on May 21, 2026
  5. DrahtBot removed the label CI failed on May 22, 2026
  6. w0xlt commented at 2:01 PM on May 22, 2026: contributor

    Concept ACK

  7. hodlinator commented at 1:37 PM on May 27, 2026: contributor

    Been thinking more about this during the weekend.

    One could posit that Bitcoin nodes should be more shelf stable in a post-apocalyptic future where NTP servers are unreachable. Re-establishing a shared accurate definition of current UTC time just from observing the sun's position could be challenging.

    In that kind of scenario, preventing the node from continuing, as this PR does, could be problematic.

    So should we do like #35208 and just set m_max_commitments to 0 instead? It would still be pretty unforgiving if start-height is very close to a multiple of the commitment_period - maybe allowing at least 1 commitment could be argued? (commitment_period is currently 641, giving an average of 961 blocks before aborting if we start at a random height).

    Although, if clocks in the network disagree too much about UTC time, block propagation would also be suffering due to the rule to not accept blocks from too far into the future (MAX_FUTURE_BLOCK_TIME/2h). The current check failing would also mean that the we somehow ended up with a starting block read from disk that is dated more than 2h in the future, meaning our clock is not just out of sync with UTC but has also jumped backwards since we accepted the starting block. So I think the current approach in the PR is consistent with the rest of the node.

  8. in src/headerssync.cpp:45 in db9c704117 outdated
      42 | -                                       + MAX_FUTURE_BLOCK_TIME};
      43 | +    const NodeClock::time_point now{NodeClock::now()};
      44 | +    const int64_t max_seconds_since_start{Ticks<std::chrono::seconds>(now - NodeSeconds{std::chrono::seconds{chain_start.GetMedianTimePast()}})
      45 | +                                          + MAX_FUTURE_BLOCK_TIME};
      46 | +    if (max_seconds_since_start < 0) {
      47 | +        throw SystemClockError{strprintf(
    


    l0rinc commented at 12:31 PM on June 6, 2026:

    hodlinator commented at 8:57 AM on June 16, 2026:

    Yes, I reference the first one in the PR description:

    Typically, the node will detect that the system clock is set too far in the past when comparing it to the chain tip during chain state loading and shut down before we start syncing headers. So in practice this is very unlikely to make a difference (might be possible if the system clock jumps backwards after we loaded the chain state).

    So we shouldn't be the first ones to detect and fail during startup. But if we do detect it in HeadersSyncState(), we should not continue, and it doesn't make sense to keep running the node if our local tip and local clock are in disagreement.

  9. in src/test/fuzz/headerssync.cpp:1 in 2cb8aa05be outdated


    l0rinc commented at 12:46 PM on June 6, 2026:

    The PR description should not imply that the time has to be off compared to genesis, the actual condition is relative to the known block, so it can be a recent local tip/header, not genesis. Timezone being off likely does not change local UTC, but if the actual system clock jumps backward, the condition can happen if the headers connect to a known block whose MTP is more than 2 hours ahead of the wrong local time.


    hodlinator commented at 8:54 AM on June 16, 2026:

    The PR description should not imply that the time has to be off compared to genesis, the actual condition is relative to the known block, so it can be a recent local tip/header, not genesis.

    I don't follow. Do you take "before we start syncing headers" to imply syncing from genesis?

    Timezone being off likely does not change local UTC, but if the actual system clock jumps backward, the condition can happen if the headers connect to a known block whose MTP is more than 2 hours ahead of the wrong local time.

    PR description doesn't mention timezone? This PR is about the base of what headers connect to. It would be easier to understand if you suggested changes to specific sentences.


    l0rinc commented at 10:37 AM on June 16, 2026:

    There was a misunderstanding when I reported this about having to be off by years (i.e. pre-genesis time) for this to be dangerous - but it can probably suffice to be off by a few hours.


    hodlinator commented at 12:31 PM on June 16, 2026:

    (Realized I did mention the UTC timezone in #35351 (comment), but that's not the PR description).

    Let me know if there's a specific part of the PR description you think should be changed.


    l0rinc commented at 6:27 PM on August 8, 2026:

    8d17175 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    The unit test only covers ComputeMaxCommitments() directly, so it does not check whether TryLowWorkHeadersSync() wires the error to fatal shutdown.

    Could we extend p2p_headers_sync_with_minchainwork.py as a preceding characterization test and update its expectation in the fix?

    <details><summary>add headerssync characterization</summary>

    diff --git a/test/functional/p2p_headers_sync_with_minchainwork.py b/test/functional/p2p_headers_sync_with_minchainwork.py
    index 1dc38faadb..44ed428bd8 100755
    --- a/test/functional/p2p_headers_sync_with_minchainwork.py
    +++ b/test/functional/p2p_headers_sync_with_minchainwork.py
    @@ -15,12 +15,14 @@ from test_framework.messages import (
     )
     
     from test_framework.blocktools import (
    +    MAX_FUTURE_BLOCK_TIME,
         NORMAL_GBT_REQUEST_PARAMS,
         create_block,
     )
     
     from test_framework.util import assert_equal
     
    +import re
     import time
     
     NODE1_BLOCKS_REQUIRED = 15
    @@ -144,6 +146,13 @@ class RejectLowDifficultyHeadersTest(BitcoinTestFramework):
             # getpeerinfo should show a sync in progress
             assert_equal(node.getpeerinfo()[0]['presynced_headers'], 2000)
     
    +        self.log.info("Test that a lagging clock aborts low-work headers sync")
    +        node.disconnect_p2ps()
    +        node.setmocktime(node.getblockheader(node.getblockhash(0))['mediantime'] - MAX_FUTURE_BLOCK_TIME - 1)
    +        p2p = node.add_p2p_connection(P2PInterface())
    +        p2p.send_without_ping(headers_message)
    +        node.wait_until_stopped(expect_error=True, expected_stderr=re.compile("Failure when attempting.*"))
    +
         def test_large_reorgs_can_succeed(self):
             self.log.info("Test that a 2000+ block reorg, starting from a point that is more than 2000 blocks before a locator entry, can succeed")
    

    </details>


    hodlinator commented at 9:28 AM on August 10, 2026:

    Good point. Thanks for the test! Let me know what you think about the initial variant before the fix.

  10. in src/headerssync.h:106 in db9c704117 outdated
      98 | @@ -99,8 +99,13 @@ struct CompressedHeader {
      99 |   * sync (temporary, per-peer storage).
     100 |   */
     101 |  
     102 | -class HeadersSyncState {
     103 | +class HeadersSyncState
     104 | +{
     105 |  public:
     106 | +    struct SystemClockError : std::runtime_error {
     107 | +        using std::runtime_error::runtime_error;
    


    l0rinc commented at 12:52 PM on June 6, 2026:

    db9c704 net: Throw exception from HeadersSyncState when system clock is behind start block MTP:

    diff --git a/src/headerssync.h b/src/headerssync.h
    --- a/src/headerssync.h	(revision 39798b46d08ff642ed59b84a6eb2ee911c070c94)
    +++ b/src/headerssync.h	(revision 1675b824feaf899e16c66ccff74c8d65bc3bf182)
    @@ -15,6 +15,7 @@
     #include <util/hasher.h>
     
     #include <deque>
    +#include <stdexcept>
     #include <vector>
     
     // A compressed CBlockHeader, which leaves out the prevhash
    

    Not important if my other suggestions are taken.

  11. in src/test/fuzz/headerssync.cpp:73 in 2cb8aa05be
      69 | @@ -70,11 +70,18 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
      70 |          .redownload_buffer_size = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, Params().HeadersSync().redownload_buffer_size * 2),
      71 |      };
      72 |      arith_uint256 min_work{UintToArith256(ConsumeUInt256(fuzzed_data_provider))};
      73 | -    FuzzedHeadersSyncState headers_sync(
      74 | -        params,
      75 | -        /*commit_offset=*/fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, params.commitment_period - 1),
      76 | -        /*chain_start=*/start_index,
      77 | -        /*minimum_required_work=*/min_work);
      78 | +    std::unique_ptr<FuzzedHeadersSyncState> headers_sync;
    


    l0rinc commented at 12:59 PM on June 6, 2026:

    The target only needs to handle a constructor path that may throw (not sure why we'd want to do an extra heap allocation here). An std::optional seems like a better fit for signalling that:

    diff --git a/src/test/fuzz/headerssync.cpp b/src/test/fuzz/headerssync.cpp
    --- a/src/test/fuzz/headerssync.cpp	(revision 09a5ecc777aa6490e094a6283a4f8d3ae021989d)
    +++ b/src/test/fuzz/headerssync.cpp	(revision 1d58a9da6ac133f4f18fd8b11814d73bbb019215)
    @@ -72,9 +72,9 @@
             .redownload_buffer_size = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, Params().HeadersSync().redownload_buffer_size * 2),
         };
         arith_uint256 min_work{UintToArith256(ConsumeUInt256(fuzzed_data_provider))};
    -    std::unique_ptr<FuzzedHeadersSyncState> headers_sync;
    +    std::optional<FuzzedHeadersSyncState> headers_sync;
         try {
    -        headers_sync = std::make_unique<FuzzedHeadersSyncState>(
    +        headers_sync.emplace(
                 params,
                 /*commit_offset=*/fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, params.commitment_period - 1),
                 /*chain_start=*/start_index,
    

    But we likely shouldn't need this new try/catch in the first place, we should rather do the validation outside and pass in a valid time instead.


    hodlinator commented at 9:23 AM on June 16, 2026:

    Taken, thanks!

  12. in src/test/headers_sync_chainwork_tests.cpp:260 in 2cb8aa05be outdated
     252 | @@ -252,4 +253,10 @@ BOOST_AUTO_TEST_CASE(too_little_work)
     253 |          /*exp_locator_hash=*/std::nullopt);
     254 |  }
     255 |  
     256 | +BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
     257 | +{
     258 | +    const NodeClockContext clock_ctx{std::chrono::seconds{genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME - 1}};
     259 | +    BOOST_CHECK_THROW(CreateState(), HeadersSyncState::SystemClockError);
     260 | +}
    


    l0rinc commented at 1:58 PM on June 6, 2026:

    Could the regression coverage pin the exact MAX_FUTURE_BLOCK_TIME boundary?

    BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
    {
        const test_only_CheckFailuresAreExceptionsNotAborts mock_checks{};
        NodeClockContext clock_ctx{(genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME) * 1s};
        BOOST_CHECK_NO_THROW(CreateState());
    
        clock_ctx -= 1s;
        BOOST_CHECK_EXCEPTION(CreateState(), NonFatalCheckError, HasReason{"Internal bug detected: max_seconds_since_start >= 0"});
    }
    

    hodlinator commented at 9:41 AM on June 16, 2026:

    Taken, thanks!

  13. in src/net_processing.cpp:2795 in 2cb8aa05be outdated
    2788 | @@ -2789,8 +2789,17 @@ bool PeerManagerImpl::TryLowWorkHeadersSync(Peer& peer, CNode& pfrom, const CBlo
    2789 |              // of headers is known, some header in this set must be new, so
    2790 |              // advancing to the first unknown header would be a small effect.
    2791 |              LOCK(peer.m_headers_sync_mutex);
    2792 | -            peer.m_headers_sync.reset(new HeadersSyncState(peer.m_id, m_chainparams.GetConsensus(),
    2793 | -                m_chainparams.HeadersSync(), chain_start_header, minimum_chain_work));
    2794 | +            try {
    2795 | +                peer.m_headers_sync.reset(new HeadersSyncState(peer.m_id, m_chainparams.GetConsensus(),
    2796 | +                    m_chainparams.HeadersSync(), chain_start_header, minimum_chain_work));
    2797 | +            } catch (const HeadersSyncState::SystemClockError& e) {
    


    l0rinc commented at 2:04 PM on June 6, 2026:

    We're using the exception here for control flow.

    Could the local clock value be captured and validated before HeadersSyncState so it no longer needs a recoverable exception type? We could also notify through fatalError() when the local clock is too far behind (passing the same now value into HeadersSyncState).

    This way we could do the validation before the call in PeerManagerImpl::TryLowWorkHeadersSync:

    const auto now{Now<NodeSeconds>()};
    if (now < NodeSeconds{(chain_start_header.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME) * 1s}) {
        m_chainman.GetNotifications().fatalError(Untranslated("System clock too far behind chain start MTP."));
        headers = {};
        return true;
    }
    LOCK(peer.m_headers_sync_mutex);
    peer.m_headers_sync.reset(new HeadersSyncState{peer.m_id, m_chainparams.GetConsensus(), m_chainparams.HeadersSync(), chain_start_header, minimum_chain_work, now});
    
    

    and HeadersSyncState::HeadersSyncState could remain just:

    const int64_t max_seconds_since_start{Ticks<std::chrono::seconds>(now - NodeSeconds{chain_start.GetMedianTimePast() * 1s}) + MAX_FUTURE_BLOCK_TIME};
    Assert(max_seconds_since_start >= 0);
    m_max_commitments = 6 * max_seconds_since_start / m_params.commitment_period;
    

    See https://github.com/l0rinc/bitcoin/pull/186/changes#diff-6875de769e90cec84d2e8a9c1b962cdbcda44d870d42e4215827e599e11e90e3R2792-R2797 for the whole implementation


    hodlinator commented at 9:20 AM on June 16, 2026:

    We're using the exception here for control flow.

    We're throwing an exception for this exceptional condition and catching it and aborting. This allows unit tests to work. You find it distasteful because the catch happens directly in the calling function? Relying on g_detail_test_only_CheckFailuresAreExceptionsNotAborts is kind of neat, didn't know of that. But would prefer something like TestOnlyAssertError being thrown instead of reusing NonFatalCheckError - is there some reason we don't want a separate error type?

    const auto now{Now<NodeSeconds>()}; if (now < NodeSeconds{(chain_start_header.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME) * 1s}) { m_chainman.GetNotifications().fatalError(Untranslated("System clock too far behind chain start MTP.")); headers = {}; return true; }

    This allows the process to continue, why would we want to do that once the local tip and clock are out of sync? Seems like we would want the node operator to intervene and fix the clock.

    Could the local clock value be captured and validated before HeadersSyncState

    That is an interesting question. How about instead of passing in now, we pass in uint64_t max_commitments to HeadersSyncState()? By only taking unsigned we push the caller to pass in a sane value (even though the could technically send in ~0ULL). It would also make the new unit test fairly redundant.


    l0rinc commented at 10:40 AM on June 16, 2026:

    You find it distasteful because the catch happens directly in the calling function?

    I dislike constructors throwing, especially when we have the option to prevalidate.

    is there some reason we don't want a separate error type

    I don't see why, I would prefer a simple solution that is easy to cherry-pick back to old branches.

    Seems like we would want the node operator to intervene and fix the clock

    isn't that what fatalError does?

    How about instead of passing in now, we pass in uint64_t max_commitments to HeadersSyncState()?

    Not sure, the side-effectful part is the time request not the commitment calculation. If we want to make the method pure/predictable we should eliminate the side effects, hence my suggestion.


    hodlinator commented at 12:40 PM on June 16, 2026:

    I dislike constructors throwing, especially when we have the option to prevalidate.

    Yeah, I wanted to make it testable and wasn't aware of g_detail_test_only_CheckFailuresAreExceptionsNotAborts. However, I prefer LogError(<user-friendly message>) + std::abort() over Assert(<condition>).

    I experimented with prevalidation now in https://github.com/bitcoin/bitcoin/compare/master...hodlinator:bitcoin:pr/35208_alt.extract_compute

    • Extracts the max commitments computation so we don't need to error inside the constructor.
    • Doesn't add the system_clock_lagging_behind_chain_start unit test nor increase the range of the start time for the fuzz test. It could probably be covered with a functional test that rewinds the clock to trigger the error in the context of net_processing, if you prefer this direction.

    Seems like we would want the node operator to intervene and fix the clock

    isn't that what fatalError does?

    Yikes, KernelNotifications::fatalError() triggers shutdown if m_shutdown_on_fatal_error is set. That's a bit more than I was expecting from a notification. :)

    How about instead of passing in now, we pass in uint64_t max_commitments to HeadersSyncState()?

    Not sure, the side-effectful part is the time request not the commitment calculation. If we want to make the method pure/predictable we should eliminate the side effects, hence my suggestion.

    Yes, pure functions are great but my primary intent would be to externalize the error handling.


    l0rinc commented at 8:20 AM on June 17, 2026:

    my primary intent would be to externalize the error handling

    Wouldn't my suggested time extraction and validation and fatalError do that? I won't block the max commitments computation extraction but seems like a workaround to me. I can be convinced otherwise.


    hodlinator commented at 9:16 AM on June 30, 2026:

    [Aa]ssert() should be used to validate logical pre-/post-conditions. The system clock disagreeing with the stored block tip timestamp is a borderline case, but I think it's fair to argue that it should not be treated with an assertion here, but rather as a marginally more legitimate edge-case to handle. Your change duplicates part of the calculation in net_processing.cpp which my externalization of the calculation in (pr/35208_alt.extract_compute) avoids, and you add an outer error handling case in net_processing which in practice makes us never reach the assertion you add.

    I think using std::abort() after having emitted the error is clean way to handle it without having to reset the headers-vector and consider other processing happening afterwards until the process exits. Is there any other work the node is doing in parallel with headers sync such as writing to disk in a way where it would be exceedingly dangerous to interrupt it and safer to use the fatal error notification flow?

    I won't block the max commitments computation extraction but seems like a workaround to me.

    Having HeadersSyncState() receive an unsigned max_commitments variable that is calculated externally helps ensure it's correct by construction, which I think is a good property.


    If you agree that https://github.com/bitcoin/bitcoin/compare/master...hodlinator:bitcoin:pr/35208_alt.extract_compute is an acceptable direction, I will explore creating a functional test to try to provoke the std::abort() edge-case.


    Edit: Ended up with keeping a unit test in src/test/headers_sync_chainwork_tests.cpp as of 8b680b2f7ccbec1e334e49fc17b20d3b647e5a51

  14. l0rinc changes_requested
  15. l0rinc commented at 6:31 PM on June 6, 2026: contributor

    I'm not sure this is going in the right direction, seems weird to discover time drift in the constructor and catch on use site - instead of sanitizing the value before call. Please see https://github.com/l0rinc/bitcoin/pull/186/commits for how I imagined simplifying this a lot more.

  16. DrahtBot added the label Needs rebase on Jun 9, 2026
  17. hodlinator force-pushed on Jun 16, 2026
  18. hodlinator commented at 9:48 AM on June 16, 2026: contributor

    Thanks for your review & suggestions @l0rinc! Pushed the straightforward ones for now.

    I'm not sure this is going in the right direction, seems weird to discover time drift in the constructor and catch on use site - instead of sanitizing the value before call.

    Curious what you think about sending unsigned max_commitments into HeadersSyncState() (see inline comment).

  19. hodlinator force-pushed on Jun 16, 2026
  20. hodlinator commented at 10:13 AM on June 16, 2026: contributor

    (Rebased against master in latest push to resolve conflicts).

  21. DrahtBot removed the label Needs rebase on Jun 16, 2026
  22. l0rinc changes_requested
  23. hodlinator force-pushed on Jul 3, 2026
  24. hodlinator force-pushed on Jul 3, 2026
  25. DrahtBot added the label CI failed on Jul 3, 2026
  26. DrahtBot removed the label CI failed on Jul 3, 2026
  27. in src/headerssync.cpp:19 in c475892e36
      15 | @@ -14,11 +16,27 @@
      16 |  // CompressedHeader (we should re-calculate parameters if we compress further).
      17 |  static_assert(sizeof(CompressedHeader) == 48);
      18 |  
      19 | +util::Expected<uint64_t, std::string> HeadersSyncState::ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start)
    


    l0rinc commented at 1:06 AM on July 4, 2026:

    c475892 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    The division by commitment_period now happens in ComputeMaxCommitments(), before the HeadersSyncState constructor can assert the period is nonzero.

    Could we keep the HeadersSyncParams invariant at the new computation boundary and assert a nonzero commitment period before the division?

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    --- a/src/headerssync.cpp	(revision d8f97dc90ee3a9179a9b2c10643109f7903cf0dd)
    +++ b/src/headerssync.cpp	(revision fdc1e179ec0cf0939483261f3339d4827e19c117)
    @@ -18,6 +18,8 @@
     
     util::Expected<uint64_t, std::string> HeadersSyncState::ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start)
     {
    +    Assert(params.commitment_period > 0);
    +
         // Estimate the number of blocks that could possibly exist on the peer's
         // chain *right now* using 6 blocks/second (fastest blockrate given the MTP
         // rule) times the number of seconds from the last allowed block until
    

    hodlinator commented at 9:22 AM on July 6, 2026:

    Taken.

  28. in src/net_processing.cpp:2822 in c475892e36
    2817 | @@ -2817,9 +2818,12 @@ bool PeerManagerImpl::TryLowWorkHeadersSync(Peer& peer, CNode& pfrom, const CBlo
    2818 |              // this logic in that case. So even if the first header in this set
    2819 |              // of headers is known, some header in this set must be new, so
    2820 |              // advancing to the first unknown header would be a small effect.
    2821 | +
    2822 | +            const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(m_chainparams.HeadersSync(), chain_start_header)};
    


    l0rinc commented at 1:08 AM on July 4, 2026:

    c475892 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    ComputeMaxCommitments() still calls NodeClock::now(), so the boundary depends on global mock time in tests and fuzzing. Could we pass the sampled node time in from TryLowWorkHeadersSync() instead? That would avoid using a FakeNodeClock and multiple side-effectful NodeClock::now() calls in the tests.

    <details><summary>Details</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 87683a9ae5..9b1178f7e8 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -16,7 +16,7 @@
     // CompressedHeader (we should re-calculate parameters if we compress further).
     static_assert(sizeof(CompressedHeader) == 48);
     
    -util::Expected<uint64_t, std::string> HeadersSyncState::ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start)
    +util::Expected<uint64_t, std::string> HeadersSyncState::ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start, NodeSeconds now)
     {
         Assert(params.commitment_period > 0);
     
    @@ -28,7 +28,6 @@ util::Expected<uint64_t, std::string> HeadersSyncState::ComputeMaxCommitments(co
         // exceeds this bound, because it's not possible for a consensus-valid
         // chain to be longer than this (at the current time -- in the future we
         // could try again, if necessary, to sync a longer chain).
    -    const NodeClock::time_point now{NodeClock::now()};
         const int64_t max_seconds_since_start{Ticks<std::chrono::seconds>(now - NodeSeconds{std::chrono::seconds{chain_start.GetMedianTimePast()}})
                                               + MAX_FUTURE_BLOCK_TIME};
         if (max_seconds_since_start < 0) {
    diff --git a/src/headerssync.h b/src/headerssync.h
    index 138b41c3ab..e0cd1237af 100644
    --- a/src/headerssync.h
    +++ b/src/headerssync.h
    @@ -14,6 +14,7 @@
     #include <util/bitdeque.h>
     #include <util/expected.h>
     #include <util/hasher.h>
    +#include <util/time.h>
     
     #include <deque>
     #include <vector>
    @@ -129,7 +130,7 @@ public:
         /** Return the amount of work in the chain received during the PRESYNC phase. */
         arith_uint256 GetPresyncWork() const { return m_current_chain_work; }
     
    -    static util::Expected<uint64_t, std::string> ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start);
    +    static util::Expected<uint64_t, std::string> ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start, NodeSeconds now);
     
         /** Construct a HeadersSyncState object representing a headers sync via this
          *  download-twice mechanism).
    diff --git a/src/net_processing.cpp b/src/net_processing.cpp
    index 24272a2a60..9b50e4c3ab 100644
    --- a/src/net_processing.cpp
    +++ b/src/net_processing.cpp
    @@ -2819,7 +2819,7 @@ bool PeerManagerImpl::TryLowWorkHeadersSync(Peer& peer, CNode& pfrom, const CBlo
                 // of headers is known, some header in this set must be new, so
                 // advancing to the first unknown header would be a small effect.
     
    -            const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(m_chainparams.HeadersSync(), chain_start_header)};
    +            const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(m_chainparams.HeadersSync(), chain_start_header, Now<NodeSeconds>())};
                 if (!max_commitments) {
                     m_chainman.GetNotifications().fatalError(Untranslated(max_commitments.error()));
                     headers = {};
    diff --git a/src/test/fuzz/headerssync.cpp b/src/test/fuzz/headerssync.cpp
    index fcbd92492e..e0eacbd135 100644
    --- a/src/test/fuzz/headerssync.cpp
    +++ b/src/test/fuzz/headerssync.cpp
    @@ -9,7 +9,6 @@
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
     #include <test/util/setup_common.h>
    -#include <test/util/time.h>
     #include <uint256.h>
     #include <util/chaintype.h>
     #include <util/expected.h>
    @@ -63,7 +62,7 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
         CBlockHeader genesis_header{Params().GenesisBlock()};
         CBlockIndex start_index(genesis_header);
     
    -    FakeNodeClock clock{ConsumeTime(fuzzed_data_provider, /*min=*/start_index.GetMedianTimePast() - 2 * MAX_FUTURE_BLOCK_TIME)};
    +    const NodeSeconds now{ConsumeTime(fuzzed_data_provider, /*min=*/start_index.GetMedianTimePast() - 2 * MAX_FUTURE_BLOCK_TIME)};
     
         const uint256 genesis_hash = genesis_header.GetHash();
         start_index.phashBlock = &genesis_hash;
    @@ -72,9 +71,9 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
             .commitment_period = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(1, Params().HeadersSync().commitment_period * 2),
             .redownload_buffer_size = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, Params().HeadersSync().redownload_buffer_size * 2),
         };
    -    const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, start_index)};
    +    const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, start_index, now)};
         if (!max_commitments) {
    -        assert(NodeClock::now() < NodeSeconds{std::chrono::seconds{start_index.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME}});
    +        assert(now < NodeSeconds{std::chrono::seconds{start_index.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME}});
             return;
         }
         arith_uint256 min_work{UintToArith256(ConsumeUInt256(fuzzed_data_provider))};
    diff --git a/src/test/headers_sync_chainwork_tests.cpp b/src/test/headers_sync_chainwork_tests.cpp
    index f88e27794f..f181291523 100644
    --- a/src/test/headers_sync_chainwork_tests.cpp
    +++ b/src/test/headers_sync_chainwork_tests.cpp
    @@ -10,8 +10,8 @@
     #include <pow.h>
     #include <test/util/common.h>
     #include <test/util/setup_common.h>
    -#include <test/util/time.h>
     #include <util/expected.h>
    +#include <util/time.h>
     #include <validation.h>
     
     #include <cstddef>
    @@ -81,13 +81,13 @@ struct HeadersGeneratorSetup : public RegTestingSetup {
             return second_chain;
         }
     
    -    util::Expected<HeadersSyncState, std::string> CreateState()
    +    util::Expected<HeadersSyncState, std::string> CreateState(NodeSeconds now = Now<NodeSeconds>())
         {
             const HeadersSyncParams params{
                 .commitment_period = COMMITMENT_PERIOD,
                 .redownload_buffer_size = REDOWNLOAD_BUFFER_SIZE,
             };
    -        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, chain_start)};
    +        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, chain_start, now)};
             if (!max_commitments) return util::Unexpected{std::move(max_commitments.error())};
     
             return HeadersSyncState{/*id=*/0,
    @@ -261,10 +261,9 @@ BOOST_AUTO_TEST_CASE(too_little_work)
     
     BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
     {
    -    FakeNodeClock clock{(genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME) * 1s};
    -    BOOST_CHECK(CreateState());
    -    clock -= 1s;
    -    BOOST_CHECK(!CreateState());
    +    const NodeSeconds boundary{(genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME) * 1s};
    +    BOOST_CHECK(CreateState(boundary));
    +    BOOST_CHECK(!CreateState(boundary - 1s));
     }
     
     BOOST_AUTO_TEST_SUITE_END()
    

    </details>


    hodlinator commented at 9:12 AM on July 6, 2026:

    Doing this makes the code a bit more verbose but also reduces side-effects so okay.

    I prefer that the commit introducing ComputeMaxCommitments() only moves the code out of the constructor without modifying the computation, so inserted the now argument in the second commit.

    Left the FakeNodeClock in the fuzz test in case removing it would have other effects.

  29. in src/headerssync.h:132 in c475892e36
     128 | @@ -128,6 +129,8 @@ class HeadersSyncState {
     129 |      /** Return the amount of work in the chain received during the PRESYNC phase. */
     130 |      arith_uint256 GetPresyncWork() const { return m_current_chain_work; }
     131 |  
     132 | +    static util::Expected<uint64_t, std::string> ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start);
    


    l0rinc commented at 1:26 AM on July 4, 2026:

    c475892 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    Could we document the ComputeMaxCommitments() failure case and the max_commitments constructor input?

        /** Compute the memory bound on presync commitments, or return an error if
         *  the chain-start MTP is too far ahead of the current node time. */
        static util::Expected<uint64_t, std::string> ComputeMaxCommitments(const HeadersSyncParams& params, const CBlockIndex& chain_start, NodeSeconds now);
    

    hodlinator commented at 9:10 AM on July 6, 2026:

    Taken, but modified the ending to "ahead of the local system time."

  30. l0rinc approved
  31. l0rinc commented at 1:26 AM on July 4, 2026: contributor

    lgtm, please see my remaining suggestion to reduce side-effects and simplify testing

  32. hodlinator force-pushed on Jul 6, 2026
  33. hodlinator commented at 9:29 AM on July 6, 2026: contributor

    (Latest few pushes after 1c3ad92d0bdcc66f252f647deb58f8becfce2772 attempt to reconcile differing viewpoints which me & @l0rinc partially discussed out-of-band last week).

  34. l0rinc commented at 3:42 PM on July 6, 2026: contributor

    ACK 8b680b2f7ccbec1e334e49fc17b20d3b647e5a51 @optout21, @vasild, @dergoegge, this is an alternative to the original acked PR, your re-review here would be welcome.

  35. dergoegge commented at 9:06 AM on July 14, 2026: member

    Concept ACK

    I thought the original PR was also fine. Code here also looks fine, but I won't prioritize giving this a full review.

  36. sedited requested review from mzumsande on Aug 6, 2026
  37. DrahtBot added the label Needs rebase on Aug 6, 2026
  38. l0rinc commented at 7:17 PM on August 6, 2026: contributor

    @hodlinator, can you please rebase the change?

  39. hodlinator force-pushed on Aug 7, 2026
  40. DrahtBot removed the label Needs rebase on Aug 7, 2026
  41. in src/test/fuzz/headerssync.cpp:78 in 70ec860ec0
      72 | @@ -72,7 +73,11 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
      73 |          .commitment_period = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(1, Params().HeadersSync().commitment_period * 2),
      74 |          .redownload_buffer_size = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, Params().HeadersSync().redownload_buffer_size * 2),
      75 |      };
      76 | -    const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, start_index)};
      77 | +    const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, start_index, now)};
      78 | +    if (!max_commitments) {
      79 | +        assert(now < NodeSeconds{std::chrono::seconds{start_index.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME}});
    


    l0rinc commented at 6:10 PM on August 8, 2026:

    70ec860 p2p: Shut down if system clock is out of sync with local tip:

    On a second thought, not sure special-casing makes sense here since this is already unit tested - we can probably revert the fuzz additions.

    <details><summary>keep headerssync fuzz states valid</summary>

    diff --git a/src/test/fuzz/headerssync.cpp b/src/test/fuzz/headerssync.cpp
    index 4c6c5ab1c1..3182354563 100644
    --- a/src/test/fuzz/headerssync.cpp
    +++ b/src/test/fuzz/headerssync.cpp
    @@ -9,7 +9,6 @@
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
     #include <test/util/setup_common.h>
    -#include <test/util/time.h>
     #include <uint256.h>
     #include <util/chaintype.h>
     #include <util/expected.h>
    @@ -63,8 +62,7 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
         CBlockHeader genesis_header{Params().GenesisBlock()};
         CBlockIndex start_index(genesis_header);
     
    -    const NodeSeconds now{ConsumeTime(fuzzed_data_provider, /*min=*/start_index.GetMedianTimePast() - 2 * MAX_FUTURE_BLOCK_TIME)};
    -    FakeNodeClock clock{now};
    +    const NodeSeconds now{ConsumeTime(fuzzed_data_provider, /*min=*/start_index.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME)};
     
         const uint256 genesis_hash = genesis_header.GetHash();
         start_index.phashBlock = &genesis_hash;
    @@ -74,10 +72,7 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
             .redownload_buffer_size = fuzzed_data_provider.ConsumeIntegralInRange<size_t>(0, Params().HeadersSync().redownload_buffer_size * 2),
         };
         const util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, start_index, now)};
    -    if (!max_commitments) {
    -        assert(now < NodeSeconds{std::chrono::seconds{start_index.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME}});
    -        return;
    -    }
    +    assert(max_commitments);
         arith_uint256 min_work{UintToArith256(ConsumeUInt256(fuzzed_data_provider))};
         FuzzedHeadersSyncState headers_sync(
             params,
    

    </details>


    hodlinator commented at 9:26 AM on August 10, 2026:

    Thanks, taken!

    I considered keeping FakeNodeClock in order to adjust the mock time, but the only code we are testing which uses time appears to be ComputeMaxCommitments(), so skipped that.

  42. in src/test/headers_sync_chainwork_tests.cpp:266 in 70ec860ec0
     269 | -        /*exp_success=*/true, /*exp_request_more=*/true,
     270 | -        /*exp_headers_size=*/0, /*exp_pow_validated_prev=*/std::nullopt,
     271 | -        /*exp_locator_hash=*/FirstChain().front().GetHash());
     272 | +    const NodeSeconds boundary{(genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME) * 1s};
     273 | +    BOOST_CHECK(CreateState(boundary));
     274 | +    BOOST_CHECK(!CreateState(boundary - 1s));
    


    l0rinc commented at 6:12 PM on August 8, 2026:

    70ec860 p2p: Shut down if system clock is out of sync with local tip:

    It should be sufficient to test HeadersSyncState::ComputeMaxCommitments here (which would allow reverting the CreateState signature change):

    <details><summary>simplify headerssync test setup</summary>

    diff --git a/src/test/headers_sync_chainwork_tests.cpp b/src/test/headers_sync_chainwork_tests.cpp
    index f181291523..652ca4c276 100644
    --- a/src/test/headers_sync_chainwork_tests.cpp
    +++ b/src/test/headers_sync_chainwork_tests.cpp
    @@ -10,7 +10,7 @@
     #include <pow.h>
     #include <test/util/common.h>
     #include <test/util/setup_common.h>
    -#include <util/expected.h>
    +#include <util/check.h>
     #include <util/time.h>
     #include <validation.h>
     
    @@ -52,6 +52,10 @@ constexpr arith_uint256 CHAIN_WORK{TARGET_BLOCKS * 2};
     // required to reach the CHAIN_WORK threshold, to behave similarly to mainnet.
     constexpr size_t REDOWNLOAD_BUFFER_SIZE{TARGET_BLOCKS - (MAX_HEADERS_RESULTS + 123)};
     constexpr size_t COMMITMENT_PERIOD{600}; // Somewhat close to mainnet.
    +constexpr HeadersSyncParams PARAMS{
    +    .commitment_period = COMMITMENT_PERIOD,
    +    .redownload_buffer_size = REDOWNLOAD_BUFFER_SIZE,
    +};
     
     struct HeadersGeneratorSetup : public RegTestingSetup {
         const CBlock& genesis{Params().GenesisBlock()};
    @@ -81,21 +85,16 @@ struct HeadersGeneratorSetup : public RegTestingSetup {
             return second_chain;
         }
     
    -    util::Expected<HeadersSyncState, std::string> CreateState(NodeSeconds now = Now<NodeSeconds>())
    +    HeadersSyncState CreateState()
         {
    -        const HeadersSyncParams params{
    -            .commitment_period = COMMITMENT_PERIOD,
    -            .redownload_buffer_size = REDOWNLOAD_BUFFER_SIZE,
    -        };
    -        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(params, chain_start, now)};
    -        if (!max_commitments) return util::Unexpected{std::move(max_commitments.error())};
    +        const auto max_commitments{HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, Now<NodeSeconds>())};
     
             return HeadersSyncState{/*id=*/0,
                                     Params().GetConsensus(),
    -                                params,
    +                                PARAMS,
                                     chain_start,
                                     /*minimum_required_work=*/CHAIN_WORK,
    -                                /*max_commitments=*/*max_commitments};
    +                                /*max_commitments=*/*Assert(max_commitments)};
         }
     
     private:
    @@ -155,7 +154,7 @@ BOOST_AUTO_TEST_CASE(sneaky_redownload)
     
         // Feed the first chain to HeadersSyncState, by delivering 1 header
         // initially and then the rest.
    -    HeadersSyncState hss{*CreateState()};
    +    HeadersSyncState hss{CreateState()};
     
         // Just feed one header and check state.
         // Pretend the message is still "full", so we don't abort.
    @@ -194,7 +193,7 @@ BOOST_AUTO_TEST_CASE(happy_path)
         // Headers message that moves us to the next state doesn't need to be full.
         for (const bool full_headers_message : {false, true}) {
             // This time we feed the first chain twice.
    -        HeadersSyncState hss{*CreateState()};
    +        HeadersSyncState hss{CreateState()};
     
             // Sufficient work transitions us from PRESYNC to REDOWNLOAD:
             const auto genesis_hash{genesis.GetHash()};
    @@ -236,7 +235,7 @@ BOOST_AUTO_TEST_CASE(too_little_work)
     
         // Verify that just trying to process the second chain would not succeed
         // (too little work).
    -    HeadersSyncState hss{*CreateState()};
    +    HeadersSyncState hss{CreateState()};
         BOOST_REQUIRE_EQUAL(hss.GetState(), State::PRESYNC);
     
         // Pretend just the first message is "full", so we don't abort.
    @@ -261,9 +260,9 @@ BOOST_AUTO_TEST_CASE(too_little_work)
     
     BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
     {
    -    const NodeSeconds boundary{(genesis.GetBlockTime() - MAX_FUTURE_BLOCK_TIME) * 1s};
    -    BOOST_CHECK(CreateState(boundary));
    -    BOOST_CHECK(!CreateState(boundary - 1s));
    +    const NodeSeconds boundary{(chain_start.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME) * 1s};
    +    BOOST_CHECK( HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary));
    +    BOOST_CHECK(!HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary - 1s));
     }
     
     BOOST_AUTO_TEST_SUITE_END()
    

    </details>


    hodlinator commented at 9:27 AM on August 10, 2026:

    Taken!

  43. l0rinc approved
  44. l0rinc commented at 6:58 PM on August 8, 2026: contributor

    ACK 70ec860ec0b46bd302c1ab4fa88d31502007286c

    The approach looks correct: reject the invalid elapsed-time calculation before constructing HeadersSyncState, then trigger fatal shutdown.

    I left a few non-blocking suggestions and some prose is also stale:

    • The PR description still says the constructor throws an exception.
    • The first commit message says commitment_period=1, although the test uses COMMITMENT_PERIOD (600), and says the fix flips the test although it replaces it with direct boundary checks.
    • The final commit subject says "local tip", but the comparison uses the chain-start header MTP, which is not necessarily the tip. Its body could also briefly explain why this condition warrants shutdown.
  45. DrahtBot requested review from dergoegge on Aug 8, 2026
  46. test: cover future chain-start MTP boundary in headers presync
    When the local clock sits more than `MAX_FUTURE_BLOCK_TIME` behind the chain-start MTP, the elapsed value used to derive `HeadersSyncState::m_max_commitments` is negative.
    The later division in `HeadersSyncState()` by the unsigned commitment period converts that negative value to a large unsigned bound.
    This lets `HeadersSyncState` remain in `PRESYNC` after processing the first header, where where it should have already aborted.
    
    Pin that pre-fix behavior in regression tests that the subsequent fix will flip.
    
    Co-authored-by: Lőrinc <pap.lorinc@gmail.com>
    9793de61e0
  47. refactor(p2p): Extract the max commitments computation from HeadersSyncState()
    This allows us to add a computation error in the next commit without triggering it from inside the constructor.
    
    Co-authored-by: Lőrinc <pap.lorinc@gmail.com>
    62900263d5
  48. p2p: Shut down if system clock is out of sync with local tip
    Being more than 2 hours (MAX_FUTURE_BLOCK_TIME) behind the starting block's MTP will currently already cause `VerifyLoadedChainstate()` to abort initialization. Having a system clock which disagrees this badly with the most recently accepted and stored block should prompt the operator to investigate.
    
    In case we ever get past chain state loading before entering this situation and proceed to syncing headers, we now also shut down. The alternative of clamping max commitments to an arbitrary value would just paper over the issue.
    
    Co-authored-by: Lőrinc <pap.lorinc@gmail.com>
    0799f3ba61
  49. hodlinator force-pushed on Aug 10, 2026
  50. hodlinator commented at 9:34 AM on August 10, 2026: contributor

    Thanks for your latest review @l0rinc, incorporated all of it in the latest push, with one exception: You disagree that the chain start header is the same as the local tip - when would that not be the case?

    Beyond your feedback I also added/corrected some comments in net_processing.cpp in the last commit.

  51. in test/functional/p2p_headers_sync_with_minchainwork.py:153 in 0799f3ba61
     149 | @@ -150,9 +150,8 @@ def test_peerinfo_includes_headers_presync_height(self):
     150 |          node.disconnect_p2ps()
     151 |          node.setmocktime(node.getblockheader(node.getblockhash(0))['mediantime'] - MAX_FUTURE_BLOCK_TIME - 1)
     152 |          p2p = node.add_p2p_connection(P2PInterface())
     153 | -        with node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Failure when attempting to initiate headers sync: system clock"]):
     154 | -            p2p.send_without_ping(headers_message)
     155 | -            p2p.wait_for_getheaders(timeout=30, block_hash=hashPrevBlock)
     156 | +        p2p.send_without_ping(headers_message)
    


    l0rinc commented at 8:12 PM on August 10, 2026:

    0799f3b p2p: Shut down if system clock is out of sync with local tip:

    Sorry if my previous suggestion caused some churn, but the characterization test added quite a few lines that aren't needed in the final tree. Could we simplify it so that it leads the reviewer towards the final solution? Currently it's a bit misleading (which is why I usually add TODO comments in the characterization commit to reassure reviewers that the incorrect expectation will be fixed in a follow-up commit).

    If we characterize the behavior with something like:

            self.log.info("Test whether a lagging clock aborts low-work headers sync")
            node.disconnect_p2ps()
            node.setmocktime(node.getblockheader(node.getblockhash(0))['mediantime'] - MAX_FUTURE_BLOCK_TIME - 1)
            p2p = node.add_p2p_connection(P2PInterface())
            p2p.send_without_ping(headers_message)
            p2p.wait_for_getheaders(timeout=30, block_hash=hashPrevBlock)  # TODO: A negative elapsed interval should trigger fatal shutdown.
    

    the diff in the fix commit would only be

    -        p2p.wait_for_getheaders(timeout=30, block_hash=hashPrevBlock)  # TODO: A negative elapsed interval should trigger fatal shutdown.
    +        node.wait_until_stopped(expect_error=True, expected_stderr=re.compile("Failure when attempting to initiate headers sync: system clock"))
    
  52. in src/test/headers_sync_chainwork_tests.cpp:93 in 62900263d5
      96 | -                },
      97 | -                chain_start,
      98 | -                /*minimum_required_work=*/CHAIN_WORK};
      99 | +        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start)};
     100 | +
     101 | +        return HeadersSyncState{/*id=*/0,
    


    l0rinc commented at 9:40 PM on August 10, 2026:

    6290026 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    nit: the diff would be smaller if we didn't add the explicit HeadersSyncState back, now that we aren't changing the return type anymore

  53. in src/test/headers_sync_chainwork_tests.cpp:256 in 9793de61e0
     252 | @@ -252,4 +253,16 @@ BOOST_AUTO_TEST_CASE(too_little_work)
     253 |          /*exp_locator_hash=*/std::nullopt);
     254 |  }
     255 |  
     256 | +BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
    


    l0rinc commented at 9:47 PM on August 10, 2026:

    9793de6 test: cover future chain-start MTP boundary in headers presync:

    As hinted before, we're adding a test here that we're dropping completely in the fix commit, so reviewers can't easily tell what the extent of the fix is. If we extract ComputeMaxCommitments() in a separate commit, we could add a characterization test like:

    BOOST_AUTO_TEST_CASE(system_clock_lagging_behind_chain_start)
    {
        const NodeSeconds boundary{(chain_start.GetMedianTimePast() - MAX_FUTURE_BLOCK_TIME) * 1s};
        BOOST_CHECK( HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary));
        BOOST_CHECK( HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary - 1s)); // TODO: A negative elapsed interval should return an error.
    }
    

    which would need only a minimal adjustment in the risky fix commit:

    -    BOOST_CHECK( HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary - 1s)); // TODO: A negative elapsed interval should return an error.
    +    BOOST_CHECK(!HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, boundary - 1s));
    
  54. in src/test/headers_sync_chainwork_tests.cpp:56 in 62900263d5
      52 | @@ -51,6 +53,10 @@ constexpr arith_uint256 CHAIN_WORK{TARGET_BLOCKS * 2};
      53 |  // required to reach the CHAIN_WORK threshold, to behave similarly to mainnet.
      54 |  constexpr size_t REDOWNLOAD_BUFFER_SIZE{TARGET_BLOCKS - (MAX_HEADERS_RESULTS + 123)};
      55 |  constexpr size_t COMMITMENT_PERIOD{600}; // Somewhat close to mainnet.
      56 | +constexpr HeadersSyncParams PARAMS{
    


    l0rinc commented at 9:51 PM on August 10, 2026:

    6290026 refactor(p2p): Extract the max commitments computation from HeadersSyncState():

    We could split this out into the characterization commit to unburden this refactor.

  55. in src/test/headers_sync_chainwork_tests.cpp:91 in 0799f3ba61
      87 | @@ -88,7 +88,7 @@ struct HeadersGeneratorSetup : public RegTestingSetup {
      88 |  
      89 |      HeadersSyncState CreateState()
      90 |      {
      91 | -        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start)};
      92 | +        util::Expected max_commitments{HeadersSyncState::ComputeMaxCommitments(PARAMS, chain_start, Now<NodeSeconds>())};
    


    l0rinc commented at 9:53 PM on August 10, 2026:

    0799f3b p2p: Shut down if system clock is out of sync with local tip:

    I'd separate adding a new parameter (making the method pure) from changing the behavior, to make the risky part obvious and the refactors trivial.

  56. l0rinc approved
  57. l0rinc commented at 10:18 PM on August 10, 2026: contributor

    ACK 0799f3ba61c3db8a9eeab9ad521814554574ad41

    This correctly rejects a negative elapsed interval before it can become a large unsigned presync commitment cap, prevents constructing an invalid HeadersSyncState, and routes the unexpected clock condition through fatal shutdown. The unit test covers the exact boundary, while the functional test covers the P2P-to-shutdown path. The focused unit and functional tests pass.

    I left some suggestions to make the review easier by separating risky changes from pure refactors (which also help with making the characterization test changes minimal) - I applied these suggestions in https://github.com/l0rinc/bitcoin/pull/276/commits during local review. I'm also okay with merging this as is and happy to rereview if any of the suggestion are taken.

    You disagree that the chain start header is the same as the local tip - when would that not be the case?

    My understanding is that the relevant presync path receives a peer-supplied full headers message, then looks up chain_start_header from its first header. As far as I can tell it may be any known block (we only require valid PoW, continuity, and a connection to our block index), for example an older active-chain block or a stale-fork block. I’d call it the chain-start header rather than the local tip.


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-08-11 11:51 UTC

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