headersync: do parameter search at runtime #35642

pull sipa wants to merge 4 commits into bitcoin:master from sipa:202607_headerssync_cpp changing 13 files +522 −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 during startup. 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. 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
    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

    No conflicts as of last run.

    <!--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};
    
  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.
    
  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?

  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.");
    
  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.

  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.

  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. 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 for the largest integer one below the optimum of the
    problem's continuous relaxation, and comparing it with the next one.
    
    The unit test checks 50 inputs against their exact optima, found by an
    exhaustive search over all periods.
    b4e6f5a09a
  103. fuzz: add headers sync parameter computation targets
    Add two fuzz targets covering the headers sync parameter optimizer over
    the entire 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 any
      positive rate, parametrized logarithmically.
    
    - headers_sync_params exercises ComputeHeadersSyncParams with any
      representable timespan, 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).
    370bb5e2a0
  104. 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 even tests and fuzz
    targets which construct a PeerManager per iteration can simply do it
    every time. Those fuzz targets already fix the clock before construction,
    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>
    0aacb71ae8
  105. 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.
    02d1084eba
  106. sipa force-pushed on Oct 9, 2026
  107. 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
    
  108. in src/net_processing.cpp:2142 in 02d1084eba
    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?

  109. l0rinc approved
  110. 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.

  111. DrahtBot requested review from darosior on Oct 10, 2026
  112. 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?

  113. 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?

  114. 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 {
    
  115. 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!


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-16 12:51 UTC

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