Skip to content

Bound the unknown-parent block buffer - #941

Merged
bkeroack merged 1 commit into
release/0.6from
fix/orphan-block-buffer-bounds
Oct 8, 2026
Merged

bkeroack merged 1 commit into
release/0.6from
fix/orphan-block-buffer-bounds

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Stacked on #936 (merge that first).

Problem

Outside IBD, a block message whose parent has no index entry goes to the block processor, where accept_block refuses it with PrevBlockNotFound. The processor then kept it in an in-memory buffer keyed by its prev_blockhash (net/manager.rs, block_processor):

  • up to 8192 entries, with no bound on bytes, no bound per peer and no expiry. An entry left only when its parent became the tip;
  • whether or not this node had asked for the block, and before any proof-of-work or size check (accept_block returns PrevBlockNotFound before either, chain/state.rs);
  • with the sender's in-flight guard, so a ping from that peer waited for the 60-second bound until the block's parent connected, or forever if it never did;
  • after the per-input fee pass (compute_block_fee_rates, a coin lookup per input), which runs before accept_block.

A block whose parent was marked invalid (BadPrevBlock) was kept the same way, though nothing can connect on it.

Bitcoin Core keeps no such block. AcceptBlockHeader runs CheckBlockHeader (src/validation.cpp:4206) and then refuses an unknown parent as prev-blk-not-found (src/validation.cpp:4216); the block is gone. satd differs on purpose in one place: it answers a block inv with a getdata as well as a getheaders (handle_inv, and the sendheaders row in CORE_DIFFERENCES.md), so a block it asked for can arrive before the parent it is still learning about. Keeping that block saves fetching it again once the parent connects.

Core references are to master 16613c9de9.

Fix

A new net/orphan_blocks.rs holds the waiting blocks and enforces the bounds. The block processor and handle_block decide what goes in.

  • Only a block this node asked for may wait. handle_block reads whether any peer was asked for the hash (Core's IsBlockRequested, read at the same point, src/net_processing.cpp:4800) before note_block_arrived clears the request record, and passes it to the processor with the block.
  • A waiting block must pass check_proof_of_work_bounded (Core's CheckProofOfWork, the check CheckBlockHeader makes) and be at most MAX_BLOCK_SERIALIZED_SIZE, 4,000,000 bytes (src/consensus/consensus.h:13). No valid block is larger.
  • At most 4 blocks from one peer, 64 blocks and 32,000,000 serialized bytes in all. When a new block needs room, the sender's own oldest goes first if the sender is at its share, then the oldest of all. Each block expires after 5 minutes; by then the headers the node asked for have made the block's own header known, and the ordinary missing-block fetch gets it again.
  • The in-flight guard is counted out when the block is set aside, waiting or not. Core is done with the block once it has refused it, so a pong behind it is answered then.
  • The processor checks for the parent before the fee pass, so a block that cannot connect yet costs no coin lookups and does not take accept_block's lock.
  • Either way the sender is asked for headers (maybe_send_getheaders, rate-limited per peer), as satd already does for a cmpctblock on an unknown parent and Core does for headers that do not connect. A block the node did not ask for is otherwise dropped.
  • A block on a parent marked invalid is not kept (Core: bad-prevblk).
  • Two blocks on the same parent can both wait. When the parent connects and the first fails to connect, the next one is tried; before, the second replaced the first in the map.

The sender of a block with an unknown parent is still not penalised, as an_invalid_block_from_a_peer_does_not_ban_or_drop_the_session in net/manager.rs requires. Core discourages it (MaybePunishNodeForBlock, BLOCK_MISSING_PREV, src/net_processing.cpp:1940); peer punishment is out of scope here.

CORE_DIFFERENCES.md gains a row for the difference that remains: satd keeps a requested block whose parent is unknown, within these bounds, where Core keeps none.

Tests

Unit tests, new file node/src/net/manager_orphanbuf_tests.rs (through handle_message and the real block processor thread):

  • an_unrequested_block_with_an_unknown_parent_is_not_kept: block 5, not requested, then block 4. Block 4 connects and block 5 does not follow it; sent again afterwards, block 5 connects.
  • a_requested_block_waits_for_its_parent_and_connects_after_it: the counterpart. Block 5, requested, then block 4: block 5 connects from the buffer.
  • a_block_waiting_for_its_parent_does_not_hold_the_senders_pong: a requested block 5 is set aside and the sender's in-flight count goes to zero; block 4 then arrives and block 5 follows it in.

Unit tests in node/src/net/orphan_blocks.rs: valid PoW kept and returned to its parent only; hash missing its target, target above regtest's powLimit, and regtest work on mainnet all refused; over 4,000,000 bytes refused and just under kept; the per-peer share evicts that peer's oldest and leaves other peers' blocks; the byte cap and the block cap evict the oldest; expiry; siblings both wait and a second copy is not kept twice; the default bounds fit a full-size block and keep one peer below the total.

Integration tests, new module satd/tests/compact_blocks/unknown_parent.rs (raw P2P against a regtest node):

  • an_unrequested_block_on_an_unknown_parent_is_dropped_and_headers_are_asked_for: the node answers with getheaders, and when the parent then connects the unrequested child does not.
  • a_requested_block_waits_for_its_parent_without_holding_the_pong: after inv → getdata, the child is sent, a ping behind it is answered within 10 s, and the child connects after its parent.

node/src/net/manager_blockaccept_tests.rs (#936): an_empty_body_buffered_before_its_parent_does_not_bar_the_real_block now records a request for block 5 first, so the empty body still takes the buffered route that test covers.

Fails without the fix:

  • an_unrequested_block_with_an_unknown_parent_is_not_kept: yes (block 4 connects and block 5 was not kept, tip was block 5).
  • a_block_waiting_for_its_parent_does_not_hold_the_senders_pong: yes (the waiting block must not hold the peer's in-flight count (1 in flight)).
  • an_unrequested_block_on_an_unknown_parent_is_dropped_and_headers_are_asked_for: yes (a block on an unknown parent must be answered with getheaders).
  • a_requested_block_waits_for_its_parent_without_holding_the_pong: yes (the pong must not wait for the child's parent).
  • a_requested_block_waits_for_its_parent_and_connects_after_it passes with and without the fix; it guards the behaviour that must stay.

Each guard on its own (removed, named test run, restored):

  • requested gate removed: an_unrequested_block_with_an_unknown_parent_is_not_kept fails.
  • In-flight guard kept with a waiting block: a_block_waiting_for_its_parent_does_not_hold_the_senders_pong fails (1 in flight).
  • getheaders request removed: an_unrequested_block_on_an_unknown_parent_is_dropped_and_headers_are_asked_for fails.
  • Bounded PoW check removed: a_block_without_proof_of_work_within_the_limit_is_not_kept fails.
  • Size check removed: a_block_over_the_serialized_size_limit_is_not_kept fails.
  • Per-peer share removed: one_peer_keeps_at_most_its_share_and_loses_its_oldest_first fails.
  • Byte cap removed: the_byte_cap_drops_the_oldest_blocks_first fails.
  • Block cap removed: the_block_cap_drops_the_oldest_blocks_first fails.
  • Expiry removed: a_block_is_dropped_once_it_has_waited_the_expiry fails.

Skipping the fee pass and accept_block for a block with an unknown parent saves work only; no test observes it.

Local: cargo clippy --all-targets --all-features -D warnings, cargo clippy -p node --no-default-features --all-targets -D warnings, cargo test -p node --lib (2169 passed), cargo test -p satd --test compact_blocks (33 passed), and the regtest tests a_pong_answers_for_the_block_that_arrived_before_the_ping and blocknotify_runs_for_a_block_the_stored_tail_drain_connects.

Notes

  • Peer punishment for a block with an unknown parent is unchanged (none). Core discourages the sender; whether satd should is a separate decision.
  • A block waiting for its parent connects when the parent connects through the block processor's channel, as before. A parent that connects another way (submitblock, the stored-tail drain) leaves the child waiting until it expires or is fetched again; this is unchanged, and the bound now frees it.

Release notes

A block received before its parent is now kept only if the node asked for it, and only within fixed bounds: valid proof of work, at most 4 MB, at most 4 blocks per peer and 32 MB in all, for at most 5 minutes. Other such blocks are dropped and the sender is asked for headers, which is how the node learns the missing parent. A peer's ping is no longer held while one of its blocks waits for a parent.

🤖 Generated with Claude Code

A block whose parent had no index entry was parked in the block
processor's buffer keyed by its prev hash: up to 8192 entries with no
byte bound, no per-peer bound and no expiry, whether or not this node had
asked for the block, before any proof-of-work or size check. Each entry
kept its sender's in-flight guard, so that peer's pongs waited for the
60-second bound until the block's parent connected, and the block went
through the per-input fee pass before accept_block refused it.

Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader
(validation.cpp:4206) and then refuses an unknown parent as
prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a
getdata, so a block it asked for can arrive before the parent it is still
learning about, and keeping that one saves a second download.

Now:
- Only a block this node asked for (Core's IsBlockRequested, read in
  handle_block before the request record is cleared) may wait.
- A waiting block must pass check_proof_of_work_bounded and be at most
  MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes.
- At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest
  goes first when it is at its share, then the oldest of all. Each block
  expires after 5 minutes.
- The in-flight guard is counted out when the block is set aside, as Core
  is done with it once refused.
- A block whose parent is unknown skips the fee pass and accept_block.
- The sender is asked for headers, as satd already does for a cmpctblock
  on an unknown parent.
- A block on a parent marked invalid is not kept.
- Two blocks on the same parent can both wait; when one fails to connect
  the next is tried.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack force-pushed the fix/orphan-block-buffer-bounds branch from 1ce59e0 to 8061ef1 Compare October 8, 2026 18:13
@bkeroack
bkeroack merged commit e431f4f into release/0.6 Oct 8, 2026
12 checks passed
@bkeroack
bkeroack deleted the fix/orphan-block-buffer-bounds branch October 8, 2026 18:13
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.

1 participant