Skip to content

Commit d18c789

Browse files
committed
fix(network): gate find-response stall tracking on per-peer height
PR #10732 stops the find-response stall tracker from disconnecting peers at the chain tip, but gates it on `is_at_or_near_network_tip`, which estimates the distance to the network tip from the local tip block's timestamp. A stale tip — mining paused, or the whole network parked at a shared tip — makes that estimate report the node as far behind, re-arming the tracker and disconnecting healthy at-tip peers again. Gate the tracker per-peer on the peer's advertised handshake height versus our tip instead: only penalise empty or failed find responses from a peer that claimed a height above ours. This is robust to a stale tip, preserves the GHSA-h9hm-m2xj-4rq9 property (a peer that promises height but delivers nothing is still dropped during real sync), and means a peer lying high about its height can only get itself dropped, never an honest peer. The trade-off — a peer claiming a height at or below our tip is never tracked — is closed by the contribution-based accounting tracked in #11080. The zcashd-compat sidecar exemption from #10952 is preserved; its stall test now arms the per-peer gate so the exemption is what it exercises.
1 parent 8e9ff3b commit d18c789

8 files changed

Lines changed: 191 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ and this project adheres to [Semantic Versioning](https://semver.org).
5454
never selected as a reconnection candidate. Previously an entry on a different port could survive
5555
the ban and occupy the first candidate slot until the node restarted
5656
([#11134](https://github.com/ZcashFoundation/zebra/issues/11134)).
57+
- Empty `FindBlocks` and `FindHeaders` responses are now only counted against peers
58+
that advertised a height above the local tip in their handshake, instead of being
59+
counted whenever a wall-clock estimate reports the node as far from the network
60+
tip. Peers at or below the local tip are no longer disconnected when the local
61+
tip is stale (mining paused, or the whole network parked at a shared tip)
62+
([#10910](https://github.com/ZcashFoundation/zebra/pull/10910)).
5763

5864
### Security
5965

zebra-network/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
never returns an address whose IP is banned. Previously an entry for the banned IP on another
3434
port could survive the ban and stay at the front of the reconnection order for the lifetime of
3535
the process ([#11134](https://github.com/ZcashFoundation/zebra/issues/11134)).
36+
- `FindBlocks`/`FindHeaders` stall tracking is now gated per peer on the height the peer
37+
advertised in its version handshake instead of a wall-clock estimate of the node's distance to
38+
the network tip, so peers at or below a stale local tip are no longer disconnected
39+
([#10910](https://github.com/ZcashFoundation/zebra/pull/10910)).
3640

3741
### Security
3842

zebra-network/src/peer/client/tests.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ impl ClientTestHarness {
5656
pub fn build() -> ClientTestHarnessBuilder {
5757
ClientTestHarnessBuilder {
5858
version: None,
59+
start_height: None,
5960
connection_task: None,
6061
heartbeat_task: None,
6162
connected_addr: None,
@@ -253,6 +254,7 @@ pub struct ClientTestHarnessBuilder<C = future::Ready<()>, H = future::Ready<()>
253254
heartbeat_task: Option<H>,
254255
version: Option<Version>,
255256
connected_addr: Option<ConnectedAddr>,
257+
start_height: Option<Height>,
256258
}
257259

258260
impl<C, H> ClientTestHarnessBuilder<C, H>
@@ -272,6 +274,12 @@ where
272274
self
273275
}
274276

277+
/// Configure the mocked handshake start height reported by the peer.
278+
pub fn with_start_height(mut self, start_height: Height) -> Self {
279+
self.start_height = Some(start_height);
280+
self
281+
}
282+
275283
/// Configure the mock connection task future to use.
276284
pub fn with_connection_task<NewC>(
277285
self,
@@ -282,6 +290,7 @@ where
282290
heartbeat_task: self.heartbeat_task,
283291
version: self.version,
284292
connected_addr: self.connected_addr,
293+
start_height: self.start_height,
285294
}
286295
}
287296

@@ -295,6 +304,7 @@ where
295304
heartbeat_task: Some(heartbeat_task),
296305
version: self.version,
297306
connected_addr: self.connected_addr,
307+
start_height: self.start_height,
298308
}
299309
}
300310

@@ -329,7 +339,7 @@ where
329339
),
330340
nonce: Nonce::default(),
331341
user_agent: "client test harness".to_string(),
332-
start_height: Height(0),
342+
start_height: self.start_height.unwrap_or(Height(0)),
333343
relay: true,
334344
};
335345

zebra-network/src/peer/load_tracked_client.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use tower::{
1212
Service,
1313
};
1414

15+
use zebra_chain::block;
16+
1517
use crate::{
1618
constants::{EWMA_DECAY_TIME_NANOS, EWMA_DEFAULT_RTT},
1719
peer::{Client, ConnectedAddr, ConnectionInfo},
@@ -65,6 +67,17 @@ impl LoadTrackedClient {
6567
if canonical_socket_addr(addr.remove_socket_addr_privacy()).ip() == expected_ip
6668
)
6769
}
70+
71+
/// Retrieve the block height the peer reported in its version handshake.
72+
///
73+
/// This is the peer's best height at the time it connected. It is not updated as the peer
74+
/// syncs, so it under-reports a peer that advanced after handshake. For stall detection this
75+
/// errs toward not tracking: it can stop tracking a peer early (missing a stall), but never
76+
/// falsely tracks a peer that is not ahead of us (see
77+
/// [`PeerSet::route_p2c`](crate::peer_set::PeerSet::route_p2c)).
78+
pub fn remote_start_height(&self) -> block::Height {
79+
self.connection_info.remote.start_height
80+
}
6881
}
6982

7083
impl<Request> Service<Request> for LoadTrackedClient

zebra-network/src/peer_set/set.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ use tower::{
125125
Service,
126126
};
127127

128-
use zebra_chain::{chain_tip::ChainTip, parameters::Network};
128+
use zebra_chain::{
129+
block,
130+
chain_tip::{ChainTip, AT_OR_NEAR_TIP_THRESHOLD},
131+
parameters::Network,
132+
};
129133

130134
use crate::{
131135
address_book::AddressMetrics,
@@ -1046,16 +1050,31 @@ where
10461050
&req,
10471051
Request::FindBlocks { .. } | Request::FindHeaders { .. }
10481052
);
1049-
let is_syncing = || {
1050-
!self
1053+
// Only penalise empty or failed find responses from a peer that advertised a height
1054+
// meaningfully above our own tip. A peer at or below our tip legitimately has nothing
1055+
// beyond it to send, so its empty responses are expected — including when our own tip
1056+
// is stale (mining paused, or the whole network parked at a shared tip), where
1057+
// extrapolating the distance to the network tip from wall-clock time (as
1058+
// `ChainTip::is_at_or_near_network_tip` does) would wrongly report us as still syncing
1059+
// and re-arm the stall tracker. Deciding per-peer, rather than from a single global
1060+
// tip estimate, also means a peer lying high about its height can only get itself
1061+
// dropped, never an honest peer. The trade-off: a peer claiming a height at or below
1062+
// our tip is never tracked, so its empty responses go unpunished; #11080 tracks
1063+
// measuring per-peer contribution instead, which closes that gap.
1064+
let peer_claims_ahead = || {
1065+
let our_tip = self
10511066
.minimum_peer_version
10521067
.chain_tip()
1053-
.is_at_or_near_network_tip(&self.network)
1068+
.best_tip_height()
1069+
.unwrap_or(block::Height(0));
1070+
svc.remote_start_height() - our_tip > AT_OR_NEAR_TIP_THRESHOLD
10541071
};
1055-
// zcashd-compat sidecars are exempt: they sync *from* this node,
1056-
// so they can legitimately trail it without being stalled peers.
1057-
let track_stalls =
1058-
is_find_request && !self.zcashd_compat_peer_keys.contains(&p2c_key) && is_syncing();
1072+
// zcashd-compat sidecars are exempt: they sync *from* this node, so they can
1073+
// legitimately return nothing — including when their handshake height exceeds a fresh
1074+
// local tip before this node loads its state.
1075+
let track_stalls = is_find_request
1076+
&& !self.zcashd_compat_peer_keys.contains(&p2c_key)
1077+
&& peer_claims_ahead();
10591078

10601079
let fut = svc.call(req);
10611080
self.push_unready(p2c_key, svc);

zebra-network/src/peer_set/set/tests.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ impl PeerVersions {
110110
}
111111
}
112112

113+
/// Build a single mock peer with the given protocol version and advertised handshake start
114+
/// height, exposed as a [`Discover`]-compatible stream (like
115+
/// [`PeerVersions::mock_peer_discovery`]).
116+
///
117+
/// The stall-tracker tests use this to control the peer's reported height relative to the local
118+
/// tip, which is what the `route_p2c` stall gate keys on.
119+
fn mock_peer_discovery_with_start_height(
120+
version: Version,
121+
start_height: block::Height,
122+
) -> (
123+
impl Stream<Item = Result<Change<PeerSocketAddr, LoadTrackedClient>, BoxError>>,
124+
Vec<ClientTestHarness>,
125+
) {
126+
let (client, harness) = ClientTestHarness::build()
127+
.with_version(version)
128+
.with_start_height(start_height)
129+
.finish();
130+
131+
let peer_address: PeerSocketAddr = SocketAddr::new([127, 0, 0, 1].into(), 1).into();
132+
let discovered_peers =
133+
stream::iter([Ok(Change::Insert(peer_address, client.into()))]).chain(stream::pending());
134+
135+
(discovered_peers, vec![harness])
136+
}
137+
113138
/// A helper builder type for creating test [`PeerSet`] instances.
114139
///
115140
/// This helps to reduce repeated boilerplate code. Fields that are not set are configured to use

0 commit comments

Comments
 (0)