Skip to main content

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}