headersync: do parameter search at runtime #35642

pull sipa wants to merge 5 commits into bitcoin:master from sipa:202607_headerssync_cpp changing 13 files +656 −424
  1. sipa commented at 3:06 PM on July 2, 2026: member

    This migrates the headers presync parameters (commitment_period and redownload_buffer_size) from being precomputed chainparams to being computed at runtime whenever a headers sync starts. This has as advantages:

    • Less work for maintainers that need to occasionally run the current contrib/devtools/headerssync-params.py script.
    • Less room for mistakes in that process.
    • Automatically adapts based on time (as opposed to needing to predict the lifetime of the software).

    To achieve this, the parameter search algorithm is implemented in C++. It is simpler, because it does not attempt to model the fact that the attacker is forced to submit their headers in batches of 2000 if they want to maintain the attack. The old Python code did that, but actually got it (slightly) wrong by missing the fact that attackers are capable of strategically deciding to stop the attack in the middle of a batch (found by darosior).

    Unit tests and fuzz tests are added, and at the end the old Python code is deleted.

  2. DrahtBot commented at 3:07 PM on July 2, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    ACK l0rinc, fametrano
    Concept ACK sedited, fjahr, hodlinator
    Approach ACK ajtowns
    Stale ACK darosior

    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:

    • #36598 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36598.svg"></sub> ([Demo] validation: remove AssumeUTXO by l0rinc)
    • #36543 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36543.svg"></sub> (validation: check the most recent AssumeUTXO commitment during IBD 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. sipa force-pushed on Jul 2, 2026
  4. DrahtBot added the label CI failed on Jul 2, 2026
  5. sipa force-pushed on Jul 2, 2026
  6. in src/net_processing.cpp:2040 in cafcdc385a outdated
    2031 | @@ -2029,12 +2032,25 @@ std::unique_ptr<PeerManager> PeerManager::make(CConnman& connman, AddrMan& addrm
    2032 |      return std::make_unique<PeerManagerImpl>(connman, addrman, banman, chainman, pool, warnings, opts);
    2033 |  }
    2034 |  
    2035 | +//! Compute the headers sync DoS-protection parameters for the given chain, using its age (genesis
    2036 | +//! to now) and the height at which its minimum chain work was taken, and log the result.
    2037 | +static HeadersSyncParams MakeHeadersSyncParams(const CChainParams& chainparams)
    2038 | +{
    2039 | +    const auto genesis_time{NodeSeconds{std::chrono::seconds{chainparams.GenesisBlock().nTime}}};
    2040 | +    const auto timespan{std::chrono::duration_cast<std::chrono::seconds>(NodeClock::now() - genesis_time)};
    


    ajtowns commented at 3:51 PM on July 2, 2026:

    Is there any anonymity breaking via NodeClock::now() influencing these parameters? (I think maybe not, but might be worth quantizing or similar to guarantee it?)


    sipa commented at 4:18 PM on July 2, 2026:

    Good point, I had not considered that.

    The choice of the commitment period should not be observable (besides a very minor time leak perhaps). However, the redownload buffer is observable in that an attacker can probably disconnect mid-redownload, and see how much you accepted afterwards.

    I'll run some numbers to see how quickly these parameters change and suggest some quantization or noise.


    sipa commented at 5:00 PM on July 2, 2026:

    It looks like the output parameters only change roughly every 22 days. I don't think quantizing further gains us much, especially as the attack only applies during IBD, so peers generally already know you just started.

  7. sipa force-pushed on Jul 2, 2026
  8. sipa force-pushed on Jul 2, 2026
  9. DrahtBot commented at 2:33 PM on July 9, 2026: contributor

    Could turn into draft while CI is red?

  10. sipa force-pushed on Jul 9, 2026
  11. sipa force-pushed on Jul 9, 2026
  12. sipa force-pushed on Jul 9, 2026
  13. sipa force-pushed on Jul 9, 2026
  14. sipa force-pushed on Jul 9, 2026
  15. DrahtBot removed the label CI failed on Jul 9, 2026
  16. sipa force-pushed on Jul 10, 2026
  17. sipa force-pushed on Jul 10, 2026
  18. DrahtBot added the label CI failed on Jul 10, 2026
  19. DrahtBot removed the label CI failed on Jul 10, 2026
  20. sedited commented at 10:58 AM on July 24, 2026: contributor

    Concept ACK

  21. DrahtBot added the label Needs rebase on Jul 25, 2026
  22. ajtowns commented at 2:08 PM on August 6, 2026: contributor

    Approach ACK ; needs rebase if wanted for 32.0 I guess?

  23. darosior commented at 4:23 PM on August 6, 2026: member

    Concept ACK

  24. sipa force-pushed on Aug 6, 2026
  25. sipa commented at 5:46 PM on August 6, 2026: member

    Rebased.

  26. DrahtBot removed the label Needs rebase on Aug 6, 2026
  27. DrahtBot added the label CI failed on Aug 6, 2026
  28. DrahtBot removed the label CI failed on Aug 7, 2026
  29. shuv-amp commented at 4:11 PM on August 12, 2026: contributor

    next_height here is int, while m_current_height is int64_t and ValidateAndStoreRedownloadedHeader uses int64_t for the same step. For the long timewarp chains you mention, the presync height can exceed INT_MAX and overflow. Not a practical concern, but it should probably be int64_t to match the redownload path?

  30. sipa force-pushed on Aug 14, 2026
  31. sipa commented at 5:36 PM on August 14, 2026: member

    @shuv-amp That's mostly independent of this PR, but added a commit to address that. I have also made a small simplification in the search algorithm.

    Ready for more review.

  32. shuv-amp commented at 7:30 PM on August 14, 2026: contributor

    ComputeHeadersSyncParams() doesn't terminate for a small minchainwork_headers combined with a short timespan:

    ComputeHeadersSyncParams(std::chrono::seconds{0}, 30228);  // hangs
    ComputeHeadersSyncParams(std::chrono::seconds{0}, 30229);  // period=2 bufsize=40
    

    30229 is the boundary; below it the region extends out to timespans around 1e4 s.

    In attack_rate(), the left side of future_limit * prob < 1.0e-16 * rate can't be negative, and the right side is exactly 0 once rate is below ~2.5e-308, so that break stops being reachable. The limit check is the only other exit and Step 1's Newton call doesn't pass one, so there it has none at all.

    The Python this replaces has the same shape (HEADER_BATCH_COUNT * prob < 1.0e-16 * rate * len(align_choices)) and doesn't terminate at those arguments either, so it isn't new, it just matters more now that it runs at startup.

    Non-strict is enough, since prob decreases to exactly 0 and takes the left side with it:

    -    if (future_limit * prob < 1.0e-16 * rate) break;
    +    if (future_limit * prob <= 1.0e-16 * rate) break;
    

    The 50 test vectors still reproduce with that, and 196/196 other inputs I compared are unchanged.

    Nothing in chainparams is close: testnet4 has the lowest minchainwork_height at 123613, and reaches an exponent of 531 against the 1075 where prob underflows. It is the region a new chain starts in though, and 0 hangs a different way (attack_headers is 0, so period is NaN and accept_forged_headers > 0.0 never holds), so a guard on the input might be worth having too.

  33. sipa force-pushed on Aug 19, 2026
  34. sipa commented at 6:55 PM on August 19, 2026: member

    @shuv-amp Fixed, and added some extra Assume()s in the code.

  35. sipa force-pushed on Aug 20, 2026
  36. sipa commented at 2:53 PM on August 20, 2026: member

    I made a further simplification to the algorithm, and also added a fuzz test that exercises the code far beyond its intended parameter range, so catch meaningless results, crashes, or infinite loops. The fuzz tests are relatively slow (for extreme values it's just above 1/second, through normal running closer to 50/second), but it's also not a particularly large or complicated space to explore.

  37. fjahr commented at 9:19 AM on August 21, 2026: contributor

    Concept ACK

  38. sipa force-pushed on Aug 21, 2026
  39. DrahtBot added the label Needs rebase on Aug 31, 2026
  40. darosior commented at 9:15 PM on September 4, 2026: member

    The HeadersSyncParams definition can now be moved from chainparams to the headersync module.

  41. in src/headerssync.cpp:435 in e330ad91d7
     430 | +            for (int64_t batch = 0;; ++batch) {
     431 | +                /** The forged headers received so far, up to and including this batch. */
     432 | +                const int64_t forged_headers = (batch + 1) * HEADER_BATCH_COUNT - honest;
     433 | +                /** The number of forged headers that would be accepted if the commitments in this
     434 | +                 *  batch and all earlier batches verify correctly. */
     435 | +                const double accept_forged_headers = std::clamp<double>(forged_headers - bufsize, 0.0, HEADER_BATCH_COUNT);
    


    darosior commented at 3:16 PM on September 9, 2026:

    nit: forged_headers counts the number of headers deviating from the honest chain up to that point (i.e. that batch and previous ones), whereas accept_forged_headers counts the additional number of headers accepted as the result of that batch. Took me a minute, and i think this could be clarified in the comment:

                    /** The forged headers received so far, up to and including this batch. */
                    const int64_t forged_headers = (batch + 1) * HEADER_BATCH_COUNT - honest;
                    /** The number of forged headers that would be accepted as a result of this batch,
                     *  if the commitments in this batch and all earlier batches verify correctly. */
                    const double accept_forged_headers = std::clamp<double>(forged_headers - bufsize, 0.0, HEADER_BATCH_COUNT);
    
  42. in src/headerssync.cpp:418 in e330ad91d7
     413 | +    auto attack_rate = [&](double period, double bufsize, std::optional<double> limit = std::nullopt) noexcept -> double {
     414 | +        /** Each batch's probability is a factor 2^(HEADER_BATCH_COUNT/period) smaller than the
     415 | +         *  previous one, so the contributions of all future batches form a geometric series.
     416 | +         *  future_limit bounds the ratio of that sum to a single batch's HEADER_BATCH_COUNT*prob.
     417 | +         */
     418 | +        const double future_limit = HEADER_BATCH_COUNT / std::expm1(std::numbers::ln2 * HEADER_BATCH_COUNT / period);
    


    darosior commented at 7:10 PM on September 9, 2026:

    nit: feel free to ignore, just mentioning what gave me pause when i was reading from scratch.

    That last sentence in the comment threw me off a little bit. future_limit is a number of headers, weighted by the exponentially-decreasing contribution each batch makes. The last sentence could be read as it representing a single batch, while really it represents all batches, so the check later can be essentially "if the contribution of the entire tail of headers at the current probability is no more than a rounding error, we are done".

  43. in src/headerssync.cpp:529 in e330ad91d7
     524 | +        // (2) becomes mem_timewarp(period, bufsize) = mem_mainchain(period, bufsize), because
     525 | +        //     timewarp memory generally goes down with increasing period, and mainchain memory
     526 | +        //     generally goes up with increasing period (with bufsize set to keep the attack rate
     527 | +        //     constant). The maximum of the two is therefore minimized exactly where they
     528 | +        //     intersect. This can be shown to be the case whenever max_headers is larger than
     529 | +        //     2 * minchainwork_headers, which is practically always true.
    


    darosior commented at 7:39 PM on September 9, 2026:

    Why is it not always true?


    sipa commented at 8:14 PM on October 7, 2026:

    In practice, it is always true. Outside of that (fuzz tests) it could be untrue, so it's good to document under what conditions the function's behavior is well-defined.

  44. in src/headerssync.cpp:541 in e330ad91d7
     536 | +        const double memory_factor = std::sqrt(double(max_headers - minchainwork_headers) / COMPACT_HEADER_SIZE);
     537 | +        // (the square root of the left-hand side) and gamma = sqrt(bufsize / period), we get:
     538 | +        //
     539 | +        //    period = memory_factor / gamma
     540 | +        //    bufsize = memory_factor * gamma
     541 | +        //    period * bufsize = memory_factor^2
    


    darosior commented at 7:57 PM on September 14, 2026:

    Why is this square root "indirection" necessary? Using gamma = bufsize / period (intuitively "bits of security") and memory_factor = (max_headers - mcw_headers) / COMPACT_HEADER_SIZE directly seem clearer to me, and equivalent?

  45. in src/headerssync.cpp:413 in e330ad91d7
     408 | +     * The period and bufsize arguments are generalized to accept floating-point inputs. This is
     409 | +     * meaningless on its own, as the actual algorithm can only operate with integer values. It is
     410 | +     * however used to find the optimum of the continuous extension below, around which the
     411 | +     * returned integer solution is searched.
     412 | +     */
     413 | +    auto attack_rate = [&](double period, double bufsize, std::optional<double> limit = std::nullopt) noexcept -> double {
    


    darosior commented at 9:16 PM on September 14, 2026:

    I think this routine underestimates the rate, because an incomplete batch sent by an attacker may be detected, but will still lead to processing as many headers from the buffer.

  46. darosior commented at 9:18 PM on September 14, 2026: member

    Submitting my comments so far, because i think attack_rate will need to be revised.

  47. sipa marked this as a draft on Sep 14, 2026
  48. sipa commented at 9:33 PM on September 14, 2026: member

    Converted to draft. @darosior found that the current version (and the original Python it's based on) did not take one attack scenario into account (using a final headers batch of size less than 2000). Fixing that is possible, but makes the code even more complicated, and results in parameters that increase per-peer memory by ~2.2 KiB.

    I'm considering instead going for a much simpler approach, that drops the assumption that all headers arrive in batches of 2000 altogether. That'd be much simpler and faster, and perhaps more obviously correct, but increase per-peer memory by ~14.7 KiB. I think that's acceptable compared to existing memory usage.

  49. sipa force-pushed on Sep 15, 2026
  50. sipa marked this as ready for review on Sep 15, 2026
  51. sipa commented at 6:19 PM on September 15, 2026: member

    Rewritten to take that approach. The commit history is simpler now too, as it doesn't attempt to show equivalence between the old Python script and the new code anymore.

  52. DrahtBot added the label CI failed on Sep 15, 2026
  53. DrahtBot commented at 7:18 PM on September 15, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task fuzzer,address,undefined,integer: https://github.com/bitcoin/bitcoin/actions/runs/35006193047/job/104506428335</sub> <sub>LLM reason (✨ experimental): Fuzz tests failed due to an UndefinedBehaviorSanitizer float-cast-overflow (inf cast to int) in ComputeHeadersSyncParamsInner (headersync.cpp:530).</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>

  54. DrahtBot removed the label Needs rebase on Sep 15, 2026
  55. sipa force-pushed on Sep 16, 2026
  56. DrahtBot removed the label CI failed on Sep 16, 2026
  57. in src/headerssync.cpp:573 in 526db3911d
     568 | +        if (best_period == 0 || mem < best_max_mem) {
     569 | +            best_period = period;
     570 | +            best_bufsize = bufsize;
     571 | +            best_max_mem = mem;
     572 | +        }
     573 | +    }
    


    darosior commented at 4:18 PM on September 18, 2026:

    Is Step 2 really useful anymore with the new approach? It made sense when Step 1 was a Newton-Raphson search that stopped at some threshold, to use as a basis for a more accurate search. But now Step 1 is an exact (according to the model) derivation.

    I asked Claude to check if Step 2 had any material impact in the new version. It claims that over 20,168 fuzz domain cases Step 2 picks offset 0 in 99.32% of cases. Furthermore, when it does pick a different offset it only saves about 0.0039% memory on realistic inputs.


    sipa commented at 8:44 PM on September 18, 2026:

    I've changed it to only checking the floor() and ceil() of the continuous optimal period. Experimentally, the only examples where neither of those wins occur with extremely low security levels (they all involve bufsize < period).

  58. darosior approved
  59. darosior commented at 6:36 PM on September 18, 2026: member

    ACK 526db3911d400b793e5715c0f989f1d3b66ac12e

    I was not familiar with the existing script, so i reviewed the new calculation from scratch. The latest approach is a lot easier to grasp. I think this slightly looser upper bound is the right tradeoff.

  60. DrahtBot requested review from fjahr on Sep 18, 2026
  61. DrahtBot requested review from ajtowns on Sep 18, 2026
  62. DrahtBot requested review from sedited on Sep 18, 2026
  63. sipa force-pushed on Sep 18, 2026
  64. in src/headerssync.cpp:566 in 0e473d1752 outdated
     561 | +        const int64_t period = real_period;
     562 | +        const int64_t bufsize = find_bufsize(period);
     563 | +        return std::tuple{memory_usage(period, bufsize), period, bufsize};
     564 | +    };
     565 | +    // Tuples compare lexicographically, so this picks the lowest memory usage, and among equally
     566 | +    // low ones the smallest period.
    


    darosior commented at 9:21 PM on September 18, 2026:

    Neat.

  65. darosior approved
  66. darosior commented at 9:22 PM on September 18, 2026: member

    re-ACK 0e473d175224746c13678db5106f9d4ab1086b9d

  67. DrahtBot added the label Needs rebase on Sep 24, 2026
  68. sipa force-pushed on Sep 29, 2026
  69. DrahtBot removed the label Needs rebase on Sep 29, 2026
  70. DrahtBot added the label CI failed on Sep 29, 2026
  71. DrahtBot removed the label CI failed on Sep 30, 2026
  72. sipa commented at 1:47 PM on September 30, 2026: member

    Rebased, should be ready for review.

  73. darosior approved
  74. darosior commented at 3:09 PM on September 30, 2026: member

    re-ACK 9f7bcfe8747

  75. in src/headerssync.cpp:192 in 9f7bcfe874 outdated
     188 | @@ -187,7 +189,7 @@ bool HeadersSyncState::ValidateAndProcessSingleHeader(const CBlockHeader& curren
     189 |      Assume(m_download_state == State::PRESYNC);
     190 |      if (m_download_state != State::PRESYNC) return false;
     191 |  
     192 | -    int next_height = m_current_height + 1;
     193 | +    int64_t next_height = m_current_height + 1;
    


    hodlinator commented at 12:56 PM on October 1, 2026:

    nanonit: If you touch again, could const and brace-initialize to further tighten the screws (mutation+anti-narrowing verification).

        const int64_t next_height{m_current_height + 1};
    

    sipa commented at 2:14 PM on October 20, 2026:

    Done.

  76. in src/headerssync.h:80 in 9f7bcfe874 outdated
      75 | + * - There are minchainwork_headers in the minchainwork chain.
      76 | + * - Up to attack_headers low-difficulty headers are allowed to be accepted by
      77 | + *   the victim, per attack.
      78 | + *
      79 | + * This is the internal part of ComputeHeadersSyncParams that performs the actual optimization.
      80 | + * Test-only.
    


    hodlinator commented at 1:09 PM on October 1, 2026:

    nanonit: It is used internally in production, as stated in the line above.

     * Only exposed for tests.
    

    sipa commented at 2:14 PM on October 20, 2026:

    Done.

  77. in src/headerssync.cpp:593 in 9f7bcfe874 outdated
     588 | +     *  duration the attack is sustained. [unitless]. 0.2 means that attackers, while they keep up the
     589 | +     * attack, can cause permanent memory usage due to headers storage to grow at 1.2 header per
     590 | +     * BLOCK_INTERVAL. */
     591 | +    constexpr double ATTACK_FRACTION{0.2};
     592 | +    /** How many bits a header uses in P2P protocol. [bits] */
     593 | +    constexpr int64_t NET_HEADER_SIZE{81 * 8};
    


    hodlinator commented at 1:22 PM on October 1, 2026:

    Rather than magic numbers, could we use sizeof(CBlockHeader) or compute it somehow, or improve the doc-string? 80 is the value of sizeof(CBlockHeader) but the 81 constant appears in headerssync-params.py on master. I would think it typically serializes down to something smaller due to compact integers, but then there is overhead for the network magic, message type etc?


    sipa commented at 9:39 AM on October 20, 2026:

    Historically, CBlockHeader was not a separate type, and headers were relayed as CBlock-with-0-transactions. That's where the value 81 comes from, and it is exact: there is just a trailing 0x00 byte at the end for the number of transactions.

    The overhead from the P2P transport (length/magic/command/checksum in V1, length/header/auth in V2) doesn't apply here because those are paid only once (and a single headers message can carry 2000 of them).


    hodlinator commented at 12:19 PM on October 20, 2026:

    Ah, I see the tx count is ignored here at the end as you say:

    https://github.com/bitcoin/bitcoin/blob/4bacf21a13c2ed25ef9362ca38f26bf0a67d22c9/src/net_processing.cpp#L5072-L5083

    And none of the integers in CBlockHeader are serialized in compact form. So it is indeed exact.

    Still think it would be more helpful to have:

        // + 1 for the always-zero tx count
        constexpr int64_t NET_HEADER_SIZE{(sizeof(CBlockHeader) + 1) * 8};
    

    sipa commented at 2:19 PM on October 20, 2026:

    Explained in the comment now.

  78. in src/headerssync.cpp:399 in 9f7bcfe874 outdated
     394 | +{
     395 | +    /** Headers in the redownload buffer are stored without prevhash, as a CompressedHeader. [bits] */
     396 | +    constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
     397 | +    // The unit tests would need to be regenerated if this constant were to change, so
     398 | +    // static_assert to give a more targeted error message.
     399 | +    static_assert(COMPACT_HEADER_SIZE == 384);
    


    hodlinator commented at 5:28 PM on October 1, 2026:

    nit: Might as well use static_assert message, and specify "case records".

        static_assert(COMPACT_HEADER_SIZE == 384,
            "Unit test case records will need to be regenerated if this constant were to change.");
    

    sipa commented at 2:24 PM on October 20, 2026:

    Done.

  79. in src/headerssync.cpp:417 in 9f7bcfe874
     412 | +     *  as arriving one by one. This overestimates the attacker's ability slightly, because the
     413 | +     *  batches are only accepted in their entirety, if all checks inside succeed. Thus, our result
     414 | +     *  here will be a conservative overestimate.
     415 | +     *
     416 | +     *  The attack we model then consists of:
     417 | +     *  - During the commitment phase, the attacker sends just the honest chain.
    


    hodlinator commented at 5:42 PM on October 1, 2026:

    remark: "commitment phase" is clearer than "presync phase". Wish it was used everywhere in older code.


    sipa commented at 7:41 PM on October 21, 2026:

    Fair point, but not going to touch this everywhere now.

  80. in src/headerssync.cpp:456 in 9f7bcfe874
     451 | +        auto m = bufsize - k * period;
     452 | +        return std::ldexp((period * (6.0 * period - 4.0 * m - 2.0) + m * (m + 1.0)) / period, -int(k + 2));
     453 | +    };
     454 | +
     455 | +    /** The peak per-peer memory a (period,bufsize) configuration needs. */
     456 | +    auto memory_usage = [max_headers, minchainwork_headers](int64_t period, int64_t bufsize) noexcept -> int64_t {
    


    hodlinator commented at 6:11 PM on October 1, 2026:

    nit: This lambda could be declared inside make_config to decrease cognitive complexity.


    sipa commented at 2:20 PM on October 20, 2026:

    I don't think that's very helpful.

  81. in src/headerssync.cpp:402 in 9f7bcfe874
     397 | +    // The unit tests would need to be regenerated if this constant were to change, so
     398 | +    // static_assert to give a more targeted error message.
     399 | +    static_assert(COMPACT_HEADER_SIZE == 384);
     400 | +
     401 | +    // The chain has at least one header.
     402 | +    Assume(minchainwork_headers > 0);
    


    l0rinc commented at 3:55 AM on October 8, 2026:

    9f7bcfe contrib: remove headerssync-params.py:

    Following up on #35642 (comment): is there a reason to use Assume (rather than Assert) for these preconditions? Shouldn't these abort in production? It seems to me that zero inputs could theoretically still lead to division by zero or an infinity-to-integer conversion.


    sipa commented at 6:54 PM on October 8, 2026:

    Done.

  82. in src/headerssync.cpp:545 in 9f7bcfe874
     540 | +        // the integer period is restricted to (which yields an endpoint when the real solution
     541 | +        // lies outside of it).
     542 | +        const double target = double(max_headers - minchainwork_headers) / COMPACT_HEADER_SIZE;
     543 | +        double lo = 1.0;
     544 | +        double hi = minchainwork_headers;
     545 | +        while (true) {
    


    l0rinc commented at 6:04 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Could we cap this loop? I was surprised we need a binary search here (considered a few alternatives but don't have a fully working version) and wondered about NaN risk, but that does actually make this condition break. Digging further with AI pointed to x87 excess precision: a midpoint can compare between the endpoints, then round back to one when stored, leaving the loop with no progress. GCC describes the relevant behavior.

    <details> <summary>Bound the existing bisection loop</summary>

    The saved GCC 12.2/i686 runs timed out on the 50 existing vectors. The capped runs matched the native outputs. I inspected those results but have not rerun the compiler matrix.

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index e218f99d3d..5c09d516fe 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -542,7 +542,7 @@ std::pair<size_t, size_t> ComputeHeadersSyncParamsInner(int64_t max_headers, int
             const double target = double(max_headers - minchainwork_headers) / COMPACT_HEADER_SIZE;
             double lo = 1.0;
             double hi = minchainwork_headers;
    -        while (true) {
    +        for (int iter{0}; iter < 128; ++iter) {
                 const double mid = 0.5 * (lo + hi);
                 if (!(lo < mid && mid < hi)) break;
                 if (mid * cont_bufsize(mid) < target) {
    

    </details>


    sipa commented at 7:05 PM on October 8, 2026:

    Nice catch. Fixed, but with a much better alternative: do the bisection over integer bufsize values, as those are the only ones we care about anyway. Avoids floating-point shenanigans like these, and is faster too.


    l0rinc commented at 6:20 AM on October 9, 2026:

    Thanks, please resolve

  83. in src/net_processing.cpp:2147 in 9f7bcfe874
    2142 | +        const auto genesis_time{NodeSeconds{std::chrono::seconds{chainparams.GenesisBlock().nTime}}};
    2143 | +        const auto timespan{std::chrono::duration_cast<std::chrono::seconds>(NodeClock::now() - genesis_time)};
    2144 | +        return ComputeHeadersSyncParams(timespan, chainparams.GetConsensus().minchainwork_height);
    2145 | +    }();
    2146 | +    LogInfo("Headers sync parameters: commitment period=%u, redownload buffer size=%u\n", period, bufsize);
    2147 | +    return HeadersSyncParams{.commitment_period = period, .redownload_buffer_size = bufsize};
    


    l0rinc commented at 6:18 AM on October 8, 2026:

    014aff1 net_processing: compute headers sync parameters at runtime:

    Since we already have a HeadersSyncParams type could we move it and use it here as well (as @darosior also hinted I think)?

    <details> <summary>Return the existing named parameter type</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 5c09d516fe..b89468b174 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -390,7 +390,7 @@ CBlockLocator HeadersSyncState::NextHeadersRequestLocator() const
     //    alternate chain. This trade-off is ignored here, as it results in at most a small constant
     //    factor in attack rate.
     
    -std::pair<size_t, size_t> ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers)
    +HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers)
     {
         /** Headers in the redownload buffer are stored without prevhash, as a CompressedHeader. [bits] */
         constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
    @@ -568,12 +568,12 @@ std::pair<size_t, size_t> ComputeHeadersSyncParamsInner(int64_t max_headers, int
             make_config(std::floor(cont_period)),
             make_config(std::ceil(cont_period)));
     
    -    return {size_t(best_period), size_t(best_bufsize)};
    +    return {.commitment_period = size_t(best_period), .redownload_buffer_size = size_t(best_bufsize)};
     }
     
     // Derive max_headers (the longest a valid chain could be by now) and the attack_headers budget from
     // the chain's age and minimum-chain-work header count, then optimize.
    -std::pair<size_t, size_t> ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_headers)
    +HeadersSyncParams ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_headers)
     {
         /** Expected block interval. [seconds] */
         constexpr double BLOCK_INTERVAL{600.0};
    diff --git a/src/headerssync.h b/src/headerssync.h
    index 5827db43f6..0dd1d0e3fe 100644
    --- a/src/headerssync.h
    +++ b/src/headerssync.h
    @@ -15,9 +15,9 @@
     #include <util/hasher.h>
     
     #include <chrono>
    +#include <cstddef>
     #include <deque>
     #include <stdexcept>
    -#include <utility>
     #include <vector>
     
     // A compressed CBlockHeader, which leaves out the prevhash
    @@ -56,6 +56,14 @@ struct CompressedHeader {
         };
     };
     
    +//! Configuration for headers sync memory usage.
    +struct HeadersSyncParams {
    +    //! Distance in blocks between header commitments.
    +    size_t commitment_period{0};
    +    //! Minimum number of validated headers to accumulate in the redownload
    +    //! buffer before feeding them into the permanent block index.
    +    size_t redownload_buffer_size{0};
    +};
     
     /** Find a near-optimal (period, bufsize) headers synchronization configuration for:
      *
    @@ -63,11 +71,8 @@ struct CompressedHeader {
      * - A specified number of headers in the minchainwork chain.
      *
      * See the constants in the implementation that control what attack success rate is acceptable.
    - *
    - * Returns {commitment period, redownload buffer size}.
      */
    -std::pair<size_t, size_t> ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_headers);
    -
    +HeadersSyncParams ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_headers);
     
     /** Find a near-optimal (period, bufsize) headers synchronization configuration for:
      *
    @@ -79,8 +84,7 @@ std::pair<size_t, size_t> ComputeHeadersSyncParams(std::chrono::seconds timespan
      * This is the internal part of ComputeHeadersSyncParams that performs the actual optimization.
      * Test-only.
      */
    -std::pair<size_t, size_t> ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers);
    -
    +HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers);
     
     /** HeadersSyncState:
      *
    diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h
    index e9761f70ae..61c734b0a4 100644
    --- a/src/kernel/chainparams.h
    +++ b/src/kernel/chainparams.h
    @@ -14,7 +14,6 @@
     #include <util/hash_type.h>
     #include <util/vector.h>
     
    -#include <cstddef>
     #include <cstdint>
     #include <memory>
     #include <optional>
    @@ -60,15 +59,6 @@ struct ChainTxData {
         double dTxRate;   //!< estimated number of transactions per second after that timestamp
     };
     
    -//! Configuration for headers sync memory usage.
    -struct HeadersSyncParams {
    -    //! Distance in blocks between header commitments.
    -    size_t commitment_period{0};
    -    //! Minimum number of validated headers to accumulate in the redownload
    -    //! buffer before feeding them into the permanent block index.
    -    size_t redownload_buffer_size{0};
    -};
    -
     /**
      * CChainParams defines various tweakable parameters of a given instance of the
      * Bitcoin system.
    diff --git a/src/net_processing.cpp b/src/net_processing.cpp
    index 5524e16b4f..ea800ba9a3 100644
    --- a/src/net_processing.cpp
    +++ b/src/net_processing.cpp
    @@ -2141,13 +2141,13 @@ std::unique_ptr<PeerManager> PeerManager::make(CConnman& connman, AddrMan& addrm
     static HeadersSyncParams MakeHeadersSyncParams(const CChainParams& chainparams,
                                                    const std::optional<std::pair<size_t, size_t>>& precomputed)
     {
    -    const auto [period, bufsize] = precomputed ? *precomputed : [&] {
    +    const auto params{precomputed ? HeadersSyncParams{.commitment_period = precomputed->first, .redownload_buffer_size = precomputed->second} : [&] {
             const auto genesis_time{NodeSeconds{std::chrono::seconds{chainparams.GenesisBlock().nTime}}};
             const auto timespan{std::chrono::duration_cast<std::chrono::seconds>(NodeClock::now() - genesis_time)};
             return ComputeHeadersSyncParams(timespan, chainparams.GetConsensus().minchainwork_height);
    -    }();
    -    LogInfo("Headers sync parameters: commitment period=%u, redownload buffer size=%u\n", period, bufsize);
    -    return HeadersSyncParams{.commitment_period = period, .redownload_buffer_size = bufsize};
    +    }()};
    +    LogInfo("Headers sync parameters: commitment period=%u, redownload buffer size=%u", params.commitment_period, params.redownload_buffer_size);
    +    return params;
     }
     
     PeerManagerImpl::PeerManagerImpl(CConnman& connman, AddrMan& addrman,
    

    </details>


    sipa commented at 7:06 PM on October 8, 2026:

    Done, in a later commit.

    The reason for this approach (still kept in the first commit) is that doing it while such parameters are still defined in chainparams would need a cyclic dependency. With the later commit that removes them from chainparams, it's possible to move the HeadersSyncParams to headerssync.h.

  84. in src/net_processing.h:117 in 9f7bcfe874
     111 | @@ -110,6 +112,9 @@ class PeerManager : public CValidationInterface, public NetEventsInterface
     112 |          bool private_broadcast{DEFAULT_PRIVATE_BROADCAST};
     113 |          //! Maximum per-second rate for sending transaction inventory to peers.
     114 |          unsigned int tx_send_rate{DEFAULT_TX_SEND_RATE};
     115 | +        //! Precomputed headers-sync parameters, as {commitment period, redownload buffer size};
     116 | +        //! when unset, they are computed at construction time (this is a test-only option).
     117 | +        std::optional<std::pair<size_t, size_t>> headers_sync_params{std::nullopt};
    


    l0rinc commented at 6:20 AM on October 8, 2026:

    014aff1 net_processing: compute headers sync parameters at runtime:

    With the rewrite down to about 3 µs per call and fixed clocks already in these fuzz targets, is the override still worth keeping?

    <details> <summary>Use the existing fixed fuzz clocks</summary>

    (Based on previous local suggestions, so this patch may not apply cleanly)

    diff --git a/src/net_processing.cpp b/src/net_processing.cpp
    index ea800ba9a3..d107c5fbc8 100644
    --- a/src/net_processing.cpp
    +++ b/src/net_processing.cpp
    @@ -2135,17 +2135,11 @@ std::unique_ptr<PeerManager> PeerManager::make(CConnman& connman, AddrMan& addrm
         return std::make_unique<PeerManagerImpl>(connman, addrman, banman, chainman, pool, warnings, opts);
     }
     
    -//! Compute the headers sync DoS-protection parameters for the given chain (unless precomputed
    -//! ones are provided), using its age (genesis to now) and the height at which its minimum chain
    -//! work was taken, and log the result.
    -static HeadersSyncParams MakeHeadersSyncParams(const CChainParams& chainparams,
    -                                               const std::optional<std::pair<size_t, size_t>>& precomputed)
    +//! Compute the headers sync DoS-protection parameters for the given chain, using its age (genesis
    +//! to now) and the height at which its minimum chain work was taken, and log the result.
    +static HeadersSyncParams MakeHeadersSyncParams(const CChainParams& chainparams)
     {
    -    const auto params{precomputed ? HeadersSyncParams{.commitment_period = precomputed->first, .redownload_buffer_size = precomputed->second} : [&] {
    -        const auto genesis_time{NodeSeconds{std::chrono::seconds{chainparams.GenesisBlock().nTime}}};
    -        const auto timespan{std::chrono::duration_cast<std::chrono::seconds>(NodeClock::now() - genesis_time)};
    -        return ComputeHeadersSyncParams(timespan, chainparams.GetConsensus().minchainwork_height);
    -    }()};
    +    const auto params{ComputeHeadersSyncParams(Now<NodeSeconds>() - chainparams.GenesisBlock().Time(), chainparams.GetConsensus().minchainwork_height)};
         LogInfo("Headers sync parameters: commitment period=%u, redownload buffer size=%u", params.commitment_period, params.redownload_buffer_size);
         return params;
     }
    @@ -2156,7 +2150,7 @@ PeerManagerImpl::PeerManagerImpl(CConnman& connman, AddrMan& addrman,
         : m_rng{opts.deterministic_rng},
           m_fee_filter_rounder{CFeeRate{DEFAULT_MIN_RELAY_TX_FEE}, m_rng},
           m_chainparams(chainman.GetParams()),
    -      m_headers_sync_params{MakeHeadersSyncParams(m_chainparams, opts.headers_sync_params)},
    +      m_headers_sync_params{MakeHeadersSyncParams(m_chainparams)},
           m_connman(connman),
           m_addrman(addrman),
           m_banman(banman),
    diff --git a/src/net_processing.h b/src/net_processing.h
    index 3a9ecbab63..edaf48418f 100644
    --- a/src/net_processing.h
    +++ b/src/net_processing.h
    @@ -18,12 +18,10 @@
     
     #include <atomic>
     #include <chrono>
    -#include <cstddef>
     #include <cstdint>
     #include <memory>
     #include <optional>
     #include <string>
    -#include <utility>
     #include <vector>
     
     class AddrMan;
    @@ -112,9 +110,6 @@ public:
             bool private_broadcast{DEFAULT_PRIVATE_BROADCAST};
             //! Maximum per-second rate for sending transaction inventory to peers.
             unsigned int tx_send_rate{DEFAULT_TX_SEND_RATE};
    -        //! Precomputed headers-sync parameters, as {commitment period, redownload buffer size};
    -        //! when unset, they are computed at construction time (this is a test-only option).
    -        std::optional<std::pair<size_t, size_t>> headers_sync_params{std::nullopt};
         };
     
         static std::unique_ptr<PeerManager> make(CConnman& connman, AddrMan& addrman,
    diff --git a/src/test/fuzz/cmpctblock.cpp b/src/test/fuzz/cmpctblock.cpp
    index 4f1e0df805..fea6229790 100644
    --- a/src/test/fuzz/cmpctblock.cpp
    +++ b/src/test/fuzz/cmpctblock.cpp
    @@ -24,7 +24,6 @@
     #include <test/fuzz/FuzzedDataProvider.h>
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
    -#include <test/fuzz/util/headerssync.h>
     #include <test/fuzz/util/net.h>
     #include <test/util/net.h>
     #include <test/util/random.h>
    @@ -141,7 +140,6 @@ FUZZ_TARGET(cmpctblock, .init = initialize_cmpctblock)
                                          mempool, *setup->m_node.warnings,
                                          PeerManager::Options{
                                              .deterministic_rng = true,
    -                                         .headers_sync_params = FUZZ_HEADERS_SYNC_PARAMS,
                                          });
         connman.SetMsgProc(peerman.get());
     
    diff --git a/src/test/fuzz/p2p_handshake.cpp b/src/test/fuzz/p2p_handshake.cpp
    index 8d1c1b2373..0ab3e0c248 100644
    --- a/src/test/fuzz/p2p_handshake.cpp
    +++ b/src/test/fuzz/p2p_handshake.cpp
    @@ -10,7 +10,6 @@
     #include <test/fuzz/FuzzedDataProvider.h>
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
    -#include <test/fuzz/util/headerssync.h>
     #include <test/fuzz/util/net.h>
     #include <test/util/net.h>
     #include <test/util/setup_common.h>
    @@ -58,7 +57,6 @@ FUZZ_TARGET(p2p_handshake, .init = ::initialize)
                                          PeerManager::Options{
                                              .reconcile_txs = true,
                                              .deterministic_rng = true,
    -                                         .headers_sync_params = FUZZ_HEADERS_SYNC_PARAMS,
                                          });
         connman.SetMsgProc(node.peerman.get());
         connman.SetAddrman(*node.addrman);
    diff --git a/src/test/fuzz/process_message.cpp b/src/test/fuzz/process_message.cpp
    index e5320155a8..a63f8a0a6a 100644
    --- a/src/test/fuzz/process_message.cpp
    +++ b/src/test/fuzz/process_message.cpp
    @@ -14,7 +14,6 @@
     #include <test/fuzz/FuzzedDataProvider.h>
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
    -#include <test/fuzz/util/headerssync.h>
     #include <test/fuzz/util/net.h>
     #include <test/util/net.h>
     #include <test/util/random.h>
    @@ -91,7 +90,6 @@ FUZZ_TARGET(process_message, .init = initialize_process_message)
                                          PeerManager::Options{
                                              .reconcile_txs = true,
                                              .deterministic_rng = true,
    -                                         .headers_sync_params = FUZZ_HEADERS_SYNC_PARAMS,
                                          });
     
         connman.SetMsgProc(node.peerman.get());
    diff --git a/src/test/fuzz/process_messages.cpp b/src/test/fuzz/process_messages.cpp
    index 4ede3ce674..f4a93df8dc 100644
    --- a/src/test/fuzz/process_messages.cpp
    +++ b/src/test/fuzz/process_messages.cpp
    @@ -14,7 +14,6 @@
     #include <test/fuzz/FuzzedDataProvider.h>
     #include <test/fuzz/fuzz.h>
     #include <test/fuzz/util.h>
    -#include <test/fuzz/util/headerssync.h>
     #include <test/fuzz/util/net.h>
     #include <test/util/net.h>
     #include <test/util/random.h>
    @@ -83,7 +82,6 @@ FUZZ_TARGET(process_messages, .init = initialize_process_messages)
                                          PeerManager::Options{
                                              .reconcile_txs = true,
                                              .deterministic_rng = true,
    -                                         .headers_sync_params = FUZZ_HEADERS_SYNC_PARAMS,
                                          });
         connman.SetMsgProc(node.peerman.get());
         connman.SetAddrman(*node.addrman);
    diff --git a/src/test/fuzz/util/headerssync.h b/src/test/fuzz/util/headerssync.h
    deleted file mode 100644
    index abfea0ab68..0000000000
    --- a/src/test/fuzz/util/headerssync.h
    +++ /dev/null
    @@ -1,18 +0,0 @@
    -// Copyright (c) The Bitcoin Core developers
    -// Distributed under the MIT software license, see the accompanying
    -// file COPYING or http://www.opensource.org/licenses/mit-license.php.
    -
    -#ifndef BITCOIN_TEST_FUZZ_UTIL_HEADERSSYNC_H
    -#define BITCOIN_TEST_FUZZ_UTIL_HEADERSSYNC_H
    -
    -#include <cstddef>
    -#include <utility>
    -
    -/** Fixed headers-sync parameters for fuzz targets that construct a PeerManager per iteration,
    - *  avoiding both recomputing them every time and any dependence on the (mocked) clock at
    - *  construction time, as {commitment period, redownload buffer size}. The values are the regtest
    - *  constants that were committed in chainparams before the parameters became runtime-computed,
    - *  but any sane values would do. */
    -inline constexpr std::pair<size_t, size_t> FUZZ_HEADERS_SYNC_PARAMS{275, 7017};
    -
    -#endif // BITCOIN_TEST_FUZZ_UTIL_HEADERSSYNC_H
    

    </details>


    sipa commented at 7:06 PM on October 8, 2026:

    Done, and attributed.

  85. in src/headerssync.cpp:473 in 9f7bcfe874
     468 | +    auto find_bufsize = [&](int64_t period) noexcept -> int64_t {
     469 | +        int64_t succ_buf{0};
     470 | +        int64_t fail_buf{1};
     471 | +        // First double iteratively until an upper bound for failure is found.
     472 | +        while (attack_rate(period, fail_buf) >= attack_headers) {
     473 | +            const int64_t next_fail = 3 * fail_buf - 2 * succ_buf;
    


    l0rinc commented at 6:22 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Since fail_buf = 2 * succ_buf + 1 throughout the growth loop, we can grow just fail_buf internally and derive succ_buf at the end.

    <details> <summary>Track one bound while growing the search range</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index b89468b174..06964703a4 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -466,14 +466,12 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
     
         /** Smallest bufsize whose attack rate against (period, bufsize) is below attack_headers. */
         auto find_bufsize = [&](int64_t period) noexcept -> int64_t {
    -        int64_t succ_buf{0};
             int64_t fail_buf{1};
             // First double iteratively until an upper bound for failure is found.
             while (attack_rate(period, fail_buf) >= attack_headers) {
    -            const int64_t next_fail = 3 * fail_buf - 2 * succ_buf;
    -            succ_buf = fail_buf;
    -            fail_buf = next_fail;
    +            fail_buf = 2 * fail_buf + 1;
             }
    +        int64_t succ_buf{fail_buf / 2};
             // Then perform a bisection search to narrow it down.
             while (fail_buf > succ_buf + 1) {
                 const int64_t try_buf = (succ_buf + fail_buf) / 2;
    

    </details>


    sipa commented at 7:04 PM on October 8, 2026:

    Done.

  86. in src/headerssync.cpp:451 in 9f7bcfe874
     446 | +     *      2^-K     * (6*P^2 - 4*P*M + M^2 - 2*P + M) / (4*P)
     447 | +     *    = 2^-(K+2) * (6*P^2 - 4*P*M + M^2 - 2*P + M) / P
     448 | +     */
     449 | +    auto attack_rate = [](int64_t period, int64_t bufsize) noexcept -> double {
     450 | +        auto k = bufsize / period;
     451 | +        auto m = bufsize - k * period;
    


    l0rinc commented at 7:27 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Unless I'm missing something, this could be simplified to modulo, see https://godbolt.org/z/53PfMqxnj. If we cannot use that, could you add a same-line comment explaining why?

    <details> <summary>Use the remainder operator</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 06964703a4..1b5ec9bc43 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -448,7 +448,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
          */
         auto attack_rate = [](int64_t period, int64_t bufsize) noexcept -> double {
             auto k = bufsize / period;
    -        auto m = bufsize - k * period;
    +        auto m = bufsize % period;
             return std::ldexp((period * (6.0 * period - 4.0 * m - 2.0) + m * (m + 1.0)) / period, -int(k + 2));
         };
     
    

    </details>


    sipa commented at 7:03 PM on October 8, 2026:

    Done.

    The reason for not doing this was my (folklore, perhaps) aversion due to division and modulus operators which are very slow instructions (as far as CPU instructions go; they used to be ~50s slower than multiplication IIRC).

    Modern compilers don't need this, as will turn the computation of both a division and a modulus into a single instruction anyway, so I took your change.

  87. in src/test/headerssync_tests.cpp:13 in 9f7bcfe874
       8 | +
       9 | +#include <boost/test/unit_test.hpp>
      10 | +
      11 | +#include <cstdint>
      12 | +
      13 | +BOOST_FIXTURE_TEST_SUITE(headerssync_tests, BasicTestingSetup)
    


    l0rinc commented at 7:29 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Nit: this only tests ComputeHeadersSyncParamsInner (which doesn’t need the node setup), could we drop the fixture?

    <details> <summary>Drop the unnecessary test fixture</summary>

    diff --git a/src/test/headerssync_tests.cpp b/src/test/headerssync_tests.cpp
    index 4a3000cdb2..a093bf6287 100644
    --- a/src/test/headerssync_tests.cpp
    +++ b/src/test/headerssync_tests.cpp
    @@ -4,13 +4,11 @@
     
     #include <headerssync.h>
     
    -#include <test/util/setup_common.h>
    -
     #include <boost/test/unit_test.hpp>
     
     #include <cstdint>
     
    -BOOST_FIXTURE_TEST_SUITE(headerssync_tests, BasicTestingSetup)
    +BOOST_AUTO_TEST_SUITE(headerssync_tests)
     
     // Test vectors checking that ComputeHeadersSyncParamsInner() finds the optimal (period, bufsize)
     // configuration, each a tuple containing:
    

    </details>


    sipa commented at 6:54 PM on October 8, 2026:

    Done.

  88. in src/headerssync.cpp:398 in 9f7bcfe874 outdated
     393 | +std::pair<size_t, size_t> ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers)
     394 | +{
     395 | +    /** Headers in the redownload buffer are stored without prevhash, as a CompressedHeader. [bits] */
     396 | +    constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
     397 | +    // The unit tests would need to be regenerated if this constant were to change, so
     398 | +    // static_assert to give a more targeted error message.
    


    l0rinc commented at 7:31 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Since this assertion protects the test vectors (and since ComputeHeadersSyncParamsInner is already very complicated), I’d keep it in the test.

    <details> <summary>Move the test-vector size assertion</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 1b5ec9bc43..be9b3d8b1a 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -394,9 +394,6 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
     {
         /** Headers in the redownload buffer are stored without prevhash, as a CompressedHeader. [bits] */
         constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
    -    // The unit tests would need to be regenerated if this constant were to change, so
    -    // static_assert to give a more targeted error message.
    -    static_assert(COMPACT_HEADER_SIZE == 384);
     
         // The chain has at least one header.
         Assume(minchainwork_headers > 0);
    diff --git a/src/test/headerssync_tests.cpp b/src/test/headerssync_tests.cpp
    index a093bf6287..b5b13da883 100644
    --- a/src/test/headerssync_tests.cpp
    +++ b/src/test/headerssync_tests.cpp
    @@ -31,6 +31,8 @@ BOOST_AUTO_TEST_SUITE(headerssync_tests)
     
     BOOST_AUTO_TEST_CASE(compute_headers_sync_params)
     {
    +    constexpr int64_t COMPACT_HEADER_SIZE{384};
    +    static_assert(sizeof(CompressedHeader) * 8 == COMPACT_HEADER_SIZE, "Regenerate the optimizer test vectors if CompressedHeader changes size");
         const struct {
             int64_t max_headers;
             int64_t minchainwork_headers;
    

    </details>


    sipa commented at 6:57 PM on October 8, 2026:

    I didn't take this, as it risks a disconnect between the two (e.g. a new ExtraCompressedHeader is introduced but only one of the two locations is updated to use it). Could be resolved by exporting the actually used COMPACT_HEADER_SIZE, and asserting on that in the test, but I feel that's overkill.


    l0rinc commented at 1:49 AM on October 10, 2026:

    Thanks, please resolve

  89. in src/headerssync.cpp:452 in 9f7bcfe874
     447 | +     *    = 2^-(K+2) * (6*P^2 - 4*P*M + M^2 - 2*P + M) / P
     448 | +     */
     449 | +    auto attack_rate = [](int64_t period, int64_t bufsize) noexcept -> double {
     450 | +        auto k = bufsize / period;
     451 | +        auto m = bufsize - k * period;
     452 | +        return std::ldexp((period * (6.0 * period - 4.0 * m - 2.0) + m * (m + 1.0)) / period, -int(k + 2));
    


    l0rinc commented at 7:56 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Nit: I find the preceding 2^-K * ... form easier to follow. Could we separate the expected headers from the probability of passing the first K checks here?

    <details> <summary>Separate the probability from the expected headers</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index bb1701cebd..7a083848c0 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -448,7 +448,9 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
         auto attack_rate = [](int64_t period, int64_t bufsize) noexcept -> double {
             auto k = bufsize / period;
             auto m = bufsize % period;
    -        return std::ldexp((period * (6.0 * period - 4.0 * m - 2.0) + m * (m + 1.0)) / period, -int(k + 2));
    +        // Expected accepted headers, given the first k checks pass
    +        const double expected_headers = (period * (6.0 * period - 4.0 * m - 2.0) + m * (m + 1.0)) / (4.0 * period);
    +        return std::ldexp(expected_headers, -int(k));
         };
     
         /** The peak per-peer memory a (period,bufsize) configuration needs. */
    

    </details>


    sipa commented at 7:04 PM on October 8, 2026:

    Done.

  90. in src/headerssync.cpp:447 in 9f7bcfe874
     442 | +     *  - for F in [0, M-1]: C = K+1, so avg n(0..M-1) = 2^-(K+1) * ((M-1)/2   + P*K - B + 2*P)
     443 | +     *  - for F in [M, P-1]: C = K,   so avg n(M..P-1) = 2^-K     * ((M+P-1)/2 + P*K - B + P)
     444 | +     *  Taking the weighted average over both (weight M for first, weight P-M for second) gives:
     445 | +     *
     446 | +     *      2^-K     * (6*P^2 - 4*P*M + M^2 - 2*P + M) / (4*P)
     447 | +     *    = 2^-(K+2) * (6*P^2 - 4*P*M + M^2 - 2*P + M) / P
    


    l0rinc commented at 7:59 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    I wonder whether we need to find the optimum this precisely. If we restricted the buffer to whole commitment periods, we could calculate its size directly, and a logarithmic estimate might replace the period search too. The earlier experiments suggest a small memory increase could let us remove both searches. Do you think that tradeoff is worth exploring?

    <details> <summary>Whole-period calculation and earlier measurements</summary>

    AI summary:

    For whole-period buffers (M = 0), the attack rate reduces to 2^-K * (1.5*P - 0.5). Choosing at least one check and K = ceil(log2(1.5*P / attack_headers)) meets the budget without searching for the buffer. A corrected logarithmic estimate for the period also removed the other search in the earlier experiments. Across the existing 50 vectors, that used about 1.1% more memory on average, with a maximum increase of 3.9%. Those are observed differences, not a bound for every input. The original exact outputs would remain the comparison baseline.

    </details>


    sipa commented at 7:00 PM on October 8, 2026:

    It's true that the need for exactness is less relevant than in earlier iterations of this PR. But I still like the fact that it actually finds the optimal solution (within rounding precision) to a well-defined (but weakened) variant of the real problem, so a more naive search will find the exact same solutions.

    To demonstrate that, I added a commit that does a more naive search on random inputs.


    l0rinc commented at 6:27 AM on October 9, 2026:

    Since this is the optimum for our simplified model of the attack surface, how much confidence do we have that the extra precision translates into a better tradeoff in practice? Honest question, I'm still working on fully understanding it. Could a slightly less memory-efficient result within that model be worth a simpler calculation, while preserving the same security bound?

    I won't block either way, but the added complexity makes it harder for me to ACK confidently.


    sipa commented at 5:14 PM on October 9, 2026:

    I forgot where, but you commented somewhere about simplifying to remove the need to search for bufsize.

    It turns out that no simplification is needed, since we already have a closed-form function that finds the optimal bufsize when relaxed to real numbers (formerly cont_bufsize, now renamed to real_bufsize). The integer version doesn't need bisection, it can just round real_bufsize up to an integer.

    This still leaves a bisection search for the periods. That one is harder to get rid of. It's possible to simplify the attack_rate formula a bit more so the result can be found using a closed form expression with the Lambert W function, and evaluating that needs a loop still too. Simplifying it even further so that the solution can be written explicitly using just elementary functions would lose a great deal of accuracy.

  91. in src/headerssync.cpp:530 in 9f7bcfe874
     525 | +        // which M follows from the quadratic equation
     526 | +        //
     527 | +        //   M^2 - (4*P - 1)*M + 6*P^2 - 2*P - attack_headers * P * 2^(K+2) = 0.
     528 | +        //
     529 | +        auto cont_bufsize = [&](double period) noexcept -> double {
     530 | +            const double k = std::floor(std::log2(1.5 * period - 0.5) - std::log2(attack_headers));
    


    l0rinc commented at 8:01 AM on October 8, 2026:

    b819b05 headerssync: add computation of optimal sync parameters:

    Nit: The attack budget stays fixed during this search, so we could calculate its logarithm once in the capture.

    <details> <summary>Calculate the fixed logarithm once</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 7a083848c0..1f8485e0a7 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -525,8 +525,8 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             //
             //   M^2 - (4*P - 1)*M + 6*P^2 - 2*P - attack_headers * P * 2^(K+2) = 0.
             //
    -        auto cont_bufsize = [&](double period) noexcept -> double {
    -            const double k = std::floor(std::log2(1.5 * period - 0.5) - std::log2(attack_headers));
    +        auto cont_bufsize = [attack_headers, log_attack_headers = std::log2(attack_headers)](double period) noexcept -> double {
    +            const double k = std::floor(std::log2(1.5 * period - 0.5) - log_attack_headers);
                 const double m = 0.5 * (4.0 * period - 1.0 - std::sqrt(std::ldexp(attack_headers * period, int(k + 4)) - 8.0 * period * period + 1.0));
                 return k * period + m;
             };
    

    </details>


    sipa commented at 7:04 PM on October 8, 2026:

    Done.

  92. l0rinc changes_requested
  93. l0rinc commented at 8:14 AM on October 8, 2026: contributor

    Concept ACK on calculating the parameters at startup from elapsed time since genesis, instead of predicting the release's lifetime.

    I found the calculation difficult to review, and the lengthy derivations interspersed with the code made it harder to follow. I left a few simplification suggestions and a question about whether a small memory tradeoff could eliminate the searches, and whether the unbounded floating-point loop is safe (and whether we can eliminate it completely).

  94. sipa force-pushed on Oct 8, 2026
  95. in src/headerssync.h:71 in 9451c93ff3
      66 | +    size_t redownload_buffer_size{0};
      67 | +};
      68 | +
      69 | +/** Find a near-optimal (period, bufsize) headers synchronization configuration for:
      70 | + *
      71 | + * - A specified timespan between genesis and the tip of the chain.
    


    l0rinc commented at 6:16 AM on October 9, 2026:

    e5c9dba headerssync: add computation of optimal sync parameters:

    Nit: The caller uses the current time, even when the local tip is still genesis. I’d describe this as genesis-to-now.

    <details> <summary>Match the age description to its caller</summary>

    diff --git a/src/headerssync.h b/src/headerssync.h
    index 74516120db..d3448d722b 100644
    --- a/src/headerssync.h
    +++ b/src/headerssync.h
    @@ -68,7 +68,7 @@ struct HeadersSyncParams {
     
     /** Find a near-optimal (period, bufsize) headers synchronization configuration for:
      *
    - * - A specified timespan between genesis and the tip of the chain.
    + * - A specified timespan between genesis and now.
      * - A specified number of headers in the minchainwork chain.
      *
      * See the constants in the implementation that control what attack success rate is acceptable.
    

    </details>


    sipa commented at 5:16 PM on October 9, 2026:

    Done.

  96. in src/headerssync.cpp:557 in 9451c93ff3
     552 | +        // the condition is evaluated.
     553 | +        const auto target = double(max_headers - minchainwork_headers) / COMPACT_HEADER_SIZE;
     554 | +        int64_t lo = 0;
     555 | +        int64_t hi = minchainwork_headers + 1;
     556 | +        while (hi - lo > 1) {
     557 | +            const int64_t mid = lo + (hi - lo) / 2;
    


    l0rinc commented at 6:18 AM on October 9, 2026:

    e5c9dba headerssync: add computation of optimal sync parameters:

    Nit: Now that both searches use integer bounds, we could use std::midpoint in both places.

    <details> <summary>Use the standard midpoint helper</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 19b9b517f4..4bdc6f8ff0 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -13,6 +13,7 @@
     #include <algorithm>
     #include <cmath>
     #include <cstdint>
    +#include <numeric>
     #include <tuple>
     #include <utility>
     
    @@ -481,7 +482,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             int64_t succ_buf = fail_buf / 2;
             // Then perform a bisection search to narrow it down.
             while (fail_buf > succ_buf + 1) {
    -            const int64_t try_buf = (succ_buf + fail_buf) / 2;
    +            const int64_t try_buf = std::midpoint(succ_buf, fail_buf);
                 if (HeadersSyncAttackRate(period, try_buf) >= attack_headers) {
                     succ_buf = try_buf;
                 } else {
    @@ -554,7 +555,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             int64_t lo = 0;
             int64_t hi = minchainwork_headers + 1;
             while (hi - lo > 1) {
    -            const int64_t mid = lo + (hi - lo) / 2;
    +            const int64_t mid = std::midpoint(lo, hi);
                 if (mid * cont_bufsize(mid) < target) {
                     lo = mid;
                 } else {
    

    </details>


    sipa commented at 5:16 PM on October 9, 2026:

    Done. Cool, I didn't know about std::midpoint.

  97. in src/headerssync.cpp:450 in 9451c93ff3
     445 | +    constexpr int64_t COMPACT_HEADER_SIZE = sizeof(CompressedHeader) * 8;
     446 | +    // The unit tests would need to be regenerated if this constant were to change, so
     447 | +    // static_assert to give a more targeted error message.
     448 | +    static_assert(COMPACT_HEADER_SIZE == 384);
     449 | +
     450 | +    // The chain has at least one header. Otherwise Step 1 below returns a zero period, which
    


    l0rinc commented at 8:23 AM on October 9, 2026:

    e5c9dba headerssync: add computation of optimal sync parameters:

    Nit: With the integer search, a zero height would give Step 2 invalid clamp bounds, so I would update this explanation. The earlier "real-valued" wording also helped me see that this formula allows fractional inputs.

    <details> <summary>Refresh the comments after the search rewrite</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 4bdc6f8ff0..40b4a05057 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -448,8 +448,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
         // static_assert to give a more targeted error message.
         static_assert(COMPACT_HEADER_SIZE == 384);
     
    -    // The chain has at least one header. Otherwise Step 1 below returns a zero period, which
    -    // Step 2 then divides by.
    +    // Step 2 requires a nonempty range of legal periods [1, minchainwork_headers]
         Assert(minchainwork_headers > 0);
         // Some small attacker success rate is inevitable. A non-positive one cannot be reached, which
         // would make Step 1 below compute with infinities, and the search for bufsize not terminate.
    @@ -516,7 +515,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             //
             // Both of these turn into exact equalities in the continuous relaxation:
             // (1) becomes HeadersSyncAttackRate(period, bufsize) = attack_headers, because there is no need
    -        //     overshoot.
    +        //     to overshoot.
             // (2) becomes mem_timewarp(period, bufsize) = mem_mainchain(period, bufsize), because
             //     timewarp memory goes down with increasing period, and mainchain memory goes up with
             //     increasing period (with bufsize set to keep the attack rate constant). The maximum
    @@ -525,7 +524,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             //     practically always true.
             //
             // For the continuous relaxation, the expression from HeadersSyncAttackRate is used with
    -        // period and bufsize (and thus M in [0, P)). As a function of bufsize it is continuous and
    +        // real-valued period and bufsize (and thus M in [0, P)). As a function of bufsize it is continuous and
             // strictly decreasing, and for the bufsize values sharing a given K it takes values in
             // (2^-K * (3*P - 1) / 4, 2^-K * (3*P - 1) / 2]. Thus (1) can be solved for bufsize
             // directly: K is the unique integer for which attack_headers falls in that range, after
    

    </details>


    sipa commented at 5:16 PM on October 9, 2026:

    Done.

  98. in src/test/headerssync_tests.cpp:118 in 9451c93ff3
     113 | +    constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
     114 | +    int64_t best_period{0};
     115 | +    int64_t best_bufsize{0};
     116 | +    int64_t best_mem{0};
     117 | +    int64_t bufsize{1};
     118 | +    int64_t boundary_failures{0};
    


    l0rinc commented at 8:28 AM on October 9, 2026:

    9451c93 test: verify the sync parameters by brute force:

    Nit: I would find the failing period more useful than a failure count here. Initializing the best memory to the maximum also avoids treating the first iteration differently.

    <details> <summary>Report the period at a boundary failure</summary>

    diff --git a/src/net_processing.h b/src/net_processing.h
    index 6f946bd98a..ec17e8f60a 100644
    --- a/src/net_processing.h
    +++ b/src/net_processing.h
    @@ -23,7 +23,6 @@
     #include <memory>
     #include <optional>
     #include <string>
    -#include <utility>
     #include <vector>
     
     class AddrMan;
    diff --git a/src/test/headerssync_tests.cpp b/src/test/headerssync_tests.cpp
    index 78aaae7cfb..42237afa17 100644
    --- a/src/test/headerssync_tests.cpp
    +++ b/src/test/headerssync_tests.cpp
    @@ -12,6 +12,7 @@
     #include <chrono>
     #include <cmath>
     #include <cstdint>
    +#include <limits>
     
     BOOST_AUTO_TEST_SUITE(headerssync_tests)
     
    @@ -130,20 +131,18 @@ static void CheckOptimalByBruteForce(int64_t max_headers, int64_t minchainwork_h
         constexpr int64_t COMPACT_HEADER_SIZE{sizeof(CompressedHeader) * 8};
         int64_t best_period{0};
         int64_t best_bufsize{0};
    -    int64_t best_mem{0};
    +    int64_t best_mem{std::numeric_limits<int64_t>::max()};
         int64_t bufsize{1};
    -    int64_t boundary_failures{0};
         for (int64_t period{1}; period <= minchainwork_headers; ++period) {
             while (HeadersSyncAttackRate(period, bufsize) >= attack_headers) ++bufsize;
    -        if (bufsize > 1 && HeadersSyncAttackRate(period, bufsize - 1) < attack_headers) ++boundary_failures;
    +        BOOST_CHECK_MESSAGE(HeadersSyncAttackRate(period, bufsize - 1) >= attack_headers, "period=" << period);
             const int64_t mem{std::max(max_headers / period, minchainwork_headers / period + bufsize * COMPACT_HEADER_SIZE)};
    -        if (best_period == 0 || mem < best_mem) {
    +        if (mem < best_mem) {
                 best_period = period;
                 best_bufsize = bufsize;
                 best_mem = mem;
             }
         }
    -    BOOST_CHECK_EQUAL(boundary_failures, 0);
         BOOST_CHECK_EQUAL(expected_period, best_period);
         BOOST_CHECK_EQUAL(expected_bufsize, best_bufsize);
     }
    

    </details>


    sipa commented at 5:08 PM on October 9, 2026:

    I have dropped this commit, because attack_rate is now gone entirely (see further). I could bring it back, with a test-only copy of the old attack_rate code if desirable, but it seems overkill now.

  99. in src/headerssync.cpp:572 in 9451c93ff3
     567 | +    // Step 2: try period_below and the period above it, and pick the one which, together with its
     568 | +    //         corresponding bufsize, results in the lowest memory usage. Both candidates are
     569 | +    //         clamped into the legal range, which matters only when the continuous optimum lies
     570 | +    //         outside of it.
     571 | +    const auto make_config = [&](int64_t candidate) noexcept {
     572 | +        const auto period = std::clamp<int64_t>(candidate, 1, minchainwork_headers);
    


    l0rinc commented at 8:28 AM on October 9, 2026:

    e5c9dba headerssync: add computation of optimal sync parameters:

    Would it make sense to encapsulate the bounds on legal periods in the search, so Step 2 can use its two candidates directly?

    <details> <summary>Try the two endpoints of the legal bracket</summary>

    diff --git a/src/headerssync.cpp b/src/headerssync.cpp
    index 40b4a05057..01786fa0fc 100644
    --- a/src/headerssync.cpp
    +++ b/src/headerssync.cpp
    @@ -494,9 +494,9 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
         // The overall strategy to solve our problem (minimizing peak memory usage while staying under
         // the acceptable attack rate), is as follows:
         //
    -    // Step 1: assume that period and bufsize can be arbitrary real numbers, and find the largest
    -    //         integer period which is still below the optimum of that continuous relaxation.
    -    // Step 2: try that period and the next one, and return the one whose memory usage is lowest
    +    // Step 1: assume that period and bufsize can be arbitrary real numbers, and find the two
    +    //         adjacent legal integer periods around the optimum of that continuous relaxation.
    +    // Step 2: try both periods, and return the one whose memory usage is lowest
         //         (breaking ties toward the smallest period).
         //
         // As a function of the period, the memory usage is the maximum of a decreasing (timewarp) and
    @@ -506,9 +506,9 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
         // for absurdly high attack rates, and even then, it only results in memory usage slightly
         // above the optimal.
     
    -    // Step 1: find the largest integer period below the optimum of the continuous relaxation of
    -    //         the problem (0 if even the smallest legal period exceeds that optimum).
    -    const auto period_below = [&]() {
    +    // Step 1: find the adjacent legal periods around the optimum of the continuous relaxation of
    +    //         the problem (or the two at the end of the legal range it lies beyond).
    +    const auto [period_below, period_above] = [&] {
             // Abstractly, our goal is to find (period, bufsize):
             // (1) such that HeadersSyncAttackRate(period, bufsize) <= attack_headers
             // (2) such that memory_usage(period, bufsize) is minimal
    @@ -545,14 +545,14 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
             //
             // whose left-hand side increases with period. The periods below the continuous optimum are
             // therefore exactly those whose left-hand side is still below the right-hand side, so the
    -        // largest one can be found by bisection. As only integer periods are ever tried, that
    -        // bisection can be performed over the integers, bracketed just outside the legal range so
    -        // that an optimum below 1 or above minchainwork_headers yields that endpoint. Note that
    +        // nearest legal periods can be found by bisection. As only integer periods are ever tried, that
    +        // bisection can be performed over the integers within the legal range. If the optimum lies
    +        // outside that range, return the two periods nearest that endpoint instead. Note that
             // the interval shrinks by construction on every iteration, independent of how accurately
             // the condition is evaluated.
             const auto target = double(max_headers - minchainwork_headers) / COMPACT_HEADER_SIZE;
    -        int64_t lo = 0;
    -        int64_t hi = minchainwork_headers + 1;
    +        int64_t lo = 1;
    +        int64_t hi = minchainwork_headers;
             while (hi - lo > 1) {
                 const int64_t mid = std::midpoint(lo, hi);
                 if (mid * cont_bufsize(mid) < target) {
    @@ -561,15 +561,12 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
                     hi = mid;
                 }
             }
    -        return lo;
    +        return std::pair{lo, hi};
         }();
     
    -    // Step 2: try period_below and the period above it, and pick the one which, together with its
    -    //         corresponding bufsize, results in the lowest memory usage. Both candidates are
    -    //         clamped into the legal range, which matters only when the continuous optimum lies
    -    //         outside of it.
    -    const auto make_config = [&](int64_t candidate) noexcept {
    -        const auto period = std::clamp<int64_t>(candidate, 1, minchainwork_headers);
    +    // Step 2: try period_below and period_above, and pick the one which, together with its
    +    //         corresponding bufsize, results in the lowest memory usage.
    +    const auto make_config = [&](int64_t period) noexcept {
             const auto bufsize = find_bufsize(period);
             return std::tuple{memory_usage(period, bufsize), period, bufsize};
         };
    @@ -577,7 +574,7 @@ HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t min
         // low ones the smallest period.
         const auto [_memory, best_period, best_bufsize] = std::min(
             make_config(period_below),
    -        make_config(period_below + 1));
    +        make_config(period_above));
     
         return {.commitment_period = size_t(best_period), .redownload_buffer_size = size_t(best_bufsize)};
     }
    

    </details>


    sipa commented at 5:16 PM on October 9, 2026:

    Done.

  100. l0rinc approved
  101. l0rinc commented at 8:31 AM on October 9, 2026: contributor

    Only had time for a quick pass and left a few questions. I'll review more carefully later.

  102. sipa force-pushed on Oct 9, 2026
  103. in src/test/fuzz/headerssync.cpp:73 in 02d1084eba
      69 | @@ -65,9 +70,11 @@ FUZZ_TARGET(headers_sync_state, .init = initialize_headers_sync_state_fuzz)
      70 |      const uint256 genesis_hash = genesis_header.GetHash();
      71 |      start_index.phashBlock = &genesis_hash;
      72 |  
      73 | +    // Roughly twice what the optimizer computes for mainnet today, hardcoded so that the covered
    


    l0rinc commented at 1:42 PM on October 10, 2026:

    02d1084 contrib: remove headerssync-params.py:

    Nit: A few commit message and PR details my clankers found, just resolve if irrelevant:

    • The first commit message still describes finding the largest period below the optimum. It could describe the two candidates within the legal range instead, including when the optimum lies outside it.
    • The fuzz commit says "any representable timespan" and "the entire range of legal inputs", but the targets use bounded ranges. "A broad range of inputs" and "the portable timespan range" seem more accurate.
    • In the PR description, "batches of up to 2000" would leave room for the shorter final batch mentioned in the next sentence.
    • The fuzz comment says "roughly twice", but these bounds are closer to three times the current values. "Above" would avoid pinning that explanation to a particular ratio.

    hodlinator commented at 11:41 AM on October 13, 2026:

    Would change it to:

        // Well over double what the optimizer computes for mainnet at the time of writing, hardcoded so that the covered
    

    sipa commented at 2:18 PM on October 20, 2026:

    Reworded.

  104. in src/net_processing.cpp:2142 in 02d1084eba outdated
    2134 | @@ -2132,12 +2135,24 @@ std::unique_ptr<PeerManager> PeerManager::make(CConnman& connman, AddrMan& addrm
    2135 |      return std::make_unique<PeerManagerImpl>(connman, addrman, banman, chainman, pool, warnings, opts);
    2136 |  }
    2137 |  
    2138 | +//! Compute the headers sync DoS-protection parameters for the given chain, using its age (genesis
    2139 | +//! to now) and the height at which its minimum chain work was taken, and log the result.
    2140 | +static HeadersSyncParams MakeHeadersSyncParams(const CChainParams& chainparams)
    2141 | +{
    2142 | +    const auto params{ComputeHeadersSyncParams(Now<NodeSeconds>() - chainparams.GenesisBlock().Time(),
    


    l0rinc commented at 1:43 PM on October 10, 2026:

    02d1084 contrib: remove headerssync-params.py:

    Similarly to #35208, could an incorrect startup clock leave us with an inflated memory allowance after the clock is corrected? Fresh nodes seem to skip the clock check against the stored chain tip for some reason: https://github.com/bitcoin/bitcoin/blob/02d1084eba30de70c45eeaee77312fd417f79bd8/src/node/chainstate.cpp#L249-L255

    According to my clanker, starting with a clock near the Unix epoch and correcting it before headers sync could leave the per-peer commitment allowance about 50x higher than intended - could we defer the estimate after the clock checks?


    sipa commented at 2:19 PM on October 20, 2026:

    Nice catch. Changed to instead just compute the parameters at runtime per connection, now that it's super fast. The need to precompute it dated from when it was too slow to repeatedly recompute.

  105. l0rinc approved
  106. l0rinc commented at 2:18 PM on October 10, 2026: contributor

    Light ACK 02d1084eba30de70c45eeaee77312fd417f79bd8

    I'd appreciate another pair of eyes on the math. The clock correction case still seems worth addressing here or in a follow-up, but isn't blocking for me, it's not systemic.

  107. DrahtBot requested review from darosior on Oct 10, 2026
  108. in src/headerssync.cpp:356 in 02d1084eba
     351 | +//      before passing them to validation. All the headers in this queue are verified against the
     352 | +//      commitment bits created in the first phase before any header is released from it. This means
     353 | +//      {bufsize/period} bits are checked "on top of" each header before actually processing it,
     354 | +//      which results in a commitment structure with roughly {bufsize/period} bits of security, as
     355 | +//      once a header is modified, due to the prevhash inclusion, all future headers necessarily
     356 | +//      change as well.
    


    hodlinator commented at 11:23 AM on October 13, 2026:

    This first section is somewhat duplicating the header: https://github.com/bitcoin/bitcoin/blob/4bacf21a13c2ed25ef9362ca38f26bf0a67d22c9/src/headerssync.h#L59-L100

    Could we refer to that instead, boil this down further, or merge them?


    sipa commented at 2:24 PM on October 20, 2026:

    I've compacted the new text a bit.

  109. in src/test/headerssync_tests.cpp:26 in 02d1084eba
      21 | +// - attack_headers: log-normal distributed with median = LIMIT_FRACTION * minchainwork_headers and
      22 | +//   sigma = 1.5
      23 | +// And then walking in a random direction in 3D log-space, sampling 100 points along the way, and
      24 | +// picking the one whose max_headers and minchainwork_headers are closest to integers among them.
      25 | +// Filtering is applied to remove cases that are too close to machine precision, as those are
      26 | +// genuinely too ambiguous.
    


    hodlinator commented at 11:57 AM on October 13, 2026:

    While the comment is quite descriptive, I feel that it is sufficiently advanced that including the code for generating and filtering would be helpful for the next human needing update the record values. Maybe you consider the comment sufficient for LLMs and therefore not worth including dead code which harms code coverage stats?


    sipa commented at 2:22 PM on October 20, 2026:

    I've included the code in a comment, so it doesn't affect code coverage stats, but still allows re-running (or examining by an LLM for inspiration for future improvements).

  110. in src/headerssync.cpp:485 in 02d1084eba
     480 | +
     481 | +    /** Smallest integer bufsize whose attack rate against (period, bufsize) is below
     482 | +     *  attack_headers. As that rate is strictly decreasing in bufsize, and is exactly
     483 | +     *  attack_headers at real_bufsize(period), this is the next integer above it, but at
     484 | +     *  least 1, because the buffer cannot be empty however permissive attack_headers is. */
     485 | +    const auto int_bufsize = [&](int64_t period) noexcept {
    


    hodlinator commented at 12:04 PM on October 13, 2026:

    Feels clearer explicitly capture here and for make_config:

        const auto int_bufsize = [real_bufsize](int64_t period) noexcept {
    

    sipa commented at 2:22 PM on October 20, 2026:

    I have made all captures explicit.

  111. hodlinator commented at 12:18 PM on October 13, 2026: contributor

    Human linter comments for 02d1084eba30de70c45eeaee77312fd417f79bd8

    Will see if I find time to dig into the math.

    Concept ACK!

  112. sipa force-pushed on Oct 20, 2026
  113. in src/test/headerssync_tests.cpp:157 in 672e628d96 outdated
     152 | +        {3544920946, 609270, 1.9148415213571845e-05, 602, 15347},
     153 | +        {2692360411, 400641, 6.871458291400915e-07, 484, 14508},
     154 | +        {9516499040, 2241730, 5.612939355277791e-05, 1002, 24723},
     155 | +        {2762151481, 103252, 1.7805562892829343e-06, 501, 14355},
     156 | +    };
     157 | +    for (const auto& cas : cases) {
    


    l0rinc commented at 9:05 PM on October 20, 2026:

    1bbf4a1 headerssync: add computation of optimal sync parameters:

    Could we also validate these expected values against the attack-rate formula? That would check the test records themselves, alongside checking that the optimizer reproduces them.

    <details> <summary>Check the buffer using the existing vectors</summary>

    diff --git a/src/test/headerssync_tests.cpp b/src/test/headerssync_tests.cpp
    index d291c11371..363df619f9 100644
    --- a/src/test/headerssync_tests.cpp
    +++ b/src/test/headerssync_tests.cpp
    @@ -6,6 +6,7 @@
     
     #include <boost/test/unit_test.hpp>
     
    +#include <cmath>
     #include <cstdint>
     
     BOOST_AUTO_TEST_SUITE(headerssync_tests)
    @@ -96,7 +97,7 @@ BOOST_AUTO_TEST_SUITE(headerssync_tests)
     
     BOOST_AUTO_TEST_CASE(compute_headers_sync_params)
     {
    -    const struct {
    +    constexpr struct {
             int64_t max_headers;
             int64_t minchainwork_headers;
             double attack_headers;
    @@ -154,10 +155,20 @@ BOOST_AUTO_TEST_CASE(compute_headers_sync_params)
             {9516499040, 2241730, 5.612939355277791e-05, 1002, 24723},
             {2762151481, 103252, 1.7805562892829343e-06, 501, 14355},
         };
    -    for (const auto& cas : cases) {
    -        const auto [period, bufsize] = ComputeHeadersSyncParamsInner(cas.max_headers, cas.minchainwork_headers, cas.attack_headers);
    -        BOOST_CHECK_EQUAL(period, cas.expected_period);
    -        BOOST_CHECK_EQUAL(bufsize, cas.expected_bufsize);
    +    const auto expected_headers{[](int64_t period, int64_t bufsize) {
    +        double accepted{0};
    +        for (int64_t offset{0}; offset < period; ++offset) {
    +            const int64_t checks{(bufsize - offset + period - 1) / period}; // ceil((B-F)/P)
    +            accepted += std::ldexp(double(offset + period * checks - bufsize + period), -int(checks));
    +        }
    +        return accepted / period;
    +    }};
    +    for (const auto& [max_headers, minchainwork_headers, attack_headers, expected_period, expected_bufsize] : cases) {
    +        const auto [period, bufsize]{ComputeHeadersSyncParamsInner(max_headers, minchainwork_headers, attack_headers)};
    +        BOOST_CHECK_EQUAL(period, expected_period);
    +        BOOST_CHECK_EQUAL(bufsize, expected_bufsize);
    +        BOOST_CHECK_LT(expected_headers(expected_period, expected_bufsize), attack_headers);
    +        BOOST_CHECK_GE(expected_headers(expected_period, expected_bufsize - 1), attack_headers);
         }
     }
     
    

    </details>


    sipa commented at 6:17 PM on October 21, 2026:

    Done, and went further by implementing more checks to verify optimality of the test vectors.

  114. in doc/release-process.md:49 in 672e628d96 outdated
      45 | @@ -46,16 +46,9 @@ Release Process
      46 |      - On mainnet, the selected value must not be orphaned, so it may be useful to set the height two blocks back from the tip.
      47 |      - Testnet should be set with a height some tens of thousands back from the tip, due to reorgs there.
      48 |    - `nMinimumChainWork` with the "chainwork" value of RPC `getblockheader` using the same height as that selected for the previous step.
      49 | +  - `minchainwork_height` with that same height. It is the number of headers corresponding to `nMinimumChainWork`, and is used to size the headers synchronization DoS-protection parameters (which are computed at node startup).
    


    l0rinc commented at 9:06 PM on October 20, 2026:

    dce0241 net_processing: compute headers sync parameters at runtime:

    The release-process note and PR description could say that the parameters are computed at runtime whenever a headers sync begins - the current startup wording describes the previous implementation.

    <details> <summary>Align the wording with the current computation point</summary>

    diff --git a/doc/release-process.md b/doc/release-process.md
    index 29ef13845b..3cf70bddd2 100644
    --- a/doc/release-process.md
    +++ b/doc/release-process.md
    @@ -46,7 +46,7 @@ Release Process
         - On mainnet, the selected value must not be orphaned, so it may be useful to set the height two blocks back from the tip.
         - Testnet should be set with a height some tens of thousands back from the tip, due to reorgs there.
       - `nMinimumChainWork` with the "chainwork" value of RPC `getblockheader` using the same height as that selected for the previous step.
    -  - `minchainwork_height` with that same height. It is the number of headers corresponding to `nMinimumChainWork`, and is used to size the headers synchronization DoS-protection parameters (which are computed at node startup).
    +  - `minchainwork_height` with that same height. It is the number of headers corresponding to `nMinimumChainWork`, and is used to size the headers synchronization DoS-protection parameters, which are computed at runtime when a headers sync begins.
       - `m_assumeutxo_data` array should be appended to with the values returned by calling `bitcoin-cli -rpcclienttimeout=0 -named dumptxoutset utxo.dat rollback=<height or hash>`
         The same height considerations for `defaultAssumeValid` apply.
     - Clear the release notes and move them to the wiki (see "Write the release notes" below).
    

    </details>


    sipa commented at 7:31 PM on October 21, 2026:

    Done.

  115. in src/test/headerssync_tests.cpp:20 in 672e628d96 outdated
      15 | +// - inputs: max_headers, minchainwork_headers, attack_headers
      16 | +// - expected outputs: period, bufsize
      17 | +//
      18 | +// The records below were generated by the following script, which finds the exact optimum for each
      19 | +// input through an exhaustive search over all periods, independent of the search strategy the
      20 | +// implementation uses. Run under PyPy for faster results.
    


    l0rinc commented at 9:06 PM on October 20, 2026:

    1bbf4a1 headerssync: add computation of optimal sync parameters:

    I'd find this easier to rerun as a script in contrib/testgen/, with a reference here. That would also let Python tooling check it and keep the test easier to read.

    <details> <summary>Move the generator to contrib/testgen</summary>

    diff --git a/contrib/testgen/README.md b/contrib/testgen/README.md
    index 2f0288df16..4190c6d89d 100644
    --- a/contrib/testgen/README.md
    +++ b/contrib/testgen/README.md
    @@ -6,3 +6,7 @@ To use inside a scripted-diff (or just execute directly):
     
         ./gen_key_io_test_vectors.py valid 70 > ../../src/test/data/key_io_valid.json
         ./gen_key_io_test_vectors.py invalid 70 > ../../src/test/data/key_io_invalid.json
    +
    +To regenerate the headers sync parameter records in `src/test/headerssync_tests.cpp`, run from the repository root:
    +
    +    pypy3 contrib/testgen/gen_headerssync_test_vectors.py
    diff --git a/contrib/testgen/gen_headerssync_test_vectors.py b/contrib/testgen/gen_headerssync_test_vectors.py
    new file mode 100644
    index 0000000000..e963085307
    --- /dev/null
    +++ b/contrib/testgen/gen_headerssync_test_vectors.py
    @@ -0,0 +1,81 @@
    +#!/usr/bin/env python3
    +# Copyright (c) The Bitcoin Core developers
    +# Distributed under the MIT software license, see the accompanying
    +# file COPYING or http://www.opensource.org/licenses/mit-license.php.
    +
    +"""Generate headers sync parameter test vectors by exhaustively searching all periods."""
    +
    +import math
    +import random
    +
    +# Both must match src/headerssync.cpp.
    +COMPACT_HEADER_SIZE = 48 * 8
    +LIMIT_FRACTION = (0.2 / 600.0) / (6000000000.0 / (81 * 8))
    +# Reject solutions which are only better by this much relatively speaking (floating point precision).
    +MARGIN = 1e-9
    +
    +def memory_usage(max_headers, mch, period, bufsize):
    +    return max(max_headers // period, mch // period + bufsize * COMPACT_HEADER_SIZE)
    +
    +def round_error(value):
    +    return abs(value - round(value)) / value
    +
    +def rate(period, bufsize):
    +    """Expected number of forged headers accepted per attack: the closed form of headerssync.cpp."""
    +    k, m = divmod(bufsize, period)
    +    return 2.0**-k * (6 * period**2 - 4 * period * m + m * m - 2 * period + m) / (4 * period)
    +
    +def optimum(max_headers, mch, attack_headers):
    +    """Exhaustively search for the optimal (period, bufsize), and the runner-up it beats."""
    +    best, runner_up, bufsize = None, None, 1
    +    for period in range(1, mch + 1):
    +        while rate(period, bufsize) >= attack_headers:  # bufsize never decreases with period
    +            bufsize += 1
    +        candidate = (memory_usage(max_headers, mch, period, bufsize), period, bufsize)
    +        if best is None or candidate < best:
    +            best, runner_up = candidate, best
    +        elif runner_up is None or candidate < runner_up:
    +            runner_up = candidate
    +    return best, runner_up
    +
    +def sample():
    +    """One record, or None if its inputs are illegal or its optimum is too ambiguous."""
    +    # Start with random initial values:
    +    # - A timespan, log-normally distributed with mode 12 years and mean 18 years.
    +    sigma = math.sqrt(math.log(18.0 / 12.0) / 1.5)
    +    timespan = random.lognormvariate(math.log(12.0 * 365.25 * 86400) + sigma**2, sigma)
    +    # - minchainwork_headers, exponentially distributed with mean 1000000.
    +    mch = random.expovariate(1.0 / 1000000.0)
    +    # - attack_headers, log-normally distributed with median LIMIT_FRACTION * mch, sigma 1.5.
    +    attack_headers = random.lognormvariate(math.log(LIMIT_FRACTION * mch), 1.5)
    +    # Convert to a starting point in (max_headers, minchainwork_headers, attack_headers) space.
    +    start = [6.0 * timespan, mch, attack_headers]
    +    # Pick a random point on the 3D sphere with radius 1/200 as (logarithm of) walk step factor to explore.
    +    direction = [random.gauss(0.0, 1.0) for _ in range(3)]
    +    step = [d * 0.005 / math.hypot(*direction) for d in direction]
    +    # Pick 100 points, starting from start, each a further factor exp(step) away from the previous one.
    +    walk = ([c * math.exp(d * i) for c, d in zip(start, step)] for i in range(100))
    +    # Among those 100 points, pick the one whose max_headers and mch are closest to integers.
    +    max_headers, mch, attack_headers = min(walk, key=lambda pt: max(round_error(pt[0]), round_error(pt[1])))
    +    max_headers, mch = round(max_headers), round(mch)
    +    # Reject if outside the inputs the optimizer is defined for.
    +    if mch < 1 or max_headers <= 2 * mch:
    +        return None
    +    best, runner_up = optimum(max_headers, mch, attack_headers)
    +    _, period, bufsize = best
    +    # Reject if too close to the runner up, to avoid depending on floating point rounding behavior.
    +    if not runner_up or runner_up[0] <= best[0] * (1.0 + MARGIN):
    +        return None  # another period is just as good
    +    if not (rate(period, bufsize) < attack_headers * (1.0 - MARGIN)
    +            and rate(period, bufsize - 1) > attack_headers * (1.0 + MARGIN)):
    +        return None  # another bufsize is just as good
    +    return max_headers, mch, attack_headers, period, bufsize
    +
    +random.seed(0)
    +count = 0
    +while count < 50:
    +    record = sample()
    +    if not record:
    +        continue
    +    print("        {%d, %d, %r, %d, %d}," % record)
    +    count += 1
    diff --git a/src/test/headerssync_tests.cpp b/src/test/headerssync_tests.cpp
    index 363df619f9..4c4e7b2b64 100644
    --- a/src/test/headerssync_tests.cpp
    +++ b/src/test/headerssync_tests.cpp
    @@ -16,84 +16,9 @@ BOOST_AUTO_TEST_SUITE(headerssync_tests)
     // - inputs: max_headers, minchainwork_headers, attack_headers
     // - expected outputs: period, bufsize
     //
    -// The records below were generated by the following script, which finds the exact optimum for each
    -// input through an exhaustive search over all periods, independent of the search strategy the
    -// implementation uses. Run under PyPy for faster results.
    -//
    -// import math
    -// import random
    -//
    -// # Both must match src/headerssync.cpp.
    -// COMPACT_HEADER_SIZE = 48 * 8
    -// LIMIT_FRACTION = (0.2 / 600.0) / (6000000000.0 / (81 * 8))
    -// # Reject solutions which are only better by this much relatively speaking (floating point precision).
    -// MARGIN = 1e-9
    -//
    -// def memory_usage(max_headers, mch, period, bufsize):
    -//     return max(max_headers // period, mch // period + bufsize * COMPACT_HEADER_SIZE)
    -//
    -// def round_error(value):
    -//     return abs(value - round(value)) / value
    -//
    -// def rate(period, bufsize):
    -//     """Expected number of forged headers accepted per attack: the closed form of headerssync.cpp."""
    -//     k, m = divmod(bufsize, period)
    -//     return 2.0**-k * (6 * period**2 - 4 * period * m + m * m - 2 * period + m) / (4 * period)
    -//
    -// def optimum(max_headers, mch, attack_headers):
    -//     """Exhaustively search for the optimal (period, bufsize), and the runner-up it beats."""
    -//     best, runner_up, bufsize = None, None, 1
    -//     for period in range(1, mch + 1):
    -//         while rate(period, bufsize) >= attack_headers:  # bufsize never decreases with period
    -//             bufsize += 1
    -//         candidate = (memory_usage(max_headers, mch, period, bufsize), period, bufsize)
    -//         if best is None or candidate < best:
    -//             best, runner_up = candidate, best
    -//         elif runner_up is None or candidate < runner_up:
    -//             runner_up = candidate
    -//     return best, runner_up
    -//
    -// def sample():
    -//     """One record, or None if its inputs are illegal or its optimum is too ambiguous."""
    -//     # Start with random initial values:
    -//     # - A timespan, log-normally distributed with mode 12 years and mean 18 years.
    -//     sigma = math.sqrt(math.log(18.0 / 12.0) / 1.5)
    -//     timespan = random.lognormvariate(math.log(12.0 * 365.25 * 86400) + sigma**2, sigma)
    -//     # - minchainwork_headers, exponentially distributed with mean 1000000.
    -//     mch = random.expovariate(1.0 / 1000000.0)
    -//     # - attack_headers, log-normally distributed with median LIMIT_FRACTION * mch, sigma 1.5.
    -//     attack_headers = random.lognormvariate(math.log(LIMIT_FRACTION * mch), 1.5)
    -//     # Convert to a starting point in (max_headers, minchainwork_headers, attack_headers) space.
    -//     start = [6.0 * timespan, mch, attack_headers]
    -//     # Pick a random point on the 3D sphere with radius 1/200 as (logarithm of) walk step factor to explore.
    -//     direction = [random.gauss(0.0, 1.0) for _ in range(3)]
    -//     step = [d * 0.005 / math.hypot(*direction) for d in direction]
    -//     # Pick 100 points, starting from start, each a further factor exp(step) away from the previous one.
    -//     walk = ([c * math.exp(d * i) for c, d in zip(start, step)] for i in range(100))
    -//     # Among those 100 points, pick the one whose max_headers and mch are closest to integers.
    -//     max_headers, mch, attack_headers = min(walk, key=lambda pt: max(round_error(pt[0]), round_error(pt[1])))
    -//     max_headers, mch = round(max_headers), round(mch)
    -//     # Reject if outside the inputs the optimizer is defined for.
    -//     if mch < 1 or max_headers <= 2 * mch:
    -//         return None
    -//     best, runner_up = optimum(max_headers, mch, attack_headers)
    -//     _, period, bufsize = best
    -//     # Reject if too close to the runner up, to avoid depending on floating point rounding behavior.
    -//     if not runner_up or runner_up[0] <= best[0] * (1.0 + MARGIN):
    -//         return None  # another period is just as good
    -//     if not (rate(period, bufsize) < attack_headers * (1.0 - MARGIN)
    -//             and rate(period, bufsize - 1) > attack_headers * (1.0 + MARGIN)):
    -//         return None  # another bufsize is just as good
    -//     return max_headers, mch, attack_headers, period, bufsize
    -//
    -// random.seed(0)
    -// count = 0
    -// while count < 50:
    -//     record = sample()
    -//     if not record:
    -//         continue
    -//     print("        {%d, %d, %r, %d, %d}," % record)
    -//     count += 1
    +// The records below were generated by contrib/testgen/gen_headerssync_test_vectors.py
    +// The generator finds the exact optimum through an exhaustive search over all periods, independently of the implementation's search strategy
    +// Run from the repository root: pypy3 contrib/testgen/gen_headerssync_test_vectors.py
     
     BOOST_AUTO_TEST_CASE(compute_headers_sync_params)
     {
    

    </details>


    sipa commented at 6:20 PM on October 21, 2026:

    I'm not taking this.

    The test generator script isn't particularly important, it's just selecting some cases to try perhaps slightly better than purely randomly. There is no requirement that the test vectors match its output, or even that it's reproducible. Burdening the project with maintaining this code is overkill.

    If you think it's undesirable to have this code in the comment, I'd rather delete it entirely and replace it with a single sentence explaining what considerations were made when generating the vectors.


    l0rinc commented at 7:05 PM on October 21, 2026:

    Thanks for considering, please resolve the suggestion.

  116. l0rinc approved
  117. l0rinc commented at 9:14 PM on October 20, 2026: contributor

    lightly tested ACK 672e628d96bf1d1f2890ff666b4d16f37309b61e

    Since my last review, the parameters are computed whenever a headers sync starts (addressing my clock-correction concern), and the test-vector generation script is now included. I like the more compact code comments, and I'd still appreciate another pair of eyes to double-check the math.

    The fixed test vectors could also be checked against the actual attack-rate formula, and the Python generator could move to contrib/testgen/, but neither suggestion is blocking for me.

  118. DrahtBot requested review from hodlinator on Oct 20, 2026
  119. headerssync: add computation of optimal sync parameters
    Add ComputeHeadersSyncParams(), which finds (near-)optimal commitment
    period and redownload buffer size values for the headers sync
    DoS-protection mechanism, given the timespan since genesis and the
    number of headers in the minimum-chainwork chain, along with the
    internal ComputeHeadersSyncParamsInner() it is based on.
    
    The attack model treats headers as arriving one at a time, which
    slightly overestimates what an attacker can achieve (in reality headers
    arrive in batches that are only accepted in their entirety), but leads
    to a closed-form expression for the expected number of accepted forged
    headers. That expression can be inverted, which gives the buffer size
    needed for a given period directly, so only the period is searched for:
    by bisecting the legal range for the two adjacent periods which straddle
    the optimum of the problem's continuous relaxation, and comparing those
    two. If that optimum lies outside the legal range, the two periods
    nearest the corresponding end are compared instead.
    
    Also widen next_height in the presync header validation to int64_t,
    matching the m_current_height it is computed from. A presync chain is
    only bounded by 6 headers per second since genesis, so it can exceed
    INT_MAX headers, which the implicit truncation would turn into a
    negative height.
    
    The unit test checks 50 inputs against their exact optima, found by an
    exhaustive search over all periods. The script which samples such inputs
    and computes their optima is included with the test records.
    3ddc510850
  120. fuzz: add headers sync parameter computation targets
    Add two fuzz targets covering the headers sync parameter optimizer over
    a broad range of legal inputs, asserting that the resulting
    configuration is sane (and, implicitly, that the computation terminates
    without crashing or throwing):
    
    - headers_sync_params_inner exercises ComputeHeadersSyncParamsInner
      directly. minchainwork_headers is a block height and thus fits an int;
      max_headers must exceed twice that (which the caller guarantees by
      clamping) and is at most 6 headers/s over the longest timespan that
      std::chrono::seconds is guaranteed to represent; attack_headers is a
      positive rate, parametrized logarithmically over a wide range of
      magnitudes.
    
    - headers_sync_params exercises ComputeHeadersSyncParams over the
      portable range of std::chrono::seconds, and any int-height
      minchainwork_headers.
    
    Both targets check that the returned period is positive and no larger
    than the minimum-chainwork chain, and that the redownload buffer covers
    at least one commitment period whenever the acceptable attack rate is
    low enough to require any commitment verification at all (which it
    always is for the rates ComputeHeadersSyncParams uses).
    7281e51459
  121. sipa force-pushed on Oct 21, 2026
  122. l0rinc approved
  123. l0rinc commented at 7:25 PM on October 21, 2026: contributor

    Lightly tested diff re-ACK 0457cec22a273eba31e92950bd485bf72c864ab0

  124. net_processing: compute headers sync parameters at runtime
    So far, the headers sync parameters are committed to the repository,
    as part of the chain parameters. This requires maintainers to
    occasionally recompute them, based on a prediction of how long the
    release is likely to be used.
    
    With the optimization algorithm now available in headerssync.cpp, it
    becomes possible to instead do this computation at runtime. This is
    less work, with fewer ways for maintainers and reviewers to get it
    wrong, and automatically adjusts based on clock time.
    
    The computation takes well under a microsecond, so it is simply redone
    whenever a headers sync is started. Doing it there, rather than once at
    startup, keeps the commitment period consistent with the bound that
    HeadersSyncState derives from the clock on how many commitments a peer
    may make us store: a node which starts up with a badly wrong clock and
    has it corrected before syncing would otherwise use a period sized for
    a much shorter chain than a peer can then feed it, inflating that bound
    (by a factor 50 for a clock which starts out before genesis). The fuzz
    targets which reach this code fix the clock around message processing,
    so the parameters they end up with do not vary with the fuzz input.
    
    As the chain parameters no longer hold them, HeadersSyncParams moves to
    headerssync.h, next to the code which computes and consumes it.
    
    Co-Authored-By: Lőrinc <pap.lorinc@gmail.com>
    990b3fa6d0
  125. contrib: remove headerssync-params.py
    The headers sync parameters are now computed at runtime, so the tool
    that used to generate them for chainparams.cpp has no purpose anymore.
    4c04debcd7
  126. test: verify the sync parameters by brute force
    The test vectors compare against values obtained by a separate
    exhaustive search, so they detect changes in behavior, but not a mistake
    in that search. Establish instead, for each of them, that the
    configuration really is the one with the lowest memory usage, by
    evaluating the attack rate for every integer period, and for every
    integer bufsize up to the point where it becomes acceptable. Periods
    whose redownload buffer alone outweighs the best configuration found so
    far are ruled out by a bound rather than tried, which keeps the search
    over the recorded chain lengths affordable.
    
    The recorded bufsize is in addition held against the attack model
    itself, by averaging the accepted headers over the offset at which the
    attacker's first commitment check falls, instead of against the closed
    form that model is condensed into and inverted.
    
    Co-Authored-By: Lőrinc <pap.lorinc@gmail.com>
    0316d0dc9e
  127. sipa force-pushed on Oct 21, 2026
  128. l0rinc commented at 7:32 PM on October 21, 2026: contributor

    reACK 0316d0dc9ec6a1abbe84489c44a47a2dd94c79ee

  129. in src/headerssync.cpp:356 in 0316d0dc9e
     351 | +//      means {n} is restricted to the honest chain's length before reaching minchainwork.
     352 | +//
     353 | +//    - When downloading a (likely false) chain of {n} headers that never reaches the chainwork
     354 | +//      threshold, we will consume per-peer memory that is sufficient to store {n/period}
     355 | +//      commitment bits. Such a chain may be very long, by exploiting the timewarp bug to avoid
     356 | +//      ramping up difficulty. There is however an absolute limit on how long such a chain can be: 6
    


    hodlinator commented at 11:56 AM on October 23, 2026:

    nit: Decreasing the difficulty allows even faster hashing?

    //      commitment bits. Such a chain may be very long, by exploiting the timewarp bug to minimize
    //      the difficulty. There is however an absolute limit on how long such a chain can be: 6
    

    sipa commented at 12:04 PM on October 24, 2026:

    If they fork off after genesis, indeed, but in this attack scenario (just wasting the victim's per-peer memory using the commitment bit data structure) I believe it is always better to start at genesis. Will address if I retouch.

  130. in src/headerssync.cpp:561 in 0316d0dc9e
     556 | +    return {.commitment_period = size_t(best_period), .redownload_buffer_size = size_t(best_bufsize)};
     557 | +}
     558 | +
     559 | +// Derive max_headers (the longest a valid chain could be by now) and the attack_headers budget from
     560 | +// the chain's age and minimum-chain-work header count, then optimize.
     561 | +HeadersSyncParams ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_headers)
    


    hodlinator commented at 12:09 PM on October 23, 2026:

    nanonit: Could keep to the name in Consensus::Params:

    // the chain's age and minimum-chain-work height, then optimize.
    HeadersSyncParams ComputeHeadersSyncParams(std::chrono::seconds timespan, int64_t minchainwork_height)
    

    sipa commented at 12:05 PM on October 24, 2026:

    Will address if I retouch.

  131. in src/headerssync.cpp:378 in 0316d0dc9e
     373 | +//    somewhat later than genesis, as long as the difficulty doesn't get too high. This reduces
     374 | +//    the attacker bandwidth required at the cost of higher PoW needed for constructing the
     375 | +//    alternate chain. This trade-off is ignored here, as it results in at most a small constant
     376 | +//    factor in attack rate.
     377 | +
     378 | +HeadersSyncParams ComputeHeadersSyncParamsInner(int64_t max_headers, int64_t minchainwork_headers, double attack_headers)
    


    hodlinator commented at 12:26 PM on October 23, 2026:

    nit: How about swapping the function definition order for ComputeHeadersSyncParams() & ComputeHeadersSyncParamsInner() to match the order in the header? In order to attempt understanding the attack_headers argument coming in it's helpful to at least have glimpsed at the body of ComputeHeadersSyncParams().


    sipa commented at 12:05 PM on October 24, 2026:

    Will address if I retouch.

  132. in src/headerssync.cpp:570 in 0316d0dc9e
     565 | +    /** Combined processing bandwidth from all attackers to one victim. [bit/s]
     566 | +     *
     567 | +     * 6 Gbit/s is approximately the speed at which a single thread of a Ryzen 5950X CPU thread can
     568 | +     * hash headers. In practice, the victim's network bandwidth and network processing overheads
     569 | +     * probably impose a far lower number, but it's a useful upper bound. */
     570 | +    constexpr double ATTACK_BANDWIDTH{6000000000.0};
    


    hodlinator commented at 12:27 PM on October 23, 2026:

    nanonit: Helps human eyes:

        constexpr double ATTACK_BANDWIDTH{6'000'000'000.0};
    

    sipa commented at 12:05 PM on October 24, 2026:

    Will address if I retouch.

  133. in src/headerssync.cpp:582 in 0316d0dc9e
     577 | +    /** How many bits a header uses in the P2P protocol: a block header, plus the trailing zero
     578 | +     *  transaction count headers are relayed with. [bits] */
     579 | +    constexpr int64_t NET_HEADER_SIZE{(sizeof(CBlockHeader) + 1) * 8};
     580 | +    /** What rate of headers worth of RAM attackers are allowed to cause in the victim. [headers/s] */
     581 | +    constexpr double LIMIT_HEADERRATE{ATTACK_FRACTION / BLOCK_INTERVAL};
     582 | +    /** How many headers can attackers (jointly) send a victim per second. [headers/s] */
    


    hodlinator commented at 12:39 PM on October 23, 2026:

    Why jointly, isn't it more correct that multiple headers sync peers could be sending you these low-difficulty headers in parallel?

        /** How much additional permanent memory usage is an attacking peer allowed to cause in the
         *  victim, expressed as fraction of the normal memory usage due to mainchain growth, for the
         *  duration the attack is sustained. [unitless]. 0.2 means that attackers, while they keep up the
         *  attack, can cause permanent memory usage due to headers storage to grow at 1.2 header per
         *  BLOCK_INTERVAL. */
        constexpr double ATTACK_FRACTION{0.2};
        /** How many bits a header uses in the P2P protocol: a block header, plus the trailing zero
         *  transaction count headers are relayed with. [bits] */
        constexpr int64_t NET_HEADER_SIZE{(sizeof(CBlockHeader) + 1) * 8};
        /** What rate of headers worth of RAM attackers are allowed to cause in the victim. [headers/s] */
        constexpr double LIMIT_HEADERRATE{ATTACK_FRACTION / BLOCK_INTERVAL};
        /** How many headers can an attacking peer send a victim per second. [headers/s] */
    

    hodlinator commented at 9:42 AM on October 24, 2026:

    (To affect RAM usage the attacker(s) would need unique header chains for each peer but the difficulty could be timewarped to a minimum before forking).


    sipa commented at 12:17 PM on October 24, 2026:

    Yes, there may be multiple attackers in parallel. But their joint bandwidth to the victim is what matters, not their individual bandwidth.

    The attack here consists of the attacker(s) sending the honest main chain during the commitment phase, and then giving a forged low-difficulty headers chain during the redownload phase. On average, for every attempt at this some tiny amount of these headers will be accepted into the blockchain index. If an someone could perform this attack an infinite number of times, our memory growth would be infinite too.

    What saves us is that the attack costs download volume; each attack requires sending the entirety of the main chain's headers during a commitment phase. And we are okay with accepting a tiny amount of forged headers into our index, as long as that growth is sufficiently slow. After all, we're already okay with our blockchain index growing at a rate of ~1 block per 10 minutes. If all an attacker can do is increase that slightly (here, to 1.2 blocks per 10 minutes), then that's fine.

    So the reasoning is: we accept that attacks are possible, and will be successful occasionally, but on average they cannot get more than a certain number of headers accepted per mainchain they send us. Since the attacker has a finite amount of bandwidth to us, that translates to a small finite amount of headers accepted per unit of time. As long as that is no more than 0.2 headers per 10 minutes, we are okay.

    But what happens when there are multiple attackers, all performing the same attack on us? If each individual attacker was limited to 6 Gbit/s and our parameters are chosen so that a single 6 Gbit/s attacker could get 0.2 headers per 10 minutes accepted, then 1000 attackers doing the same thing in parallel could get 200 headers per 10 minutes accepted which would be totally unacceptable. However, this is not a concern: it's not each individual attacker that is limited to 6 Gbit/s, but all attackers combined. So, the 6 Gbit/s number must represent the joint bandwidth all attackers together have to us, which is indeed limited (our own network connection is a bound, as is the rate at which we can compute hashes).

  134. in src/headerssync.cpp:585 in 0316d0dc9e
     580 | +    /** What rate of headers worth of RAM attackers are allowed to cause in the victim. [headers/s] */
     581 | +    constexpr double LIMIT_HEADERRATE{ATTACK_FRACTION / BLOCK_INTERVAL};
     582 | +    /** How many headers can attackers (jointly) send a victim per second. [headers/s] */
     583 | +    constexpr double NET_HEADERRATE{ATTACK_BANDWIDTH / NET_HEADER_SIZE};
     584 | +    /** What fraction of headers sent by attackers can at most be accepted by a victim. [unitless] */
     585 | +    constexpr double LIMIT_FRACTION{LIMIT_HEADERRATE / NET_HEADERRATE};
    


    hodlinator commented at 12:57 PM on October 23, 2026:

    Would expect attack_headers => 1, but LIMIT_FRACTION becomes 3.6000000000000005E-11. For a mainchainwork_headers value of 966143 that makes the attack_headers parameter equal 0.00003478.... Going by the above comment, the number of headers sent by attackers that can at most be accepted by a victim is... 0.00003478...?! Does that mean zero attack headers are accepted? Or do we round it up to only 1 attack header? Would it be possible to better explain this in the comment?


    sipa commented at 12:48 PM on October 24, 2026:

    (disregard my now-deleted earlier comment, I missed you're already multiplying with minchainwork_headers)

    The number is correct, in expectation. We permit 0.00003478 headers to be accepted per attack. That is, an attacker that performs the attack 1000000000 times, will on average get no more than 3478 headers accepted.

  135. DrahtBot requested review from hodlinator on Oct 24, 2026
  136. fametrano commented at 6:12 AM on October 25, 2026: none

    tACK 0316d0dc9ec6a1abbe84489c44a47a2dd94c79ee

    Compiled from this branch, the two new functions return the exact optimum of their model for all five chains, every six hours from 2026-10-25 to 2041. The same holds on a grid of heights from 1 to 5 million and clocks, some before genesis. The chosen bufsize always meets the attack budget. Summing the attack model term by term, in exact arithmetic, gives the closed form for every period up to 16 and bufsize up to 60. I checked this with a Python port and a brute-force search; I can share the scripts as a gist. The new unit tests and p2p_headers_sync_with_minchainwork.py pass, and the parameters its nodes log match the port at each node's clock.

    At 2026-10-25 00:00 UTC the parameters, as period/bufsize, are mainnet 597/14697, testnet3 590/13096, testnet4 217/5604, signet 345/8770 and regtest 535/14517. Mainnet needs 689 KiB per peer against 722 KiB for the hard-coded 649/15400. On 2029-04-10, the date that pair was sized for, mainnet needs 14.7 KiB more than the pair, as you estimated. Testnet4 and signet fall much further, from 616/16199 and 629/15885, because each chain now uses its own genesis time, like HeadersSyncState's commitment bound when syncing from genesis. Rerunning the old script with 2009-01-03, mainnet's genesis date, reproduces all four hard-coded pairs.


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-10-30 09:40 UTC