Skip to content

net: bound the size of headers messages we send - #266

Open
djh58 wants to merge 2 commits into
1.x.xfrom
djh58/headers-message-size
Open

djh58 wants to merge 2 commits into
1.x.xfrom
djh58/headers-message-size

Conversation

@djh58

@djh58 djh58 commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

AuxPoW headers carry the parent coinbase transaction and merkle branches, so a headers message is no longer bounded by MAX_HEADERS_RESULTS alone. Every peer rejects a message larger than MAX_PROTOCOL_MESSAGE_LENGTH (4,000,000 bytes) and ends the connection. A run of blocks with large AuxPoW payloads could therefore make a getheaders response, or a header announcement, larger than any peer accepts, and headers sync could not get past it.

What changed (src/net_processing.cpp):

  • getheaders responses stop before the header that would make the message exceed MAX_HEADERS_MESSAGE_SIZE (= MAX_PROTOCOL_MESSAGE_LENGTH). A response always includes at least one header.
    • The headers are collected under cs_main and serialized after it is released. The CBlock copies share the block index's immutable AuxPoW payloads.
    • pindexBestHeaderSent is reset to the last header actually sent, after the message is queued. An announcement made while the message was being serialized is repeated, which keeps the existing "reset, not max" behaviour.
  • Header announcements in SendMessages that would exceed the limit fall back to an inv for the tip. The peer then asks for the headers through the bounded getheaders path.
  • Receiving: the new MayHaveMoreHeaders() treats a headers message as possibly cut short when it has the maximum number of headers, or when it is at least THRESHOLD_HEADERS_MESSAGE_SIZE = MAX_PROTOCOL_MESSAGE_LENGTH - MAX_BLOCK_SERIALIZED_SIZE (2,000,000) bytes.
    • A message cut short by size reaches that size, because the header that did not fit belongs to a block of at most MAX_BLOCK_SERIALIZED_SIZE bytes. A single header never does.
    • It replaces the count-only check in the four places that used it: the request for more headers, starting a low-work headers sync, full_headers_message for HeadersSyncState (presync and redownload), and the IBD check in UpdatePeerStateForReceivedHeaders.
    • A last batch that happens to be 2,000,000 bytes or more costs one extra getheaders, answered with a smaller or empty message.

Consensus rules are unchanged. There is no protocol version bump.

Refs #258

Testing

  • Built locally.
  • Ran focused unit or functional tests for the changed area.
  • Ran lint or formatting checks relevant to this change.
  • Not run. Reason:

Built with cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DBUILD_BENCH=OFF -DBUILD_FUZZ_BINARY=OFF (targets test_qbit qbitd qbit-cli). Results at this head, f18fb5069a:

New functional test test/functional/p2p_auxpow_headers_size.py (registered in test_runner.py):

  • Mines four consensus-valid regtest blocks, each with a 1,400,000-byte parent coinbase output, accepted through submitauxblock.
  • Checks that a getheaders response to a raw peer is at most 4,000,000 and at least 2,000,000 bytes, is cut short, and continues contiguously.
  • Checks that a header announcement that would be about 5.6 MB (after invalidateblock/reconsiderblock) falls back to inv.
  • Checks that two nodes sync to the tip without a disconnect: one on the normal path, one with -minimumchainwork at the tip, so it goes through presync and redownload.
  • Without the net_processing.cpp change it fails (AssertionError: 4000000 < 5602133).

Unit tests: build/bin/test_qbit --run_test='pqc_tests:pqc_sigcache_tests:txvalidationcache_tests:script_tests:net_tests:net_peer_connection_tests:net_peer_eviction_tests:netbase_tests:denialofservice_tests:peerman_tests:headers_sync_chainwork_tests:blockencodings_tests': 133 cases, 586,298 assertions, no errors.

Functional tests: build/test/functional/test_runner.py --jobs=4 over the headers-sync, compact-block and AuxPoW scripts: 20 of 20 runs passed.

  • p2p_auxpow_headers_size, feature_auxpow, feature_auxpow_commitment_activation, feature_minchainwork, feature_minchainwork_requested_block
  • p2p_block_sync (v1, v2), p2p_compactblocks, p2p_compactblocks_blocksonly, p2p_compactblocks_hb (v1, v2), p2p_fingerprint, p2p_getdata
  • p2p_headers_sync_with_minchainwork, p2p_ibd_stalling (v1, v2), p2p_initial_headers_sync, p2p_invalid_messages, p2p_sendheaders, p2p_unrequested_blocks

Earlier runs on the same tree: full ctest --test-dir build -j4, 176 of 176 passed (script_assets_tests skipped, as on the base). lint-includes, lint-files, lint-tests, lint-locale-dependence, lint-include-guards and git diff --check are clean.

Mixed versions: checked with a base-branch qbitd standing in for an earlier release (the headers logic in net_processing.cpp is identical between v1.0.0 and the base). A real v1.0.0 binary was not run.

Target Branch

  • This PR targets main or a maintainer-requested release branch such as 0.1.x.

This targets 1.x.x for v1.0.1 (#181 prepares that release), and then 1.1.0-rc2.

Risk / Review Notes

  • Consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.
  • No consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.

Notes:

  • P2P only. Consensus rules are unchanged, and the change needs no protocol version bump: a message is cut only where the whole message would have exceeded every peer's receive limit.
  • Peers running earlier versions accept every message this produces, where an oversized message previously ended the connection. They treat a message cut short by size as the end of the headers:
    • Once their headers chain has reached their minimum chain work, they continue at the next block announcement.
    • Before that, during their low-work headers sync, they do not continue past such a message. This is no worse than before, when the connection ended at the same point.
  • Rollout: the change helps when the sending node runs it. A node running an earlier version still sends the oversized message, and an upgraded receiver still rejects it. Upgrading the nodes that serve headers is what restores sync across large-AuxPoW blocks.
  • Size threshold assumption: it assumes that the AuxPoW kept with each header is the one of its block. A header first received with a different, larger AuxPoW keeps it, so a message cut short before such a header can be smaller than the threshold. The receiver then treats it as complete and continues at the next block announcement.
  • Unchanged here: the low-work redownload's existing limit on retained AuxPoW bytes (MAX_REDOWNLOAD_AUXPOW_SERIALIZED_SIZE in src/headerssync.cpp), and any limit on AuxPoW payload size. Both are separate decisions.

Docs / Process Impact

Choose exactly one:

  • I updated public docs because this PR changes user-visible behavior, integration guidance, release/process guidance, or expected validation.
  • No public docs update needed. Reason: P2P message sizing only; no RPC, option or consensus change. The v1.0.1 release notes could mention that nodes now keep headers messages within the protocol message limit.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

AuxPoW headers carry the parent coinbase transaction and merkle branches,
so a 'headers' message is no longer bounded by MAX_HEADERS_RESULTS alone,
and every peer rejects a message larger than MAX_PROTOCOL_MESSAGE_LENGTH.

- getheaders responses stop before the header that would make the message
  exceed MAX_PROTOCOL_MESSAGE_LENGTH, always including at least one
  header, and are serialized after cs_main is released.
- Header announcements that would exceed the limit fall back to an inv
  for the tip.
- A received 'headers' message with fewer than the maximum number of
  headers but at least MAX_PROTOCOL_MESSAGE_LENGTH -
  MAX_BLOCK_SERIALIZED_SIZE bytes may have more headers behind it, in both
  the normal and the low-work headers sync. A message cut short by size
  reaches that size, because the header that did not fit belongs to
  a block of at most MAX_BLOCK_SERIALIZED_SIZE bytes.

Peers running earlier versions accept every message this produces, where
an oversized message previously ended the connection. They treat a
message cut short by size as the end of the headers:

- Once their headers chain has reached their minimum chain work, they
  continue at the next block announcement.
- Before that, during their low-work headers sync, they do not continue
  past such a message. This is no worse than before, when the connection
  ended at the same point.

The size threshold assumes that the AuxPoW kept with each header is the
one of its block. A header first received with a different, larger AuxPoW
keeps it, so a message cut short before such a header can be smaller than
the threshold. The receiver then treats it as complete and continues at
the next block announcement.

Consensus rules are unchanged.

Refs #258

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@djh58

djh58 commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T17:36:51.714806Z 1c03c4f New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: f18fb5069a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kiwidream

Copy link
Copy Markdown
Member

Review input for #266 (head f18fb5069a), from the parallel #258 work.

#272 is stacked on this branch. It adds 8 unit tests and doc/release-notes-266.md with no implementation changes. The tests cover:

  • count and size boundaries
  • a byte-identical 2000-header reply
  • large-AuxPoW replies staying within the limit and walking to the tip
  • the case where the sender is still on v1.0.0
  • announcement → inv
  • HeadersSyncState PRESYNC/REDOWNLOAD through byte-limited messages

All of them pass against #266. Since #272 is stacked, CI won't run on it until it's merged into this branch or retargeted.

Finding: a stored oversized header payload can make a cut-short reply look final (no test fails today)

  • The block index keeps the first header copy it receives, and AcceptBlockHeader never replaces its AuxPoW payload. A header-only copy carrying a padded AuxPoW above the block-size bound (it needs a second valid parent PoW for the same block) can therefore be planted, and stays even after the real block arrives.
  • A reply cut short before that header can then fall below THRESHOLD_HEADERS_MESSAGE_SIZE. The receiver treats it as the tip.
  • In IBD below minimum chain work this disconnects the outbound peer (UpdatePeerStateForReceivedHeaders). Low-work PRESYNC aborts at the same height on every retry.
  • Possible fixes (outside net_processing):
    • reject header copies whose serialized size is above the block-size bound, since no valid block can contain them, so this doesn't change which blocks are valid; or
    • replace the index's payload with the block's own when the block is accepted.

Minor

  • The THRESHOLD_HEADERS_MESSAGE_SIZE comment ("a single header never is") doesn't hold for stored oversized payloads; the behaviour itself is fine.
  • These comments still describe the old count-only rule:
    • MAX_HEADERS_RESULTS in net_processing.h
    • full_headers_message in headerssync.h
    • the MAX_REDOWNLOAD_AUXPOW_SERIALIZED_SIZE rationale in headerssync.cpp
  • Low-work sync can now be kept going by 2 padded headers instead of 2,000. The PoW per header is unchanged, so the cost per round shifts to bandwidth. We saw no amplification.
  • There's no release-notes fragment; test: unit coverage and release notes for #266 #272 adds one.

Design note: re-locking and looking up State() again after serializing outside cs_main is more defensive than our version, which kept the CNodeState pointer. Keep it.

Add unit tests for #266, which bounds the size of headers messages:

- peerman_tests: the header count or message size decides whether a
  received headers message makes us ask for more, at MAX_HEADERS_RESULTS
  and at THRESHOLD_HEADERS_MESSAGE_SIZE. A reply of 2,000 small headers
  is unchanged byte for byte. Replies through large AuxPoW headers stay
  within MAX_PROTOCOL_MESSAGE_LENGTH and reach the tip. A header whose
  stored AuxPoW is close to 4 MB goes alone. The normal and low-work
  requesters keep asking after messages cut short by size. A v1.0.0
  sender's last message over the threshold costs one empty reply. An
  announcement too large for one message falls back to inv.
- headers_sync_chainwork_tests: HeadersSyncState continues through
  messages cut short by size in PRESYNC and REDOWNLOAD.

Also add the release notes for #266.

Refs #258

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants