dig_pex/engine.rs
1//! The [`PexEngine`] — the transport-agnostic, sans-IO core both a DIG Node and the relay embed
2//! (SPEC Appendix A).
3//!
4//! You feed the engine four kinds of input and it returns the messages to send + the events to act
5//! on; it does no I/O itself (the node/relay do the actual dig-nat mux / WebSocket reads and writes):
6//!
7//! - **link events** — [`link_up`](PexEngine::link_up) (produces our outgoing handshake + snapshot)
8//! and [`link_down`](PexEngine::link_down) (discards all per-link state, SPEC §5.5);
9//! - **inbound messages** — [`on_message`](PexEngine::on_message) validates + advances the receiver
10//! state machine, returning verified-candidate / dropped events and any `pex_error` replies, and
11//! penalizing misbehavior (SPEC §5, §6.4, §7, §11);
12//! - **local peer-set changes** — [`upsert_known`](PexEngine::upsert_known) /
13//! [`remove_known`](PexEngine::remove_known) maintain the first-hand set PEX advertises (SPEC §9.3);
14//! - **clock ticks** — [`tick`](PexEngine::tick) (~1/s) emits per-link `pex_delta`s for pending
15//! changes, spaced by the effective interval (SPEC §6).
16//!
17//! Timestamps are **Unix epoch milliseconds**. See [`crate`] docs for the node vs relay embedding.
18
19use std::collections::HashMap;
20
21use crate::caps::frame_within_bound;
22use crate::caps::{
23 PEX_MAX_ADDED, PEX_MAX_DROPPED, PEX_MAX_HINTS, PEX_MAX_INTERVAL, PEX_MAX_RECEIVED_PER_LINK,
24 PEX_MAX_SNAPSHOT, PEX_VERSION, PEX_VIOLATION_LIMIT,
25};
26use crate::entry::{PeerEntry, ValidateCtx};
27use crate::error::PexErrorCode;
28use crate::state::{LinkState, RecvPhase};
29use crate::timer::{arrival_floor_ms, clamp_interval, effective_interval_secs, jitter_ms};
30use crate::wire::PexMessage;
31
32/// Configuration for a [`PexEngine`] (SPEC Appendix A).
33#[derive(Debug, Clone)]
34pub struct PexConfig {
35 /// This participant's own transport identity (`peer_id`, `<64hex>`) — excluded from every
36 /// advertisement and used to skip self-entries on receive (SPEC §5.4).
37 pub local_peer_id: String,
38 /// The network this participant serves — every handshake declares it and every entry MUST match
39 /// it (SPEC §5.2, §7.3).
40 pub network_id: String,
41 /// This participant's own capability flags, sent in its handshake (SPEC §4.2). For the relay
42 /// introducer this is `["introducer"]`.
43 pub flags: Vec<String>,
44 /// The declared send interval (seconds) — clamped into `[30, 3600]` (SPEC §6.2). Default `60`.
45 pub interval: u32,
46 /// Whether to add SPEC §6.3 send jitter. Default `true`; tests may disable it for deterministic
47 /// scheduling (0% jitter is within the allowed `0..+10%`).
48 pub jitter: bool,
49}
50
51impl PexConfig {
52 /// A new config for `local_peer_id` on `network_id`, with the default 60 s interval, no flags,
53 /// and jitter enabled.
54 #[must_use]
55 pub fn new(local_peer_id: impl Into<String>, network_id: impl Into<String>) -> Self {
56 PexConfig {
57 local_peer_id: local_peer_id.into(),
58 network_id: network_id.into(),
59 flags: Vec::new(),
60 interval: crate::caps::PEX_DEFAULT_INTERVAL,
61 jitter: true,
62 }
63 }
64
65 /// Builder: set this participant's own capability flags.
66 #[must_use]
67 pub fn with_flags(mut self, flags: Vec<String>) -> Self {
68 self.flags = flags;
69 self
70 }
71
72 /// Builder: set the declared send interval (seconds), clamped into `[30, 3600]`.
73 #[must_use]
74 pub fn with_interval(mut self, secs: u32) -> Self {
75 self.interval = clamp_interval(secs);
76 self
77 }
78
79 /// Builder: enable/disable send jitter (SPEC §6.3).
80 #[must_use]
81 pub fn with_jitter(mut self, jitter: bool) -> Self {
82 self.jitter = jitter;
83 self
84 }
85}
86
87/// An event the engine surfaces from an inbound message for the host to act on (SPEC Appendix A).
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum PexEvent {
90 /// Validated, verified-candidate peer hints — feed to the address manager as new-table
91 /// candidates to dial + verify (SPEC §9.3). These are hints, never authenticated facts (§11.1).
92 Candidates(Vec<PeerEntry>),
93 /// The link's sender dropped these `peer_id`s (SPEC §8.3) — advisory. Unlist the sender as a
94 /// source for them; never delete a first-hand-verified peer on this alone.
95 Dropped {
96 /// The dropped ids this link had previously told us (ids it never told us are ignored).
97 peer_ids: Vec<String>,
98 },
99 /// The link's sender committed a violation (SPEC §11.2). `mute` is `true` once the direction is
100 /// muted — either at the strike limit (misbehavior: `code` 1/3/4/6 → the host MAY penalize /
101 /// disconnect) or immediately for a version/network mismatch (`code` 2/5 → benign; the host MUST
102 /// NOT tear down the underlying connection for that alone, SPEC §5.2).
103 Violation {
104 /// The SPEC §4.5 error code.
105 code: u16,
106 /// Whether the incoming direction is now muted.
107 mute: bool,
108 },
109}
110
111/// The result of feeding the engine an inbound message or transport error: the messages to send back
112/// on the link, plus the events for the host to act on.
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
114pub struct PexOutcome {
115 /// Messages to write back on the link (e.g. a `pex_error`). Best-effort / advisory (SPEC §4.5).
116 pub replies: Vec<PexMessage>,
117 /// Events for the host (candidates / dropped / violation).
118 pub events: Vec<PexEvent>,
119}
120
121/// A deduplicated inbound hint (SPEC §9.2) — the current best entry for a `peer_id` across all links.
122#[derive(Debug, Clone)]
123struct ReceivedHint {
124 /// The link (`peer_id`) that is currently the source for this hint.
125 source: String,
126 /// The `last_seen` of the current hint — newer wins across senders.
127 last_seen: u64,
128}
129
130/// The transport-agnostic PEX engine (SPEC Appendix A). One instance per participant; it multiplexes
131/// all of that participant's links.
132#[derive(Debug)]
133pub struct PexEngine {
134 cfg: PexConfig,
135 /// The first-hand known-peer set PEX advertises, keyed by `peer_id` (SPEC §9.3 outbound).
136 known: HashMap<String, PeerEntry>,
137 /// Per-link state, keyed by the transport `peer_id`.
138 links: HashMap<String, LinkState>,
139 /// Global inbound dedup (SPEC §9.2): `peer_id → current best hint`.
140 hints: HashMap<String, ReceivedHint>,
141 /// Bumped on every `known` mutation ([`upsert_known`](Self::upsert_known) /
142 /// [`remove_known`](Self::remove_known)) — invalidates `advertisable_cache` (#179 MED
143 /// optimization: the freshest-first, self-excluded base list is identical across every link
144 /// within one tick, so it is computed once and reused rather than per-link).
145 known_epoch: u64,
146 /// The cached partner-independent advertisable base list (self excluded, stale dropped,
147 /// freshest-first) — `(epoch it was built at, the `now_secs` it was built for, the list)`.
148 /// Recomputed only when `known_epoch` or `now_secs` has moved since the cached build.
149 advertisable_cache: std::cell::RefCell<Option<(u64, u64, Vec<PeerEntry>)>>,
150 /// Test-only instrumentation (#179 MED): counts actual `advertisable_base` rebuilds, so a test
151 /// can assert the cache is hit (O(1) rebuild per tick) rather than only checking behavioral
152 /// equivalence, which a naive per-link recompute would also satisfy.
153 #[cfg(test)]
154 advertisable_rebuilds: std::cell::Cell<u64>,
155}
156
157impl PexEngine {
158 /// Create an engine from `cfg`.
159 #[must_use]
160 pub fn new(cfg: PexConfig) -> Self {
161 PexEngine {
162 cfg,
163 known: HashMap::new(),
164 links: HashMap::new(),
165 hints: HashMap::new(),
166 known_epoch: 0,
167 advertisable_cache: std::cell::RefCell::new(None),
168 #[cfg(test)]
169 advertisable_rebuilds: std::cell::Cell::new(0),
170 }
171 }
172
173 // ----- local first-hand set (SPEC §9.3 outbound) -----
174
175 /// Add or update a **first-hand-known** peer in the advertise set (SPEC §8.1, §9.3). The caller
176 /// supplies the honest `via` + a fresh `last_seen`; the [`Provenance`](crate::Provenance) type
177 /// structurally forbids a `"pex"` provenance, so a PEX-learned entry can never be re-advertised
178 /// unverified. The change surfaces as `added` in the next [`tick`](Self::tick) delta on each link.
179 pub fn upsert_known(&mut self, entry: PeerEntry) {
180 // Never advertise ourselves (the link is our own advertisement — SPEC §5.4).
181 if entry.peer_id == self.cfg.local_peer_id {
182 return;
183 }
184 self.known.insert(entry.peer_id.clone(), entry);
185 self.known_epoch = self.known_epoch.wrapping_add(1);
186 }
187
188 /// Remove a peer from the advertise set — it disconnected or went stale (SPEC §9.3). It surfaces
189 /// as `dropped` in the next delta on links that were told it.
190 pub fn remove_known(&mut self, peer_id: &str) {
191 self.known.remove(peer_id);
192 self.known_epoch = self.known_epoch.wrapping_add(1);
193 }
194
195 // ----- link lifecycle (SPEC §5) -----
196
197 /// A link came up: register it and produce our outgoing direction — the `pex_handshake` followed
198 /// (back-to-back) by the `pex_snapshot` of our current first-hand set (SPEC §5.1, §6.1). Write
199 /// the returned messages on our sending stream. Preserves any receiver-side state if the link
200 /// already exists (e.g. an inbound message arrived first).
201 pub fn link_up(&mut self, peer_id: &str, now_ms: u64) -> Vec<PexMessage> {
202 let interval = self.cfg.interval;
203 let handshake = PexMessage::PexHandshake {
204 version: PEX_VERSION,
205 network_id: self.cfg.network_id.clone(),
206 interval,
207 flags: self.cfg.flags.clone(),
208 };
209
210 // Build the snapshot from the current advertisable set (freshest-first, capped, self+partner
211 // excluded) before mutating the link, so `known` isn't borrowed across the link mutation. Uses
212 // the same cached partner-independent base as `tick`'s deltas (#179 MED optimization).
213 let now_secs = now_ms / 1000;
214 let mut peers = self.advertisable_for(peer_id, now_secs);
215 peers.truncate(PEX_MAX_SNAPSHOT);
216 trim_to_frame_budget(&mut peers);
217
218 let remote_declared = self.links.get(peer_id).and_then(|l| l.remote_declared_secs);
219 let jitter = self.draw_jitter(effective_interval_secs(interval, remote_declared));
220
221 let link = self
222 .links
223 .entry(peer_id.to_string())
224 .or_insert_with(|| LinkState::new(interval));
225 link.self_interval_secs = interval;
226 link.handshake_sent = true;
227 for e in &peers {
228 link.told.insert(e.peer_id.clone(), e.fingerprint_hash());
229 }
230 link.snapshot_sent = true;
231 link.last_data_send_ms = Some(now_ms);
232 link.send_jitter_ms = jitter;
233
234 vec![handshake, PexMessage::PexSnapshot { peers }]
235 }
236
237 /// A link went down: discard all per-link state, and unlist it as the source of any current hints
238 /// (SPEC §5.5, §9.2). A new connection starts fresh.
239 pub fn link_down(&mut self, peer_id: &str) {
240 self.links.remove(peer_id);
241 self.hints.retain(|_, h| h.source != peer_id);
242 }
243
244 // ----- inbound (SPEC §5.3, §6.4, §7, §11) -----
245
246 /// Feed one decoded inbound message from `peer_id`. Returns replies to send + events to act on.
247 /// A malformed *entry* inside a valid message is skipped silently; a malformed *message* /
248 /// rate / oversize / state violation is discarded with a strike (SPEC §7.3, §11.2).
249 pub fn on_message(&mut self, peer_id: &str, msg: PexMessage, now_ms: u64) -> PexOutcome {
250 // Ensure a link exists (an inbound message may precede our own `link_up`).
251 let interval = self.cfg.interval;
252 let muted = {
253 let link = self
254 .links
255 .entry(peer_id.to_string())
256 .or_insert_with(|| LinkState::new(interval));
257 link.muted
258 };
259 if muted {
260 // Direction muted — ignore all further inbound PEX (SPEC §5.2, §11.2).
261 return PexOutcome::default();
262 }
263
264 match msg {
265 PexMessage::PexError { code, .. } => self.on_pex_error(peer_id, code, now_ms),
266 PexMessage::PexHandshake {
267 version,
268 network_id,
269 interval: declared,
270 ..
271 } => self.on_handshake(peer_id, version, &network_id, declared),
272 PexMessage::PexSnapshot { peers } => self.on_snapshot(peer_id, peers, now_ms),
273 PexMessage::PexDelta { added, dropped } => {
274 self.on_delta(peer_id, added, dropped, now_ms)
275 }
276 }
277 }
278
279 /// Record a transport-detected violation the engine could not see itself: a frame-size overrun
280 /// (`Oversized`) or an undecodable/malformed frame (`BadMessage`) — SPEC §7.2, §7.3. Counts a
281 /// strike and mutes at the limit, exactly like an engine-detected violation.
282 pub fn record_violation(
283 &mut self,
284 peer_id: &str,
285 code: PexErrorCode,
286 _now_ms: u64,
287 ) -> PexOutcome {
288 let interval = self.cfg.interval;
289 self.links
290 .entry(peer_id.to_string())
291 .or_insert_with(|| LinkState::new(interval));
292 self.strike(peer_id, code)
293 }
294
295 // ----- clock (SPEC §6.1) -----
296
297 /// Drive the send cadence (call ~1/s). For each link whose effective interval has elapsed since
298 /// its last data message and that has pending changes, emits a `pex_delta` (SPEC §4.4, §6). A
299 /// delta with no changes is suppressed (empty deltas are never sent). Returns `(peer_id,
300 /// message)` pairs to write to the matching links.
301 pub fn tick(&mut self, now_ms: u64) -> Vec<(String, PexMessage)> {
302 let now_secs = now_ms / 1000;
303 let mut out = Vec::new();
304 // Snapshot the link keys to avoid borrowing `self.links` while mutating per-link below.
305 let peer_ids: Vec<String> = self.links.keys().cloned().collect();
306 for peer_id in peer_ids {
307 let (eligible, effective) = {
308 let link = &self.links[&peer_id];
309 if !link.snapshot_sent {
310 continue; // we are receive-only on this link
311 }
312 let effective =
313 effective_interval_secs(link.self_interval_secs, link.remote_declared_secs);
314 let base = link.last_data_send_ms.unwrap_or(0);
315 let eligible = now_ms >= base + u64::from(effective) * 1000 + link.send_jitter_ms;
316 (eligible, effective)
317 };
318 if !eligible {
319 continue;
320 }
321
322 let (added, dropped) = self.build_delta(&peer_id, now_secs);
323 if added.is_empty() && dropped.is_empty() {
324 continue; // suppress empty deltas (SPEC §4.4)
325 }
326
327 // Commit the told-state for exactly what we send (SPEC §9.1); the capped remainder recurs.
328 let link = self.links.get_mut(&peer_id).expect("link exists");
329 for e in &added {
330 link.told.insert(e.peer_id.clone(), e.fingerprint_hash());
331 }
332 for id in &dropped {
333 link.told.remove(id);
334 }
335 link.last_data_send_ms = Some(now_ms);
336 let jitter = self.draw_jitter(effective);
337 self.links
338 .get_mut(&peer_id)
339 .expect("link exists")
340 .send_jitter_ms = jitter;
341
342 out.push((peer_id, PexMessage::PexDelta { added, dropped }));
343 }
344 out
345 }
346
347 // ----- read-only accessors (observability / tests) -----
348
349 /// Number of peers in the first-hand advertise set.
350 #[must_use]
351 pub fn known_count(&self) -> usize {
352 self.known.len()
353 }
354
355 /// Number of live links.
356 #[must_use]
357 pub fn link_count(&self) -> usize {
358 self.links.len()
359 }
360
361 /// Whether the incoming direction of `peer_id`'s link is muted (SPEC §5.2, §11.2).
362 #[must_use]
363 pub fn is_muted(&self, peer_id: &str) -> bool {
364 self.links.get(peer_id).is_some_and(|l| l.muted)
365 }
366
367 /// The violation strike count on `peer_id`'s incoming direction (SPEC §11.2).
368 #[must_use]
369 pub fn strikes(&self, peer_id: &str) -> u32 {
370 self.links.get(peer_id).map_or(0, |l| l.strikes)
371 }
372
373 /// How many peer ids we have currently told `peer_id`'s link (told-state size, SPEC §9.1).
374 #[must_use]
375 pub fn told_count(&self, peer_id: &str) -> usize {
376 self.links.get(peer_id).map_or(0, |l| l.told.len())
377 }
378
379 /// The current deduplicated hint for `peer_id` (SPEC §9.2): `(source link, last_seen)`, if any.
380 #[must_use]
381 pub fn current_hint(&self, peer_id: &str) -> Option<(&str, u64)> {
382 self.hints
383 .get(peer_id)
384 .map(|h| (h.source.as_str(), h.last_seen))
385 }
386
387 /// How many `peer_id`s `peer_id`'s link has told us are currently tracked in its `received`
388 /// accumulator (SPEC §9.2, §11.3) — bounded by [`crate::caps::PEX_MAX_RECEIVED_PER_LINK`].
389 #[must_use]
390 pub fn received_count(&self, peer_id: &str) -> usize {
391 self.links.get(peer_id).map_or(0, |l| l.received.len())
392 }
393
394 /// The total number of deduplicated hints currently held across all links (SPEC §9.2, §11.3) —
395 /// bounded by [`crate::caps::PEX_MAX_HINTS`].
396 #[must_use]
397 pub fn hints_count(&self) -> usize {
398 self.hints.len()
399 }
400
401 // ----- internals -----
402
403 fn draw_jitter(&self, effective_secs: u32) -> u64 {
404 if self.cfg.jitter {
405 jitter_ms(effective_secs)
406 } else {
407 0
408 }
409 }
410
411 /// `pex_error` is acceptable in any state and never changes the receiver state (SPEC §5.3). A
412 /// sender receiving code `3` SHOULD back off — double its effective interval, capped (SPEC §6.4).
413 ///
414 /// `pex_error` is advisory and **unauthenticated** (SPEC §4.5): any non-muted peer can send it at
415 /// will. Two gates bound how far/fast a spoofed code-3 flood can push us (LOW #179 fix):
416 ///
417 /// 1. **Plausibility** — only honored if we actually sent a data message to this peer recently
418 /// enough that a rate violation is plausible: `now_ms` must fall within
419 /// `arrival_floor_ms(self_interval_secs)` of `last_data_send_ms`. A code-3 arriving long after
420 /// our last send (or before we have ever sent anything) cannot correspond to a real violation
421 /// of *our* sends, so it is ignored.
422 /// 2. **Rate limit** — even a plausible code-3 is honored at most once per (pre-doubling)
423 /// effective interval: a flood of code-3 frames right after a legitimate one cannot keep
424 /// doubling toward `PEX_MAX_INTERVAL` faster than one genuine violation could.
425 fn on_pex_error(&mut self, peer_id: &str, code: u16, now_ms: u64) -> PexOutcome {
426 if code == PexErrorCode::RateViolation.as_u16() {
427 if let Some(link) = self.links.get_mut(peer_id) {
428 let plausible = link.last_data_send_ms.is_some_and(|sent| {
429 now_ms.saturating_sub(sent) < arrival_floor_ms(link.self_interval_secs)
430 });
431 let effective_ms = u64::from(link.self_interval_secs) * 1000;
432 let rate_limited = link
433 .last_backoff_applied_ms
434 .is_some_and(|applied| now_ms.saturating_sub(applied) < effective_ms);
435 if plausible && !rate_limited {
436 link.self_interval_secs = clamp_interval(
437 (link.self_interval_secs.saturating_mul(2)).min(PEX_MAX_INTERVAL),
438 );
439 link.last_backoff_applied_ms = Some(now_ms);
440 }
441 }
442 }
443 PexOutcome::default()
444 }
445
446 fn on_handshake(
447 &mut self,
448 peer_id: &str,
449 version: u32,
450 network_id: &str,
451 declared: u32,
452 ) -> PexOutcome {
453 let phase = self.links[peer_id].phase;
454 if phase != RecvPhase::AwaitingHandshake {
455 // A repeat handshake once past the handshake state is a protocol violation (SPEC §5.3).
456 return self.strike(peer_id, PexErrorCode::ProtocolViolation);
457 }
458 if version != PEX_VERSION {
459 return self.mute_mismatch(peer_id, PexErrorCode::UnsupportedVersion);
460 }
461 if network_id != self.cfg.network_id {
462 return self.mute_mismatch(peer_id, PexErrorCode::NetworkMismatch);
463 }
464 let link = self.links.get_mut(peer_id).expect("link exists");
465 link.remote_declared_secs = Some(clamp_interval(declared));
466 link.phase = RecvPhase::AwaitingSnapshot;
467 PexOutcome::default()
468 }
469
470 fn on_snapshot(&mut self, peer_id: &str, peers: Vec<PeerEntry>, now_ms: u64) -> PexOutcome {
471 match self.links[peer_id].phase {
472 RecvPhase::AwaitingHandshake => self.strike(peer_id, PexErrorCode::ProtocolViolation),
473 RecvPhase::Streaming => self.strike(peer_id, PexErrorCode::ProtocolViolation),
474 RecvPhase::AwaitingSnapshot => {
475 if peers.len() > PEX_MAX_SNAPSHOT {
476 return self.strike(peer_id, PexErrorCode::Oversized);
477 }
478 let link = self.links.get_mut(peer_id).expect("link exists");
479 link.phase = RecvPhase::Streaming;
480 link.last_arrival_ms = Some(now_ms); // the snapshot starts the arrival clock
481 self.ingest_added(peer_id, peers, now_ms)
482 }
483 }
484 }
485
486 fn on_delta(
487 &mut self,
488 peer_id: &str,
489 added: Vec<PeerEntry>,
490 dropped: Vec<String>,
491 now_ms: u64,
492 ) -> PexOutcome {
493 match self.links[peer_id].phase {
494 RecvPhase::AwaitingHandshake | RecvPhase::AwaitingSnapshot => {
495 // Data before handshake, or a delta before the snapshot (SPEC §5.3).
496 return self.strike(peer_id, PexErrorCode::ProtocolViolation);
497 }
498 RecvPhase::Streaming => {}
499 }
500
501 // Rate enforcement (SPEC §6.4): a delta arriving under the floor is discarded + struck.
502 let (floor, last) = {
503 let link = &self.links[peer_id];
504 (
505 arrival_floor_ms(link.remote_declared_secs.unwrap_or(0)),
506 link.last_arrival_ms,
507 )
508 };
509 if let Some(last) = last {
510 if now_ms.saturating_sub(last) < floor {
511 return self.strike(peer_id, PexErrorCode::RateViolation);
512 }
513 }
514
515 // List caps: reject the whole message, never truncate (SPEC §7.2).
516 if added.len() > PEX_MAX_ADDED || dropped.len() > PEX_MAX_DROPPED {
517 return self.strike(peer_id, PexErrorCode::Oversized);
518 }
519 // Structural MUST: a peer_id may not appear in both `added` and `dropped` (SPEC §4.4).
520 let added_ids: std::collections::HashSet<&str> =
521 added.iter().map(|e| e.peer_id.as_str()).collect();
522 if dropped.iter().any(|d| added_ids.contains(d.as_str())) {
523 return self.strike(peer_id, PexErrorCode::BadMessage);
524 }
525
526 self.links
527 .get_mut(peer_id)
528 .expect("link exists")
529 .last_arrival_ms = Some(now_ms);
530
531 let mut outcome = self.ingest_added(peer_id, added, now_ms);
532 outcome
533 .events
534 .extend(self.ingest_dropped(peer_id, dropped).events);
535 outcome
536 }
537
538 /// Validate + dedup a batch of inbound entries into `Candidates` (SPEC §3.3, §9.2). Malformed
539 /// entries are skipped silently.
540 fn ingest_added(&mut self, peer_id: &str, entries: Vec<PeerEntry>, now_ms: u64) -> PexOutcome {
541 let now_secs = now_ms / 1000;
542 let mut candidates = Vec::new();
543 for e in entries {
544 let ctx = ValidateCtx {
545 receiver_peer_id: &self.cfg.local_peer_id,
546 sender_peer_id: peer_id,
547 network_id: &self.cfg.network_id,
548 now_secs,
549 };
550 if e.validate(&ctx).is_err() {
551 continue; // malformed entry — skip silently (SPEC §3.3, §7.3)
552 }
553 let ce = e.clamped(now_secs);
554 // Attribute the hint to this link so a later `dropped` can be matched (SPEC §8.3). Bound
555 // the accumulator first (HIGH #179): a single authenticated peer must not be able to grow
556 // this link's `received` map without limit by streaming many distinct fresh peer_ids.
557 let link = self.links.get_mut(peer_id).expect("link exists");
558 if !link.received.contains_key(&ce.peer_id)
559 && link.received.len() >= PEX_MAX_RECEIVED_PER_LINK
560 {
561 evict_oldest(&mut link.received, |last_seen| *last_seen);
562 }
563 link.received.insert(ce.peer_id.clone(), ce.last_seen);
564 // Dedup: newest `last_seen` wins as the current hint (SPEC §9.2); only surface an entry
565 // that is new or fresher than what we already hold, to avoid re-dialing stale duplicates.
566 let fresher = match self.hints.get(&ce.peer_id) {
567 Some(h) => ce.last_seen > h.last_seen,
568 None => true,
569 };
570 if fresher {
571 // Bound the global hints map the same way (HIGH #179): many links each contributing
572 // distinct peer_ids must not grow this map without limit.
573 if !self.hints.contains_key(&ce.peer_id) && self.hints.len() >= PEX_MAX_HINTS {
574 evict_oldest(&mut self.hints, |h| h.last_seen);
575 }
576 self.hints.insert(
577 ce.peer_id.clone(),
578 ReceivedHint {
579 source: peer_id.to_string(),
580 last_seen: ce.last_seen,
581 },
582 );
583 candidates.push(ce);
584 }
585 }
586 let mut outcome = PexOutcome::default();
587 if !candidates.is_empty() {
588 outcome.events.push(PexEvent::Candidates(candidates));
589 }
590 outcome
591 }
592
593 /// Attribute `dropped` ids: only those this link previously told us are acted on (SPEC §4.4,
594 /// §8.3). If a dropped id's current hint was sourced from this link, clear it (unlist the source).
595 fn ingest_dropped(&mut self, peer_id: &str, dropped: Vec<String>) -> PexOutcome {
596 let mut attributed = Vec::new();
597 for id in dropped {
598 let told_us = self
599 .links
600 .get_mut(peer_id)
601 .expect("link exists")
602 .received
603 .remove(&id)
604 .is_some();
605 if told_us {
606 if let Some(h) = self.hints.get(&id) {
607 if h.source == peer_id {
608 self.hints.remove(&id);
609 }
610 }
611 attributed.push(id);
612 }
613 }
614 let mut outcome = PexOutcome::default();
615 if !attributed.is_empty() {
616 outcome.events.push(PexEvent::Dropped {
617 peer_ids: attributed,
618 });
619 }
620 outcome
621 }
622
623 /// Count a misbehavior strike (SPEC §11.2): discard the message, reply `pex_error` (advisory),
624 /// mute at the limit, and surface a `Violation` event. Version/network mismatch use
625 /// [`mute_mismatch`](Self::mute_mismatch) instead (immediate, non-strike mute).
626 fn strike(&mut self, peer_id: &str, code: PexErrorCode) -> PexOutcome {
627 let link = self.links.get_mut(peer_id).expect("link exists");
628 link.strikes += 1;
629 let mute = link.strikes >= PEX_VIOLATION_LIMIT;
630 if mute {
631 link.muted = true;
632 self.free_muted_link_state(peer_id);
633 }
634 PexOutcome {
635 replies: vec![PexMessage::PexError {
636 code: code.as_u16(),
637 message: code.message().to_string(),
638 }],
639 events: vec![PexEvent::Violation {
640 code: code.as_u16(),
641 mute,
642 }],
643 }
644 }
645
646 /// Immediately mute the direction for a version/network mismatch (SPEC §5.2). This is NOT a
647 /// strike (the peer is simply on a different version/network) and MUST NOT tear down the
648 /// underlying connection — PEX is an optional overlay.
649 fn mute_mismatch(&mut self, peer_id: &str, code: PexErrorCode) -> PexOutcome {
650 self.links.get_mut(peer_id).expect("link exists").muted = true;
651 self.free_muted_link_state(peer_id);
652 PexOutcome {
653 replies: vec![PexMessage::PexError {
654 code: code.as_u16(),
655 message: code.message().to_string(),
656 }],
657 events: vec![PexEvent::Violation {
658 code: code.as_u16(),
659 mute: true,
660 }],
661 }
662 }
663
664 /// Free accumulated state for a link whose incoming direction was just muted (SPEC §9.2, §11.3 —
665 /// HIGH #179 fix): treat mute like a soft `link_down` for the `received`/`hints` accumulators,
666 /// since a muted direction accepts no further inbound PEX (`on_message` early-returns) and so can
667 /// never again reference or grow that state. This bounds memory promptly rather than waiting for
668 /// the real `link_down` (which may be much later, or never, if the transport itself stays open).
669 fn free_muted_link_state(&mut self, peer_id: &str) {
670 if let Some(link) = self.links.get_mut(peer_id) {
671 link.received.clear();
672 }
673 self.hints.retain(|_, h| h.source != peer_id);
674 }
675}
676
677/// Evict the single oldest entry (by `last_seen`, ascending) from a bounded accumulator (SPEC §9.2,
678/// §11.3 — HIGH #179). Called once, immediately before an insert that would otherwise exceed the
679/// map's cardinality bound, so the map never grows past its cap. Ties break on key order for
680/// determinism. A no-op on an empty map (the caller only reaches the cap check when non-empty).
681fn evict_oldest<V>(map: &mut HashMap<String, V>, last_seen: impl Fn(&V) -> u64) {
682 if let Some(oldest_key) = map
683 .iter()
684 .min_by(|(ka, va), (kb, vb)| last_seen(va).cmp(&last_seen(vb)).then_with(|| ka.cmp(kb)))
685 .map(|(k, _)| k.clone())
686 {
687 map.remove(&oldest_key);
688 }
689}
690
691/// The **partner-independent** advertisable base for `known` at `now_secs`: self excluded (SPEC
692/// §5.4), stale entries dropped (SPEC §8.2), sorted **freshest-first** then by `peer_id` for a
693/// deterministic order (SPEC §4.3, §9.1). This ordering is identical for every link in a given tick
694/// (only the partner exclusion differs per link) — see [`PexEngine::advertisable_for`], which caches
695/// this and applies the cheap per-link partner exclusion (#179 MED optimization).
696fn advertisable_base(
697 known: &HashMap<String, PeerEntry>,
698 local_peer_id: &str,
699 now_secs: u64,
700) -> Vec<PeerEntry> {
701 let mut out: Vec<PeerEntry> = known
702 .values()
703 .filter(|e| e.peer_id != local_peer_id)
704 .filter(|e| {
705 // Not stale: within PEX_MAX_ENTRY_AGE (a future last_seen is treated as fresh).
706 e.last_seen >= now_secs || now_secs - e.last_seen <= crate::caps::PEX_MAX_ENTRY_AGE
707 })
708 .cloned()
709 .collect();
710 out.sort_by(|a, b| {
711 b.last_seen
712 .cmp(&a.last_seen)
713 .then_with(|| a.peer_id.cmp(&b.peer_id))
714 });
715 out
716}
717
718impl PexEngine {
719 /// The partner-independent advertisable base list for `now_secs`, computed once and reused for
720 /// every link (#179 MED optimization): freshest-first, self excluded, stale dropped. Cached in
721 /// `advertisable_cache` and only recomputed when `known_epoch` (bumped by
722 /// [`upsert_known`](Self::upsert_known)/[`remove_known`](Self::remove_known)) or `now_secs` has
723 /// moved since the cached build — so `L` links in one `tick` share a single O(K log K) build
724 /// instead of each paying it, where `K = known.len()`.
725 fn advertisable_cached(&self, now_secs: u64) -> std::cell::Ref<'_, Vec<PeerEntry>> {
726 {
727 let cache = self.advertisable_cache.borrow();
728 if let Some((epoch, cached_secs, _)) = cache.as_ref() {
729 if *epoch == self.known_epoch && *cached_secs == now_secs {
730 drop(cache);
731 return std::cell::Ref::map(self.advertisable_cache.borrow(), |c| {
732 &c.as_ref().unwrap().2
733 });
734 }
735 }
736 }
737 let fresh = advertisable_base(&self.known, &self.cfg.local_peer_id, now_secs);
738 #[cfg(test)]
739 self.advertisable_rebuilds
740 .set(self.advertisable_rebuilds.get() + 1);
741 *self.advertisable_cache.borrow_mut() = Some((self.known_epoch, now_secs, fresh));
742 std::cell::Ref::map(self.advertisable_cache.borrow(), |c| &c.as_ref().unwrap().2)
743 }
744
745 /// Test-only: how many times the advertisable base list has actually been rebuilt (#179 MED) —
746 /// used to assert the per-tick cache is hit rather than recomputed per link.
747 #[cfg(test)]
748 fn advertisable_rebuild_count(&self) -> u64 {
749 self.advertisable_rebuilds.get()
750 }
751
752 /// The advertisable subset for a link to `partner` at `now_secs`: the cached partner-independent
753 /// base (see [`advertisable_cached`](Self::advertisable_cached)) with `partner` excluded (SPEC
754 /// §5.4) cheaply during iteration — no additional clone or sort of the shared list.
755 fn advertisable_for(&self, partner: &str, now_secs: u64) -> Vec<PeerEntry> {
756 self.advertisable_cached(now_secs)
757 .iter()
758 .filter(|e| e.peer_id != partner)
759 .cloned()
760 .collect()
761 }
762
763 /// Compute the delta for a link relative to its told-state (SPEC §9.1): `added` = advertisable
764 /// entries not yet told (or told with a changed fingerprint), freshest-first, capped at
765 /// [`PEX_MAX_ADDED`]; `dropped` = told ids no longer advertisable, capped at [`PEX_MAX_DROPPED`].
766 fn build_delta(&self, peer_id: &str, now_secs: u64) -> (Vec<PeerEntry>, Vec<String>) {
767 let link = &self.links[peer_id];
768 let base = self.advertisable_cached(now_secs);
769
770 let mut added = Vec::new();
771 let mut advert_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
772 for e in base.iter().filter(|e| e.peer_id != peer_id) {
773 advert_ids.insert(e.peer_id.as_str());
774 if added.len() >= PEX_MAX_ADDED {
775 continue; // keep collecting ids for the dropped-set below; added is already capped
776 }
777 match link.told.get(&e.peer_id) {
778 // Cheap, allocation-free u64 equality — the hot-path check (#179 MED optimization).
779 Some(fp) if *fp == e.fingerprint_hash() => {} // unchanged — never re-advertise (SPEC §9.1)
780 _ => added.push(e.clone()),
781 }
782 }
783
784 let mut dropped = Vec::new();
785 for id in link.told.keys() {
786 if dropped.len() >= PEX_MAX_DROPPED {
787 break;
788 }
789 if !advert_ids.contains(id.as_str()) {
790 dropped.push(id.clone());
791 }
792 }
793 dropped.sort(); // deterministic order
794
795 (added, dropped)
796 }
797}
798
799/// Drop trailing entries until the snapshot they form encodes within [`PEX_MAX_FRAME`] (SPEC §7.2's
800/// sender-level cap).
801///
802/// `PEX_MAX_SNAPSHOT` bounds the entry *count*, but the frame cap a receiver enforces is in *bytes*,
803/// and the two stopped agreeing once entries could carry a signed payment claim (SPEC §3.4): 200
804/// maximal entries are ~214 KB unsigned and ~281 KB signed, against a 256 KiB frame. A sender that
805/// ignored the byte bound would emit a frame every conformant receiver must reject — and be struck
806/// for it. Entries arrive freshest-first, so trimming from the tail sheds the least useful ones.
807///
808/// The remainder is not lost: an entry omitted here is simply not in this link's told-set, so the
809/// next delta advertises it (SPEC §9.1).
810fn trim_to_frame_budget(peers: &mut Vec<PeerEntry>) {
811 // Measure the real encoding rather than estimating: the entry is JSON, its size depends on the
812 // address and flag content, and an estimate that drifted low would fail exactly where it matters.
813 let envelope = PexMessage::PexSnapshot { peers: Vec::new() }.encode().len();
814 let mut used = envelope;
815 for (i, entry) in peers.iter().enumerate() {
816 // +1 for the comma joining this entry to the previous one.
817 let cost = serde_json::to_vec(entry).map_or(usize::MAX, |b| b.len() + 1);
818 match used.checked_add(cost) {
819 Some(total) if frame_within_bound(total) => used = total,
820 _ => {
821 peers.truncate(i);
822 return;
823 }
824 }
825 }
826}
827
828#[cfg(test)]
829mod cap_tests {
830 use super::*;
831 use crate::caps::{PEX_MAX_HINTS, PEX_MAX_RECEIVED_PER_LINK};
832 use crate::entry::{Address, Provenance};
833
834 fn hex_id(n: u32) -> String {
835 // A deterministic, distinct 64-hex peer_id for index `n`.
836 format!("{n:064x}")
837 }
838
839 fn eng(local: &str) -> PexEngine {
840 PexEngine::new(PexConfig::new(local.to_string(), "mainnet".to_string()).with_jitter(false))
841 }
842
843 /// Ensure a link entry exists for `peer_id` (mirrors what `on_message` does before dispatch) so
844 /// the private `ingest_added`/`strike` helpers can be exercised directly in these tests.
845 fn ensure_link(e: &mut PexEngine, peer_id: &str) {
846 let interval = e.cfg.interval;
847 e.links
848 .entry(peer_id.to_string())
849 .or_insert_with(|| LinkState::new(interval));
850 }
851
852 fn distinct_entry(n: u32, now_secs: u64) -> PeerEntry {
853 PeerEntry::new(hex_id(n), "mainnet", now_secs, Provenance::Direct)
854 .with_address(Address::direct("203.0.113.7", 9444))
855 }
856
857 /// HIGH finding (#179): a single authenticated peer streaming far more than
858 /// `PEX_MAX_RECEIVED_PER_LINK` distinct fresh `peer_id`s over the life of a link must not grow
859 /// that link's `received` accumulator without bound — it must stay capped, with the oldest
860 /// entries evicted to make room for newer ones.
861 #[test]
862 fn received_map_is_capped_per_link_with_eviction() {
863 let local = hex_id(0);
864 let sender = hex_id(1);
865 let mut e = eng(&local);
866 let now_ms = 1_000_000_000_u64;
867
868 ensure_link(&mut e, &sender);
869 // Stream well past the cap in batches (ingest_added has no per-call size limit of its own —
870 // the message-level cap is enforced by on_delta/on_snapshot before this point).
871 let total = PEX_MAX_RECEIVED_PER_LINK + 500;
872 for n in 2..2 + total as u32 {
873 let entry = distinct_entry(n, now_ms / 1000);
874 e.ingest_added(&sender, vec![entry], now_ms);
875 }
876
877 assert!(
878 e.received_count(&sender) <= PEX_MAX_RECEIVED_PER_LINK,
879 "received map must stay bounded at PEX_MAX_RECEIVED_PER_LINK, got {}",
880 e.received_count(&sender)
881 );
882 // The oldest ids (evicted first) must be gone; the newest must remain.
883 assert!(
884 !e.links[&sender].received.contains_key(&hex_id(2)),
885 "the oldest entry should have been evicted"
886 );
887 let newest = hex_id(1 + total as u32);
888 assert!(
889 e.links[&sender].received.contains_key(&newest),
890 "the newest entry must survive eviction"
891 );
892 }
893
894 /// HIGH finding (#179): the engine-global `hints` map must stay bounded even when many distinct
895 /// links each contribute distinct fresh `peer_id`s, with oldest-`last_seen` eviction.
896 #[test]
897 fn hints_map_is_capped_globally_with_eviction() {
898 let local = hex_id(0);
899 let mut e = eng(&local);
900 let now_ms = 1_000_000_000_u64;
901
902 let total = PEX_MAX_HINTS + 500;
903 for n in 0..total as u32 {
904 // A distinct sender per entry so every hint is a genuinely new peer_id from a live link.
905 let sender = hex_id(1_000_000 + n);
906 ensure_link(&mut e, &sender);
907 let entry = distinct_entry(2_000_000 + n, now_ms / 1000 + u64::from(n));
908 e.ingest_added(&sender, vec![entry], now_ms);
909 }
910
911 assert!(
912 e.hints_count() <= PEX_MAX_HINTS,
913 "hints map must stay bounded at PEX_MAX_HINTS, got {}",
914 e.hints_count()
915 );
916 // The oldest (lowest last_seen) hint must have been evicted; the newest must remain.
917 assert!(
918 e.current_hint(&hex_id(2_000_000)).is_none(),
919 "the oldest hint should have been evicted"
920 );
921 let newest_peer = hex_id(2_000_000 + total as u32 - 1);
922 assert!(
923 e.current_hint(&newest_peer).is_some(),
924 "the newest hint must survive eviction"
925 );
926 }
927
928 /// Muting a direction is treated like a soft `link_down` for accumulated state (#179 fix note):
929 /// the link's `received` entries and any global `hints` sourced from it are freed immediately,
930 /// not left to accumulate until the real `link_down`.
931 #[test]
932 fn muting_a_direction_frees_its_received_and_sourced_hints() {
933 let local = hex_id(0);
934 let sender = hex_id(1);
935 let mut e = eng(&local);
936 let now_ms = 1_000_000_000_u64;
937
938 ensure_link(&mut e, &sender);
939 e.ingest_added(&sender, vec![distinct_entry(2, now_ms / 1000)], now_ms);
940 assert_eq!(e.received_count(&sender), 1);
941 assert!(e.current_hint(&hex_id(2)).is_some());
942
943 // Force three strikes to mute the incoming direction.
944 for _ in 0..3 {
945 e.strike(&sender, PexErrorCode::ProtocolViolation);
946 }
947 assert!(e.is_muted(&sender));
948
949 assert_eq!(
950 e.received_count(&sender),
951 0,
952 "received state must be freed when the direction is muted"
953 );
954 assert!(
955 e.current_hint(&hex_id(2)).is_none(),
956 "hints sourced from a now-muted link must be cleared"
957 );
958 }
959}
960
961#[cfg(test)]
962mod advertisable_cache_tests {
963 use super::*;
964 use crate::entry::{Address, Provenance};
965
966 fn hex_id(n: u32) -> String {
967 format!("{n:064x}")
968 }
969
970 fn eng(local: &str) -> PexEngine {
971 PexEngine::new(PexConfig::new(local.to_string(), "mainnet".to_string()).with_jitter(false))
972 }
973
974 fn known_entry(n: u32, last_seen: u64) -> PeerEntry {
975 PeerEntry::new(hex_id(n), "mainnet", last_seen, Provenance::Direct)
976 .with_address(Address::direct("203.0.113.7", 9444))
977 }
978
979 /// MEDIUM finding (#179): a single `tick` covering many links must build the partner-independent
980 /// advertisable base list ONCE and reuse it across every link, not clone+re-sort per link.
981 #[test]
982 fn tick_rebuilds_advertisable_base_once_for_many_links() {
983 let mut e = eng(&hex_id(0));
984 for n in 100..110 {
985 e.upsert_known(known_entry(n, 1_000));
986 }
987 for n in 0..20u32 {
988 e.link_up(&hex_id(n), 1_000_000);
989 }
990 // link_up itself uses the cache; each link_up call is at the SAME now_secs, so all 20 share
991 // one rebuild.
992 assert_eq!(
993 e.advertisable_rebuild_count(),
994 1,
995 "20 link_ups at the same now_secs must share a single advertisable rebuild, got {}",
996 e.advertisable_rebuild_count()
997 );
998
999 // Advance past every link's interval and tick: the per-tick delta computation for all 20
1000 // links must again share a single rebuild (a fresh one, since now_secs moved). Deltas may be
1001 // empty (nothing changed since link_up already told everything) — the rebuild count is what
1002 // this test asserts, not the delta contents.
1003 let _out = e.tick(1_000_000 + 61_000);
1004 assert_eq!(
1005 e.advertisable_rebuild_count(),
1006 2,
1007 "one tick covering 20 links must add exactly one more rebuild (now_secs changed once), got {}",
1008 e.advertisable_rebuild_count()
1009 );
1010 }
1011
1012 /// The cache must not go stale: a `known` mutation between two calls at the same `now_secs` MUST
1013 /// force a rebuild so a newly upserted (or removed) peer is reflected immediately.
1014 #[test]
1015 fn cache_invalidates_on_known_mutation_even_at_same_now_secs() {
1016 let mut e = eng(&hex_id(0));
1017 e.upsert_known(known_entry(1, 1_000));
1018 let now_ms = 1_000_000;
1019
1020 let first = e.advertisable_for(&hex_id(99), now_ms / 1000);
1021 assert_eq!(first.len(), 1);
1022 assert_eq!(e.advertisable_rebuild_count(), 1);
1023
1024 // Same now_secs, but known changed — must rebuild, not serve the stale cached list.
1025 e.upsert_known(known_entry(2, 1_000));
1026 let second = e.advertisable_for(&hex_id(99), now_ms / 1000);
1027 assert_eq!(second.len(), 2, "the newly upserted peer must appear");
1028 assert_eq!(
1029 e.advertisable_rebuild_count(),
1030 2,
1031 "a known mutation must invalidate the cache even at the same now_secs"
1032 );
1033
1034 // Same now_secs, no mutation — must reuse the cache (no third rebuild).
1035 let third = e.advertisable_for(&hex_id(98), now_ms / 1000);
1036 assert_eq!(third.len(), 2);
1037 assert_eq!(
1038 e.advertisable_rebuild_count(),
1039 2,
1040 "an unchanged known set at the same now_secs must reuse the cached build"
1041 );
1042 }
1043
1044 /// The cached base is still correctly filtered per-partner: excluding one link's partner must
1045 /// never leak into another link's view, even though they share the same cached base list.
1046 #[test]
1047 fn cached_base_still_excludes_each_links_own_partner() {
1048 let mut e = eng(&hex_id(0));
1049 e.upsert_known(known_entry(1, 1_000));
1050 e.upsert_known(known_entry(2, 1_000));
1051 let now_secs = 1_000;
1052
1053 let for_1 = e.advertisable_for(&hex_id(1), now_secs);
1054 assert!(
1055 for_1.iter().all(|p| p.peer_id != hex_id(1)),
1056 "peer 1's own link must never be advertised back to it"
1057 );
1058 assert!(for_1.iter().any(|p| p.peer_id == hex_id(2)));
1059
1060 let for_2 = e.advertisable_for(&hex_id(2), now_secs);
1061 assert!(for_2.iter().all(|p| p.peer_id != hex_id(2)));
1062 assert!(for_2.iter().any(|p| p.peer_id == hex_id(1)));
1063 }
1064}