Skip to content

perf(dash-spv): prototype of the new peer selection algorithm#865

Open
ZocoLini wants to merge 1 commit into
devfrom
perf/peer-selection
Open

perf(dash-spv): prototype of the new peer selection algorithm#865
ZocoLini wants to merge 1 commit into
devfrom
perf/peer-selection

Conversation

@ZocoLini

@ZocoLini ZocoLini commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Improved peer selection by prioritizing reliable, responsive, and measured connections.
    • Added latency-based routing, peer probing, and stalled-connection detection.
    • Automatically ranks and replaces underperforming peers during maintenance.
    • Enhanced peer reputation tracking using behavior quality and response latency.
    • Routes messages through the best eligible peer, with direct-peer delivery support.
  • Bug Fixes

    • Invalid compressed-header data now causes the connection to be safely disconnected.
    • Improved handling of timeouts, failed responses, and unsuccessful message delivery.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Peer reputation now uses behavior multipliers and RTT hints. Peer selection, connection establishment, maintenance, scouting, and request routing use latency, service flags, stalling state, and reputation outcomes.

Changes

Peer reputation and quality

Layer / File(s) Summary
Multiplier reputation model
dash-spv/src/network/reputation.rs, dash-spv/src/network/reputation_tests.rs
Reputation persistence and ranking now use bounded behavior multipliers with optional median RTT values, including updated usability, latency, and persistence tests.
Peer RTT and behavior tracking
dash-spv/src/network/peer.rs
Peers record bounded RTT samples, behavior changes, request stalls, and headers2 state, with quality scoring and pong integration.
Service-aware peer selection
dash-spv/src/network/pool.rs
The pool classifies message requirements, selects eligible peers by probing, stall state, quality, and RTT, and supports targeted sending.
Connection and reader reputation flow
dash-spv/src/network/manager.rs
Connections seed and probe peer metrics, reader outcomes apply typed reputation reasons, and final behavior and RTT are persisted.
Network request routing
dash-spv/src/network/manager.rs
Request processing and sync-message sending now use pool-level routing, with targeted-send fallback behavior.
Maintenance and peer scouting
dash-spv/src/network/manager.rs
Maintenance applies latency-tier filtering, ping and stall handling, peer replacement, and periodic scouting of unmeasured peers.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NetworkManager
  participant PeerReputationManager
  participant PeerPool
  participant Peer
  NetworkManager->>PeerReputationManager: load reputation hint
  NetworkManager->>Peer: establish connection and probe RTT
  Peer-->>NetworkManager: latency and behavior samples
  NetworkManager->>PeerReputationManager: record multiplier and median RTT
  NetworkManager->>PeerPool: send network request
  PeerPool->>Peer: select and dispatch request
  Peer-->>PeerPool: response or send result
  PeerPool-->>NetworkManager: routing result
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a new peer selection algorithm in dash-spv.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/peer-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ZocoLini ZocoLini changed the title Perf/peer selectiongit perf(dash-spv): prototype of the new peer selection algorithm Jul 10, 2026
@ZocoLini ZocoLini force-pushed the perf/peer-selection branch from b83814d to fd58fe1 Compare July 10, 2026 22:07
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.63551% with 125 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.80%. Comparing base (845ee9e) to head (fd58fe1).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/network/manager.rs 57.95% 103 Missing ⚠️
dash-spv/src/network/reputation.rs 81.01% 15 Missing ⚠️
dash-spv/src/network/peer.rs 95.06% 4 Missing ⚠️
dash-spv/src/network/pool.rs 97.69% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #865      +/-   ##
==========================================
+ Coverage   73.62%   73.80%   +0.18%     
==========================================
  Files         324      324              
  Lines       73374    73572     +198     
==========================================
+ Hits        54018    54303     +285     
+ Misses      19356    19269      -87     
Flag Coverage Δ
core 77.08% <ø> (ø)
ffi 47.46% <ø> (+1.38%) ⬆️
rpc 20.00% <ø> (ø)
spv 90.79% <76.63%> (-0.16%) ⬇️
wallet 73.11% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/network/pool.rs 95.94% <97.69%> (+2.97%) ⬆️
dash-spv/src/network/peer.rs 63.11% <95.06%> (+2.70%) ⬆️
dash-spv/src/network/reputation.rs 79.82% <81.01%> (+0.69%) ⬆️
dash-spv/src/network/manager.rs 70.96% <57.95%> (-1.39%) ⬇️

... and 18 files with indirect coverage changes

@ZocoLini ZocoLini requested a review from xdustinface July 11, 2026 13:54
@ZocoLini ZocoLini marked this pull request as ready for review July 11, 2026 13:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv/src/network/manager.rs`:
- Around line 798-804: Update the reader lifecycle around the peer handling flow
to capture an Instant when the reader starts, then use its elapsed duration for
the LongUptime threshold in the apply_reason block. Replace the
loop_iteration-based calculation while preserving the existing one-hour
comparison and reward behavior.
- Around line 1128-1143: Update the pong branch in the receive loop to count
each pending probe nonce only once: after confirming the nonce is pending, call
peer.handle_pong(*n) and remove the nonce from pending only when handling
succeeds, incrementing answered only for that successful removal. Preserve the
existing handling for pings and other messages.
- Around line 37-59: Centralize the hardcoded peer-selection and scoring
parameters in configuration: update dash-spv/src/network/manager.rs lines 37-59
to configure probe counts, wait duration, scout interval, batch size, and
latency-tier thresholds; update dash-spv/src/network/reputation.rs lines 37-48
to configure behavior-event weights; and update dash-spv/src/network/peer.rs
lines 56-70 to configure RTT buckets, sample count, and stall threshold, then
have the relevant manager, reputation, and peer logic consume those
configuration values.
- Around line 1054-1060: Update the low_quality peer handling around
replace_peer so stalled peers are disconnected in exclusive mode as well,
allowing the reconnect path to redial them and preventing selection from
retaining a stalled connection. Preserve the existing replacement behavior for
non-exclusive mode while ensuring exclusive mode performs the required
disconnect without attempting non-exclusive peer swapping.

In `@dash-spv/src/network/peer.rs`:
- Around line 861-871: The peer request-tracking flow must correlate responses
with individual outstanding requests instead of using one unconditional timer.
In dash-spv/src/network/peer.rs:861-871, update the peer state and
note_request_sent/note_response behavior to track a request identity and
deadline, or enforce a single in-flight request; in
dash-spv/src/network/manager.rs:659-663, clear the pending request and reward
only when Headers2 matches it; in dash-spv/src/network/manager.rs:718-724, stop
clearing or rewarding for arbitrary forwarded messages.

In `@dash-spv/src/network/pool.rs`:
- Around line 38-40: Update the NetworkMessage capability mapping so only
ordinary GetHeaders permits fallback to arbitrary peers; explicit GetHeaders2
must require ServiceFlags::NODE_HEADERS_COMPRESSED and disable fallback. Keep
the existing compressed-header capability requirement while changing the
fallback behavior for the GetHeaders2 variant.
- Around line 267-306: Update select_best eligibility so that when all probed
peers are stalling, live unprobed peers are eligible before any stalled probed
peer. Preserve the existing preference for non-stalling probed peers, and retain
the single-peer fallback so selection never becomes empty when no live peer
exists.

In `@dash-spv/src/network/reputation.rs`:
- Around line 1-8: Update the pull request title to use the allowed feat prefix,
specifically “feat(dash-spv): …”, instead of “perf(...)”.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3b70d4ef-4511-4add-9837-27cbcdd95aa0

📥 Commits

Reviewing files that changed from the base of the PR and between 845ee9e and fd58fe1.

📒 Files selected for processing (5)
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/peer.rs
  • dash-spv/src/network/pool.rs
  • dash-spv/src/network/reputation.rs
  • dash-spv/src/network/reputation_tests.rs

Comment on lines +37 to +59
/// Latency probes fired right after handshake to seed peer scoring.
const INITIAL_PROBE_PINGS: usize = 3;

/// How long to wait for the initial probe pongs before scoring the peer.
const INITIAL_PROBE_WAIT: Duration = Duration::from_millis(1200);

/// How often to probe known-but-unconnected peers looking for better ones.
const SCOUT_INTERVAL: Duration = Duration::from_secs(45);

/// Peers to probe per scout tick.
const SCOUT_BATCH: usize = 4;

/// Latency tier (lower is better): the band we rank and keep peers by. We only dial peers
/// in the best-known tier or one worse, and drop a connected peer more than one tier below
/// the best in the pool — so a genuinely slow peer never takes a slot. `<=50ms`=0,
/// `<=100ms`=1, `<=150ms`=2, else 3.
fn latency_tier(latency_ms: u32) -> u8 {
match latency_ms {
0..=50 => 0,
51..=100 => 1,
101..=150 => 2,
_ => 3,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Centralize the hardcoded peer-selection policy in configuration.

  • dash-spv/src/network/manager.rs#L37-L59: configure probe counts, waits, scout frequency, batch size, and latency tiers.
  • dash-spv/src/network/reputation.rs#L37-L48: configure behavior-event weights.
  • dash-spv/src/network/peer.rs#L56-L70: configure RTT buckets, sample count, and stall threshold.

As per coding guidelines, “Never hardcode network parameters, addresses, or keys.”

📍 Affects 3 files
  • dash-spv/src/network/manager.rs#L37-L59 (this comment)
  • dash-spv/src/network/reputation.rs#L37-L48
  • dash-spv/src/network/peer.rs#L56-L70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 37 - 59, Centralize the
hardcoded peer-selection and scoring parameters in configuration: update
dash-spv/src/network/manager.rs lines 37-59 to configure probe counts, wait
duration, scout interval, batch size, and latency-tier thresholds; update
dash-spv/src/network/reputation.rs lines 37-48 to configure behavior-event
weights; and update dash-spv/src/network/peer.rs lines 56-70 to configure RTT
buckets, sample count, and stall threshold, then have the relevant manager,
reputation, and peer logic consume those configuration values.

Source: Coding guidelines

Comment on lines +798 to +804
// Persist the peer's final behaviour and latency before removing it.
if let Some(peer) = pool.get_peer(&addr).await {
let mut guard = peer.write().await;
// Reward a long, healthy connection.
if Duration::from_secs(60 * loop_iteration) > Duration::from_secs(3600) {
guard.apply_reason(ChangeReason::LongUptime);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Measure long uptime with elapsed time, not loop iterations.

loop_iteration increments on every read poll, not once per minute. Busy peers can receive the one-hour reward almost immediately; capture an Instant when the reader starts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 798 - 804, Update the reader
lifecycle around the peer handling flow to capture an Instant when the reader
starts, then use its elapsed duration for the LongUptime threshold in the
apply_reason block. Replace the loop_iteration-based calculation while
preserving the existing one-hour comparison and reward behavior.

Comment on lines +1054 to +1060
// Replace peers that stalled, swapping in the best known unconnected peer and
// probing more to find a permanent one.
if !self.exclusive_mode {
for addr in low_quality {
self.replace_peer(addr).await;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Disconnect stalled peers in exclusive mode so they can reconnect.

low_quality peers are handled only outside exclusive mode. In exclusive mode they remain connected, preventing the reconnect branch from redialing them and allowing selection to keep falling back to the stalled connection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1054 - 1060, Update the
low_quality peer handling around replace_peer so stalled peers are disconnected
in exclusive mode as well, allowing the reconnect path to redial them and
preventing selection from retaining a stalled connection. Preserve the existing
replacement behavior for non-exclusive mode while ensuring exclusive mode
performs the required disconnect without attempting non-exclusive peer swapping.

Comment on lines +1128 to +1143
match tokio::time::timeout(remaining, peer.receive_message()).await {
Ok(Ok(Some(message))) => match message.inner() {
NetworkMessage::Pong(n) if pending.contains(n) => {
let _ = peer.handle_pong(*n);
answered += 1;
}
NetworkMessage::Ping(p) => {
let _ = peer.handle_ping(*p).await;
}
_ => {}
},
_ => break,
}
}
for _ in answered..pending.len() {
peer.record_rtt(INITIAL_PROBE_WAIT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Count each probe nonce at most once.

pending.contains(n) stays true after handle_pong removes the nonce, and its result is ignored. Replaying one valid pong can increment answered repeatedly and avoid the missing-probe penalties. Remove each nonce from pending only after successful handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1128 - 1143, Update the pong
branch in the receive loop to count each pending probe nonce only once: after
confirming the nonce is pending, call peer.handle_pong(*n) and remove the nonce
from pending only when handling succeeds, incrementing answered only for that
successful removal. Preserve the existing handling for pings and other messages.

Comment on lines +861 to +871
/// Note that a request expecting a response was sent to this peer.
pub fn note_request_sent(&mut self) {
if self.awaiting_since.is_none() {
self.awaiting_since = Some(Instant::now());
}
}

/// Note that the peer answered with data, clearing any pending-request timer.
pub fn note_response(&mut self) {
self.awaiting_since = None;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Correlate responses with individual outstanding requests.

The single timer plus unconditional response handling lets unrelated traffic clear every pending request and earn reputation.

  • dash-spv/src/network/peer.rs#L861-L871: track request identity and deadline, or enforce one in-flight request per peer.
  • dash-spv/src/network/manager.rs#L659-L663: clear/reward only when Headers2 matches the pending request.
  • dash-spv/src/network/manager.rs#L718-L724: do not clear/reward for arbitrary forwarded messages.
📍 Affects 2 files
  • dash-spv/src/network/peer.rs#L861-L871 (this comment)
  • dash-spv/src/network/manager.rs#L659-L663
  • dash-spv/src/network/manager.rs#L718-L724
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/peer.rs` around lines 861 - 871, The peer
request-tracking flow must correlate responses with individual outstanding
requests instead of using one unconditional timer. In
dash-spv/src/network/peer.rs:861-871, update the peer state and
note_request_sent/note_response behavior to track a request identity and
deadline, or enforce a single in-flight request; in
dash-spv/src/network/manager.rs:659-663, clear the pending request and reward
only when Headers2 matches it; in dash-spv/src/network/manager.rs:718-724, stop
clearing or rewarding for arbitrary forwarded messages.

Comment on lines +38 to +40
NetworkMessage::GetHeaders(_) | NetworkMessage::GetHeaders2(_) => {
Some((ServiceFlags::NODE_HEADERS_COMPRESSED, false))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require compressed-header support for explicit GetHeaders2.

When no capable peer exists, this currently falls back to arbitrary peers and sends them GetHeaders2. Only ordinary GetHeaders should permit fallback.

Proposed fix
-        NetworkMessage::GetHeaders(_) | NetworkMessage::GetHeaders2(_) => {
+        NetworkMessage::GetHeaders(_) => {
             Some((ServiceFlags::NODE_HEADERS_COMPRESSED, false))
         }
+        NetworkMessage::GetHeaders2(_) => {
+            Some((ServiceFlags::NODE_HEADERS_COMPRESSED, true))
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
NetworkMessage::GetHeaders(_) | NetworkMessage::GetHeaders2(_) => {
Some((ServiceFlags::NODE_HEADERS_COMPRESSED, false))
}
NetworkMessage::GetHeaders(_) => {
Some((ServiceFlags::NODE_HEADERS_COMPRESSED, false))
}
NetworkMessage::GetHeaders2(_) => {
Some((ServiceFlags::NODE_HEADERS_COMPRESSED, true))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/pool.rs` around lines 38 - 40, Update the NetworkMessage
capability mapping so only ordinary GetHeaders permits fallback to arbitrary
peers; explicit GetHeaders2 must require ServiceFlags::NODE_HEADERS_COMPRESSED
and disable fallback. Keep the existing compressed-header capability requirement
while changing the fallback behavior for the GetHeaders2 variant.

Comment on lines +267 to +306
/// Pick the best peer: once any peer has a latency sample, only probed peers
/// are eligible (a still-unprobed or silent peer is skipped); among those it
/// takes the highest quality (latency score × behaviour multiplier), breaking
/// ties by lowest median RTT.
async fn select_best(
&self,
candidates: &[(SocketAddr, Arc<RwLock<Peer>>)],
) -> Option<(SocketAddr, Arc<RwLock<Peer>>)> {
if candidates.is_empty() {
return None;
}
let mut entries = Vec::with_capacity(candidates.len());
for (addr, peer) in candidates {
let (probed, stalling, quality, median) = {
let guard = peer.read().await;
(
guard.median_rtt().is_some(),
guard.is_stalling(),
guard.quality(),
guard.median_rtt(),
)
};
entries.push((
*addr,
peer.clone(),
probed,
stalling,
quality,
median.unwrap_or(Duration::MAX),
));
}

// Prefer non-stalling, probed peers; fall back only if none qualify so a
// single (even stalling) peer still gets used rather than stalling forever.
let any_probed = entries.iter().any(|e| e.2);
let any_live = entries.iter().any(|e| (!any_probed || e.2) && !e.3);
entries
.into_iter()
.filter(|e| (!any_probed || e.2) && (!any_live || !e.3))
.max_by(|a, b| a.4.total_cmp(&b.4).then_with(|| b.5.cmp(&a.5)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fall back to a live unprobed peer before a stalled probed peer.

If every probed peer is stalled, any_probed still excludes all unprobed peers and the filter retains the stalled set. This can repeatedly route through a hung peer despite a live alternative.

Proposed eligibility logic
-        let any_probed = entries.iter().any(|e| e.2);
-        let any_live = entries.iter().any(|e| (!any_probed || e.2) && !e.3);
+        let any_live_probed = entries.iter().any(|e| e.2 && !e.3);
+        let any_live = entries.iter().any(|e| !e.3);
         entries
             .into_iter()
-            .filter(|e| (!any_probed || e.2) && (!any_live || !e.3))
+            .filter(|e| {
+                if any_live_probed {
+                    e.2 && !e.3
+                } else if any_live {
+                    !e.3
+                } else {
+                    true
+                }
+            })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Pick the best peer: once any peer has a latency sample, only probed peers
/// are eligible (a still-unprobed or silent peer is skipped); among those it
/// takes the highest quality (latency score × behaviour multiplier), breaking
/// ties by lowest median RTT.
async fn select_best(
&self,
candidates: &[(SocketAddr, Arc<RwLock<Peer>>)],
) -> Option<(SocketAddr, Arc<RwLock<Peer>>)> {
if candidates.is_empty() {
return None;
}
let mut entries = Vec::with_capacity(candidates.len());
for (addr, peer) in candidates {
let (probed, stalling, quality, median) = {
let guard = peer.read().await;
(
guard.median_rtt().is_some(),
guard.is_stalling(),
guard.quality(),
guard.median_rtt(),
)
};
entries.push((
*addr,
peer.clone(),
probed,
stalling,
quality,
median.unwrap_or(Duration::MAX),
));
}
// Prefer non-stalling, probed peers; fall back only if none qualify so a
// single (even stalling) peer still gets used rather than stalling forever.
let any_probed = entries.iter().any(|e| e.2);
let any_live = entries.iter().any(|e| (!any_probed || e.2) && !e.3);
entries
.into_iter()
.filter(|e| (!any_probed || e.2) && (!any_live || !e.3))
.max_by(|a, b| a.4.total_cmp(&b.4).then_with(|| b.5.cmp(&a.5)))
/// Pick the best peer: once any peer has a latency sample, only probed peers
/// are eligible (a still-unprobed or silent peer is skipped); among those it
/// takes the highest quality (latency score × behaviour multiplier), breaking
/// ties by lowest median RTT.
async fn select_best(
&self,
candidates: &[(SocketAddr, Arc<RwLock<Peer>>)],
) -> Option<(SocketAddr, Arc<RwLock<Peer>>)> {
if candidates.is_empty() {
return None;
}
let mut entries = Vec::with_capacity(candidates.len());
for (addr, peer) in candidates {
let (probed, stalling, quality, median) = {
let guard = peer.read().await;
(
guard.median_rtt().is_some(),
guard.is_stalling(),
guard.quality(),
guard.median_rtt(),
)
};
entries.push((
*addr,
peer.clone(),
probed,
stalling,
quality,
median.unwrap_or(Duration::MAX),
));
}
// Prefer non-stalling, probed peers; fall back only if none qualify so a
// single (even stalling) peer still gets used rather than stalling forever.
let any_live_probed = entries.iter().any(|e| e.2 && !e.3);
let any_live = entries.iter().any(|e| !e.3);
entries
.into_iter()
.filter(|e| {
if any_live_probed {
e.2 && !e.3
} else if any_live {
!e.3
} else {
true
}
})
.max_by(|a, b| a.4.total_cmp(&b.4).then_with(|| b.5.cmp(&a.5)))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/pool.rs` around lines 267 - 306, Update select_best
eligibility so that when all probed peers are stalling, live unprobed peers are
eligible before any stalled probed peer. Preserve the existing preference for
non-stalling probed peers, and retain the single-peer fallback so selection
never becomes empty when no live peer exists.

Comment on lines +1 to +8
//! Peer behaviour scoring for selection.
//!
//! This module implements a reputation system to track peer behavior and protect
//! against malicious peers. It tracks both positive and negative behaviors,
//! implements automatic banning for excessive misbehavior, and provides reputation
//! decay over time for recovery.
//! Each peer carries a behaviour multiplier in `[0, 1]`. Good actions nudge it up
//! toward 1, bad actions down toward 0, and a security violation (the peer fed us
//! invalid data — it lied) drops it straight to 0. Peer selection multiplies this
//! by the peer's response-time score, so a misbehaving peer is deprioritised and a
//! lying one is never chosen. This manager persists the multiplier (and last
//! measured latency) so the judgement survives across connections.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use an allowed PR title prefix.

perf(...) is rejected by pr-title.yml. Since this PR adds peer-selection functionality, use feat(dash-spv): ....

As per path instructions, “the PR title matches one of: build, chore, ci, docs, feat, fix, refactor, test.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/reputation.rs` around lines 1 - 8, Update the pull
request title to use the allowed feat prefix, specifically “feat(dash-spv): …”,
instead of “perf(...)”.

Source: Path instructions

@QuantumExplorer

QuantumExplorer commented Jul 12, 2026

Copy link
Copy Markdown
Member

Review

Well-written, well-commented code with a sensible core idea, but it carries one availability bug I'd call blocking, plus several robustness regressions versus the system it replaces. Appropriate for a prototype; not mergeable as-is.

Overview

The PR replaces the score-based reputation/ban system with a simpler model (+902/−718 across 5 files in dash-spv/src/network/):

  • Each peer gets a behavior multiplier in [0, 1] (good events +0.1, bad events −0.1…−0.3, invalid data → instant 0) and a rolling window of 8 RTT samples.
  • New peers are latency-probed inline (3 pings, 1.2 s budget) before joining the pool; peers more than one latency tier worse than the pool's best are rejected or dropped.
  • A background scout probes 4 unconnected peers every 45 s to keep replacement candidates measured.
  • Round-robin request distribution is removed; all sync requests now route to the single best-scoring peer, with a 4 s stall detector triggering replacement.
  • Bans, decay, and the ban_peer/unban_peer/get_peer_reputations APIs are deleted.

I checked out the branch: it builds and all 35 network unit tests pass. The removed public APIs have no other callers in the repo, and old persisted reputation data degrades gracefully to defaults thanks to #[serde(default)].

Critical issue: peers zero out permanently, and the client can brick itself

The multiplier has no recovery path once it reaches 0: is_usable() returns false, so the peer is never dialed again, so no GoodResponse can ever raise it — and the zero is persisted to disk. The old system had hourly decay and 24 h ban expiry; this has neither, and the manual unban_peer escape hatch was deleted too. Concretely:

  • ConnectionFailed is −0.1 and MAINTENANCE_INTERVAL is 10 s. If the machine loses connectivity (laptop lid closed, flaky Wi-Fi), every known peer accrues a failure roughly every tick. ~100 seconds offline zeroes every known peer forever, surviving restarts. The SPV client is bricked until storage is wiped. Scout-probe failures (scout_once in manager.rs) feed the same spiral.
  • Exclusive mode death spiral: a stalling exclusive peer gets Timeout (−0.2) every 10 s tick with no replacement possible; after ~50 s its multiplier is 0 and connect_to_peer refuses it ("untrusted") permanently — including a locally-configured dev node that was briefly overloaded.
  • Headers2 decompression failure → multiplier 0 + disconnect. If the failure is ever caused by a bug on our side rather than a lying peer, the client will permanently distrust every headers2 peer one by one. The old behavior (disable headers2 for that peer, keep the connection) was strictly safer. Notably, the PR adds Peer::disable_headers2() for exactly that fallback but never calls it — dead code that suggests the safer path was intended.

Minimum fix: reintroduce time-based decay/expiry (or a floor > 0 for non-security penalties), and treat transport-level failures (can't connect) very differently from protocol violations.

Other correctness issues

  • probe_latency_inline swallows messages (manager.rs): during the up-to-1.2 s probe window it discards every non-ping/pong message. Peers typically send sendaddrv2/sendheaders2/addr/inv right after handshake; silently dropping them can lose protocol negotiation state.
  • Stall detection is loose (pool.rs, peer.rs): note_response() + GoodResponse fire on any forwarded message, not one matching the outstanding request — an unsolicited inv clears the stall timer. And awaiting_since is only armed for the first request, so pipelined requests share one timer. Also, since every dispatched message grants +0.1, the multiplier saturates at 1.0 during sync, largely neutralizing the behavioral signal while connected.
  • Worst-of-8 scoring is unforgiving: response_score() takes the minimum bucket over the sample window, and each unanswered probe ping is recorded as 1200 ms. One lost pong at connect tanks the peer's score for the next 8 samples.
  • Duplicate-pong quirk: pending.contains(n) doesn't drain the answered nonce, so a peer echoing the same pong three times ends the probe early with one cheap sample instead of penalty samples.
  • Duration::from_secs(60 * loop_iteration) for the LongUptime reward treats read-loop iterations as minutes — a pre-existing "rough estimate" that was carried over rather than fixed; worth a real timestamp now that it feeds persisted scoring.

Design concerns

  • Single-best-peer routing replaces round-robin distribution for sync traffic. The rationale (filter pipeline handles one fast peer better than interleaved responses) is plausible but unproven here — this is a perf(...) PR with no benchmark or sync-time measurements, and all load piles on one peer until a 4 s stall fires.
  • Exclusive mode now second-guesses the user: within_tier_band will silently refuse to redial an explicitly configured peer that measured a couple of tiers slower. For user-pinned peers, I'd argue configuration should win over heuristics.
  • All ban capability is gone. There is no way left to manually exclude a hostile peer, and no is_banned concept for callers. That's a public API break worth flagging in the PR description even though nothing in-repo uses it.

Test coverage

Unit tests are decent for the new primitives (scoring buckets, tie-breaks, bounded samples, rank ordering, persistence round-trip) and pass locally. But none of the interesting behavior — inline probe, pre-pool rejection, stall→replace, tier-band dropping, scout loop — is covered, and these are exactly the paths the dashd integration suite exists for. The offline-bricking and exclusive-mode scenarios above would make excellent integration tests (dash-spv/tests/dashd_sync/ has restart/disconnect harnesses already).

What's good

  • Clear, honest comments explaining why (tier bands, ceiling-not-quota, no-second-chances) — the intent is easy to audit.
  • The reputation module shrank by ~450 lines into something far more legible; PeerPool now owns peer selection instead of leaking Peer handles to the manager, which removes a class of races.
  • Graceful handling of legacy persisted data via serde defaults.
  • select_best's fallback logic (use a stalling peer rather than none) shows edge cases were considered.

Verdict

The direction — measure latency, score behavior, keep the best peers — is right, and the code craftsmanship is above average. But the trust model regressed from "penalize with decay and time-limited bans" to "permanent unrecoverable blacklist persisted to disk," which under ordinary conditions (a couple of minutes offline) can permanently brick the client. That, plus the unproven perf claim and missing integration coverage, is why I land at 5/10 for merge-readiness — as a prototype for discussion, it does its job.

🤖 Generated with Claude Code

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