Skip to content

net: bound connected-peer resources - #874

Merged
reardencode merged 12 commits into
docs/livera-indexfrom
net/peer-resources
Oct 4, 2026
Merged

reardencode merged 12 commits into
docs/livera-indexfrom
net/peer-resources

Conversation

@rearden-grok

@rearden-grok rearden-grok Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Why

A connected peer could grow process-wide parent-request state without a cap, and a wtxid announcement was later fetched as a txid. Outbound getdata and transaction announcements did not count against the per-peer send budget, so block serving could keep queueing after that budget was already over. BIP324 decoys and unknown message types never entered the rate window, and the window reset in a way that granted a second full budget at the one-second boundary. Mempool announcements went out as one inv per transaction.

What

  • Cap parent-request tracking per peer and process-wide, and re-request a wtxid announcement as a wtxid.
  • Charge outbound getdata and tx announcements against the existing send budget, and stop serving blocks once that budget is already over.
  • Count v2 decoys and unknown message types in the rate window on tip-follow and IBD. Requested block bodies stay off that window.
  • Keep the previous second in the rate window so a boundary does not grant another full budget.
  • Batch filtered transaction announcements into chunks of 1000. Each chunk still charges the send budget.

Index rows C1, N2, H1, N1, M1, L13, and M7 are marked fixed in docs/external_findings/052-livera-review-index.md.

Stacked on docs/livera-index.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Send-budget charging, 1000-inv batching, and the weighted rate window match the stated decisions. The IBD block reader does not enter the limiter; decoys are the only new note on that path, and the window does not allocate. Three behavior bugs are new.

Issue 1 -- bug

crates/rbitcoin-net/src/peer.rs:3220

note_inv returns false for both the per-peer cap and the process-wide cap. on_inv treats that as misbehavior, disconnects, and returns before getheaders and before getdata already collected in want. The global cap is 200_000 rows (40 peers at the 5_000 per-peer cap), which fits in the inbound limit, and those rows live until the holding peer's heartbeat expires the 60s in-flight entry. An honest peer is then disconnected for a table someone else filled. schedule drops orphan parents on the same cap with no signal.

Suggestion: Disconnect only at MAX_PARENT_ANN_PER_PEER. On the process-wide cap, skip the new row, do not set parent_capped, and still send getheaders plus any getdata already accepted.

Issue 2 -- bug

crates/rbitcoin-net/src/parent_req.rs:127

ParentSlot::wtxid is sticky for every announcer of those 32 bytes. schedule records orphan parents as txids, but take_due then sends MSG_WTX once any wtxid inv has set the bit. A MSG_WTX inv of a parent txid flips the slot, so the parent is requested as a wtxid and a segwit parent is not fetched. take_due_parent_getdata also stops using parent_already_have for that hash.

Suggestion: Key rows by announcement kind, or store the kind on the announcement select chooses. Txid parents stay WitnessTransaction. Wtxid-inv follow-ups stay WTx.

Issue 3 -- bug

crates/rbitcoin-net/src/peer.rs:1607 (IBD: crates/rbitcoin-net/src/ibd/peer_io.rs:251)

The first decoy that does not fit the window aborts the read with peer misbehavior threshold and drops the peer. Unknown types and ordinary frames only add RATE_LIMIT_BAN_SCORE (50) and disconnect at 100. A tip-follow peer already at the 4_000 msg/s budget stays up for an extra inv and is disconnected for a decoy. On IBD the generic error arm marks the peer dead with ban_score still 0.

Suggestion: Count the decoy, add RATE_LIMIT_BAN_SCORE when note returns false, and disconnect only at BAN_SCORE_THRESHOLD. Return Ok(()) from the decoy hook until then.

mp.note_getdata_tx(gd_tx);
}
if parent_capped {
punish_disconnect(&mut follow.ban_score, session);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] note_inv returns false for both the per-peer cap and the process-wide cap (ParentTracker::at_cap). This treats every false as misbehavior, disconnects, and returns before getheaders and before getdata already collected in want. The global cap is 200_000 rows, which is 40 peers at the 5_000 per-peer cap and fits in the inbound limit. Those rows stay until the holding peer's heartbeat expires the 60s in-flight entry, so an honest peer is disconnected for a table someone else filled. schedule hits the same cap and drops the orphan parent with no signal.

Suggestion: Disconnect only when this peer's own counter is at MAX_PARENT_ANN_PER_PEER. When the process-wide cap refuses a new row, skip that announcement, do not set parent_capped, and still send getheaders plus any getdata already accepted. note_inv has to tell the two caps apart.

Comment thread crates/rbitcoin-net/src/parent_req.rs Outdated
return false;
};
if wtxid {
slot.wtxid = true;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] ParentSlot::wtxid is one sticky bit for every announcer of these 32 bytes. This assignment runs before the function knows whether this peer already has a row, and nothing clears it. schedule always passes wtxid: false because orphan parents are txids, but take_due then emits Inventory::WTx for whichever peer is selected. A MSG_WTX inv whose hash is a parent txid flips the slot, so the parent getdata will not match a segwit parent's txid and the orphan is not fetched. Refreshing that inv sets the bit again. take_due_parent_getdata also stops using parent_already_have once the bit is set, so a parent already in the mempool by txid is requested again.

Suggestion: Key rows by announcement kind, or store the kind on the announcement select chooses. A scheduled orphan parent stays WitnessTransaction. A wtxid-inv follow-up stays WTx. One inv must not change the getdata type for other peers' txid rows. Point wtxid_followup_is_requested_as_wtx at the wtxid row's own retry.

if rate.note(n) {
Ok(())
} else {
Err(NetError::Protocol("peer misbehavior threshold"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] A decoy that does not fit the window fails the read with peer misbehavior threshold. The match arm below (Err(e) => return Err(e)) drops the peer on that first overflow. Unknown types in this loop, and ordinary frames, only add RATE_LIMIT_BAN_SCORE (50) and disconnect at BAN_SCORE_THRESHOLD (100). note returns false for a single message that does not fit, so a peer already at the 4_000 msg/s budget stays connected if the extra message is an inv and is disconnected if it is a decoy. The IBD reader has the same callback at ibd/peer_io.rs:251; that error hits the generic Err(e) arm and marks the peer dead while ban_score is still 0. Requested block bodies stay off the IBD window, which is right.

Suggestion: Keep counting decoys in this limiter. On note false, add RATE_LIMIT_BAN_SCORE and disconnect only at BAN_SCORE_THRESHOLD, same as invalid types. Return Ok(()) from the decoy hook until that threshold so one overflow does not abort the read.

Comment thread crates/rbitcoin-net/src/ibd/peer_io.rs Outdated
if rate.note(n) {
Ok(())
} else {
Err(NetError::Protocol("peer misbehavior threshold"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] A decoy that does not fit the window fails this read with peer misbehavior threshold. The generic Err(e) arm below marks the peer dead on that first overflow, while ban_score is still 0. Unknown types in this same loop add RATE_LIMIT_BAN_SCORE (50) and disconnect only at BAN_SCORE_THRESHOLD (100). Tip-follow has the same decoy callback (peer.rs:1607) against the two-strike path used for ordinary frames. A single overflow is what note reports; it is not yet a disconnect under the score used everywhere else. Requested block bodies stay off this window, which is right.

Suggestion: Keep counting decoys in this limiter. On note false, add RATE_LIMIT_BAN_SCORE and disconnect only at BAN_SCORE_THRESHOLD, same as the invalid-type arm here. Return Ok(()) from the decoy hook until that threshold so one overflow does not abort the read.

@rearden-grok
rearden-grok Bot force-pushed the net/peer-resources branch from df9afe5 to 10c8e6d Compare October 3, 2026 17:04
@rearden-grok
rearden-grok Bot force-pushed the net/peer-resources branch from 10c8e6d to 09d67ed Compare October 4, 2026 04:46
rearden-grok Bot added 8 commits October 3, 2026 21:54
An inv flood grew the process-wide parent map, and each heartbeat copied
every key. Announcements now stop at a per-peer and global cap, the
heartbeat walks one peer's due index, and a wtxid follow-up is requested
as WTx.
Inv-driven getdata, parent getdata, tx announcements, and filter
checkpoints were queued without moving the per-peer send counter, and
block getdata kept serving after that counter was already over.
A tumbling one-second reset granted a second full message and byte
budget at the boundary. The limiter now weighs the previous second and
does not allocate per message.
Decoy packets never left the v2 reader, and unknown types were logged
and ignored, so neither counted toward the peer rate window. Tip-follow
and the IBD reader now note those bytes and disconnect once the
misbehavior score crosses the threshold. Requested block bodies stay
off that window.
Each due transaction was its own inv, so a flush sent one message per
tx. After the candidate filter, announcements go out in chunks of 1000
and each chunk still charges the send budget.
The cap tests already assert the next insert is refused. The counter
existed only for those asserts, and clippy rejects it on the library
build.
The index links 053-059 for the rows this branch closes. Each note
names the severity, the behavior that changed, and the regression.
A process-wide parent cap skips the new row. Headers and getdata already
collected still go out, and only a peer at its own cap is disconnected.
Each announcement keeps its getdata kind, so a wtxid inv does not turn
another peer's txid parent into WTx. One decoy past the rate window adds
the same score as an unknown type. The IBD reader and writer are their
own functions so spawn_peer stays under the CRAP gate.
@rearden-grok
rearden-grok Bot force-pushed the net/peer-resources branch from 09d67ed to 80e2d62 Compare October 4, 2026 04:54
rearden-grok Bot added 4 commits October 3, 2026 22:39
read_ibd_peer scored above the coverage CRAP gate. Ping, block,
decode, and read-error handling now live in small functions the
tests call directly.
A non-segwit wtxid announcement uses the same hash as the txid. Sending that hash again makes the parent getdata larger than the missing set. The kind of a later request stays the announcement's own.
A non-segwit wtxid request and the orphan parent share one hash. The
parent stays out of getdata while that request is in flight, then goes
out as a txid once the window ends. The wtxid follow-up stays WTx.
A txid schedule followed by a same-peer wtxid inv left the wtxid window
indexed after it ended, so the txid parent was never requested.
@reardencode
reardencode added this pull request to stack #904 October 4, 2026 14:22
@reardencode
reardencode merged commit 096d202 into master Oct 4, 2026
18 checks passed
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