Skip to main content

dig_pex/
entry.rs

1//! The **peer entry** — the unit of exchange (SPEC §3). It is the L7 `PeerRecord` shape extended
2//! with a `flags` list, carrying a `peer_id`, candidate `addresses`, the `network_id`, a `last_seen`
3//! Unix-seconds timestamp, a `via` provenance, and per-peer capability `flags`.
4//!
5//! ## Byte-compatibility
6//!
7//! [`Address`] (`{ host, port, kind }`) is byte-compatible with the L7 `dig.getPeers` addresses and
8//! the dig-nat / dig-gossip / dig-dht `Contact` address shape — same JSON field names and the same
9//! `kind` tokens (`direct` | `mapped` | `reflexive` | `relay`). It is mirrored here (rather than
10//! importing those crates) to keep the PEX dependency surface minimal; the wire form MUST stay
11//! identical so a returned entry drops straight into a dial target.
12//!
13//! ## Tolerant decode, strict validation
14//!
15//! Inbound entries decode **tolerantly**: an unrecognized `kind` or `via` token deserializes to the
16//! [`AddressKind::Unknown`] / [`Provenance::Unknown`] catch-all rather than failing the whole
17//! message, and every field has a default. This is what makes a malformed entry *skipped, not fatal*
18//! (SPEC §3.3, §7.3): [`PeerEntry::validate`] decides keep-or-skip; a broken token never aborts the
19//! sibling entries around it.
20
21use serde::{Deserialize, Serialize};
22
23use crate::caps::{PEX_MAX_ADDRESSES, PEX_MAX_ENTRY_AGE, PEX_MAX_FLAGS, PEX_MAX_FLAG_LEN};
24use crate::error::EntrySkip;
25use crate::payment::{PaymentClaim, PaymentClaimError, SignatureVerifier};
26
27/// How a candidate address was learned — the L7 `dig.getPeers` `addresses[].kind` tokens (SPEC §3.1).
28/// The lowercase serde spelling is the frozen wire form.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum AddressKind {
32    /// A directly reachable address (publicly routable or port-forwarded).
33    Direct,
34    /// A UPnP / NAT-PMP / PCP-mapped external address.
35    Mapped,
36    /// A STUN-discovered public reflexive address.
37    Reflexive,
38    /// Reachable through the relay (no direct candidate).
39    Relay,
40    /// An unrecognized token — the catch-all so an unknown `kind` skips the entry (SPEC §3.3)
41    /// instead of aborting the message. Never emitted by a conformant sender.
42    #[serde(other)]
43    #[default]
44    Unknown,
45}
46
47impl AddressKind {
48    /// The frozen lowercase wire token (or `"unknown"` for the catch-all).
49    #[must_use]
50    pub fn as_str(self) -> &'static str {
51        match self {
52            AddressKind::Direct => "direct",
53            AddressKind::Mapped => "mapped",
54            AddressKind::Reflexive => "reflexive",
55            AddressKind::Relay => "relay",
56            AddressKind::Unknown => "unknown",
57        }
58    }
59
60    /// Whether this is one of the four registered tokens (i.e. not the `Unknown` catch-all).
61    #[must_use]
62    pub fn is_registered(self) -> bool {
63        !matches!(self, AddressKind::Unknown)
64    }
65}
66
67/// The advertiser's **provenance** for an entry (SPEC §3.1, §8.1) — how it knows the peer first-hand.
68/// There is deliberately no `"pex"` token: an entry known only via PEX has no legitimate `via` to
69/// claim, which is what stops re-gossip amplification (SPEC §8.1).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Provenance {
73    /// Learned from a direct mTLS-verified connection to the peer.
74    Direct,
75    /// Learned from a relayed mTLS-verified connection to the peer.
76    Relay,
77    /// Learned from this participant's own introducer / relay registration surface.
78    Introducer,
79    /// An unrecognized token — the catch-all so an unknown `via` skips the entry (SPEC §3.3).
80    /// Never emitted by a conformant sender.
81    #[serde(other)]
82    #[default]
83    Unknown,
84}
85
86impl Provenance {
87    /// The frozen lowercase wire token (or `"unknown"` for the catch-all).
88    #[must_use]
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Provenance::Direct => "direct",
92            Provenance::Relay => "relay",
93            Provenance::Introducer => "introducer",
94            Provenance::Unknown => "unknown",
95        }
96    }
97
98    /// Whether this is one of the three registered provenance tokens.
99    #[must_use]
100    pub fn is_registered(self) -> bool {
101        !matches!(self, Provenance::Unknown)
102    }
103}
104
105/// One candidate address for a peer: `{ host, port, kind }` (SPEC §3.1). Byte-compatible with the L7
106/// `dig.getPeers` / DHT `Contact` address shape.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Address {
109    /// IPv4/IPv6 literal or hostname. MUST be non-empty (SPEC §3.3).
110    #[serde(default)]
111    pub host: String,
112    /// P2P port, 1–65535. A `port` of 0 skips the entry (SPEC §3.3).
113    #[serde(default)]
114    pub port: u16,
115    /// How this address was learned.
116    #[serde(default)]
117    pub kind: AddressKind,
118}
119
120impl Address {
121    /// A directly-dialable candidate (public / port-forwarded / discovered).
122    #[must_use]
123    pub fn direct(host: impl Into<String>, port: u16) -> Self {
124        Address {
125            host: host.into(),
126            port,
127            kind: AddressKind::Direct,
128        }
129    }
130
131    /// A candidate of a specific [`AddressKind`].
132    #[must_use]
133    pub fn new(host: impl Into<String>, port: u16, kind: AddressKind) -> Self {
134        Address {
135            host: host.into(),
136            port,
137            kind,
138        }
139    }
140
141    /// Whether this address is well-formed for a receiver (non-empty host, non-zero port, a
142    /// registered `kind`) — SPEC §3.3.
143    #[must_use]
144    pub fn is_valid(&self) -> bool {
145        !self.host.is_empty() && self.port != 0 && self.kind.is_registered()
146    }
147}
148
149/// The unit of exchange — a peer entry (SPEC §3). Constructed via [`PeerEntry::new`] + the builder
150/// methods for outgoing advertisements; decoded tolerantly for inbound validation.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct PeerEntry {
153    /// The advertised peer's mTLS identity, `<64hex>`.
154    #[serde(default)]
155    pub peer_id: String,
156    /// Candidate addresses, most-direct-first. MAY be empty (reachable only via shared
157    /// infrastructure, e.g. relay rendezvous by `peer_id`; see the `relay-only` flag).
158    #[serde(default)]
159    pub addresses: Vec<Address>,
160    /// The network the peer belongs to. MUST equal the link's network.
161    #[serde(default)]
162    pub network_id: String,
163    /// Unix seconds when the advertiser last had first-hand evidence of the peer (SPEC §8.2).
164    #[serde(default)]
165    pub last_seen: u64,
166    /// The advertiser's provenance for this entry (SPEC §8.1).
167    #[serde(default)]
168    pub via: Provenance,
169    /// Per-peer capability flags (SPEC §3.2). Optional; defaults to empty.
170    #[serde(default)]
171    pub flags: Vec<String>,
172    /// The peer's self-signed payment address (SPEC §3.4). Optional and omitted entirely when absent,
173    /// so a record stays readable by peers predating the field.
174    ///
175    /// Read it with [`verified_payment_address`](Self::verified_payment_address) — the claim's own
176    /// fields are private precisely so an unchecked payee cannot be obtained from it.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub payment: Option<PaymentClaim>,
179}
180
181impl PeerEntry {
182    /// A new entry for `peer_id` on `network_id`, last seen at `last_seen` (Unix seconds), with
183    /// provenance `via`. Add addresses/flags with the builder methods.
184    #[must_use]
185    pub fn new(
186        peer_id: impl Into<String>,
187        network_id: impl Into<String>,
188        last_seen: u64,
189        via: Provenance,
190    ) -> Self {
191        PeerEntry {
192            peer_id: peer_id.into(),
193            addresses: Vec::new(),
194            network_id: network_id.into(),
195            last_seen,
196            via,
197            flags: Vec::new(),
198            payment: None,
199        }
200    }
201
202    /// Builder: append a candidate address.
203    #[must_use]
204    pub fn with_address(mut self, addr: Address) -> Self {
205        self.addresses.push(addr);
206        self
207    }
208
209    /// Builder: append a capability flag token.
210    #[must_use]
211    pub fn with_flag(mut self, flag: impl Into<String>) -> Self {
212        self.flags.push(flag.into());
213        self
214    }
215
216    /// Builder: attach the peer's self-signed payment claim (SPEC §3.4).
217    #[must_use]
218    pub fn with_payment(mut self, claim: PaymentClaim) -> Self {
219        self.payment = Some(claim);
220        self
221    }
222
223    /// The peer's payment address, **only** when the attached claim proves this peer designated it on
224    /// this entry's network (SPEC §3.4.3); otherwise the reason there is no payee.
225    ///
226    /// This is deliberately a different question from whether the entry is usable at all: an entry
227    /// whose claim fails here is still a perfectly good dial hint (see
228    /// [`validate`](Self::validate)), because letting a corrupted payee cost a peer its reachability
229    /// would hand a relaying attacker a way to partition it. Reachability and payability are two
230    /// verdicts on one record, and they are answered by two different methods.
231    ///
232    /// `verifier` supplies the signature primitive for the peer's key type (SPEC §3.4.3); the
233    /// `SHA-256(SPKI) == peer_id` binding is checked by this crate regardless of what the verifier
234    /// does.
235    pub fn verified_payment_address(
236        &self,
237        verifier: &impl SignatureVerifier,
238    ) -> Result<&str, PaymentClaimError> {
239        self.payment
240            .as_ref()
241            .ok_or(PaymentClaimError::NotPresent)?
242            .verify(&self.peer_id, &self.network_id, verifier)
243    }
244
245    /// Validate this entry against the receiver's link context (SPEC §3.3). Returns the reason a
246    /// conformant receiver skips it, or `Ok(())` to keep it. Skipping is silent — no strike.
247    pub fn validate(&self, ctx: &ValidateCtx<'_>) -> Result<(), EntrySkip> {
248        if !is_hex64(&self.peer_id) {
249            return Err(EntrySkip::BadPeerId);
250        }
251        if self.peer_id == ctx.receiver_peer_id || self.peer_id == ctx.sender_peer_id {
252            return Err(EntrySkip::SelfOrPartner);
253        }
254        if self.addresses.len() > PEX_MAX_ADDRESSES {
255            return Err(EntrySkip::TooManyAddresses);
256        }
257        if self.addresses.iter().any(|a| !a.is_valid()) {
258            return Err(EntrySkip::BadAddress);
259        }
260        if self.flags.len() > PEX_MAX_FLAGS || self.flags.iter().any(|f| f.len() > PEX_MAX_FLAG_LEN)
261        {
262            return Err(EntrySkip::TooManyFlags);
263        }
264        if self.network_id != ctx.network_id {
265            return Err(EntrySkip::NetworkMismatch);
266        }
267        if !self.via.is_registered() {
268            return Err(EntrySkip::BadVia);
269        }
270        // Size only — an unverifiable claim is NOT a reason to drop the entry (SPEC §3.4.3); this
271        // bounds what a hostile sender can make a receiver hold, nothing more.
272        if self.payment.as_ref().is_some_and(|p| !p.within_caps()) {
273            return Err(EntrySkip::OversizePayment);
274        }
275        // A `last_seen` in the future is clamped by the caller (see `clamped`); only an entry too far
276        // in the PAST is skipped.
277        if self.last_seen < ctx.now_secs && ctx.now_secs - self.last_seen > PEX_MAX_ENTRY_AGE {
278            return Err(EntrySkip::TooOld);
279        }
280        Ok(())
281    }
282
283    /// A copy with a future `last_seen` clamped to `now_secs` (SPEC §3.3 — a receiver SHOULD clamp a
284    /// `last_seen` in the future to its own clock).
285    #[must_use]
286    pub fn clamped(&self, now_secs: u64) -> PeerEntry {
287        let mut e = self.clone();
288        if e.last_seen > now_secs {
289            e.last_seen = now_secs;
290        }
291        e
292    }
293
294    /// The per-link **advertised-content fingerprint** — a stable string over the addresses and flags
295    /// **excluding `last_seen`** (SPEC §9.1), so heartbeat churn (a fresher `last_seen` alone) never
296    /// re-advertises an unchanged peer. Two entries with the same fingerprint are "the same
297    /// advertisement" for delta purposes.
298    ///
299    /// This allocates (a `Vec<String>` per call plus the final formatted `String`) and is intended
300    /// for display/debugging/tests. The delta hot path uses the allocation-free
301    /// [`fingerprint_hash`](Self::fingerprint_hash) instead (#179 MED optimization).
302    #[must_use]
303    pub fn fingerprint(&self) -> String {
304        let mut addrs: Vec<String> = self
305            .addresses
306            .iter()
307            .map(|a| format!("{}|{}|{}", a.host, a.port, a.kind.as_str()))
308            .collect();
309        addrs.sort();
310        let mut flags = self.flags.clone();
311        flags.sort();
312        let payment = self
313            .payment
314            .as_ref()
315            .map(|p| p.wire_parts().join("|"))
316            .unwrap_or_default();
317        format!("{}#{}#{}", addrs.join(","), flags.join(","), payment)
318    }
319
320    /// The allocation-free equivalent of [`fingerprint`](Self::fingerprint): a 64-bit hash over the
321    /// same canonical content (addresses + flags, sorted, **excluding `last_seen`**) with the same
322    /// equality semantics — two entries with equal `fingerprint()` strings MUST have equal
323    /// `fingerprint_hash()` values (and vice versa for practical purposes; a hash collision is
324    /// possible but not a correctness concern for this delta-suppression use). Used as the `told`-map
325    /// value (SPEC §9.1) so the per-tick, per-link delta comparison is a `Copy`, allocation-free `u64`
326    /// equality check instead of building + sorting + formatting a `String` per advertisable entry
327    /// per link per tick (#179 MED optimization).
328    ///
329    /// Sorts addresses/flags by **reference** (`Vec<&Address>` / `Vec<&str>`, no cloning) before
330    /// feeding a stable field-separated byte stream to the hasher, so the result is independent of
331    /// input order while never allocating an intermediate `String`.
332    #[must_use]
333    pub fn fingerprint_hash(&self) -> u64 {
334        use std::hash::{Hash, Hasher};
335
336        let mut addrs: Vec<&Address> = self.addresses.iter().collect();
337        addrs.sort_by(|a, b| {
338            (a.host.as_str(), a.port, a.kind.as_str()).cmp(&(
339                b.host.as_str(),
340                b.port,
341                b.kind.as_str(),
342            ))
343        });
344        let mut flags: Vec<&str> = self.flags.iter().map(String::as_str).collect();
345        flags.sort_unstable();
346
347        // A fixed-seed hasher (not HashMap's randomized default) so the result is reproducible within
348        // and across engine instances in the same process run — only ever compared in-memory, never
349        // persisted or sent over the wire, so cross-process/version stability is not required.
350        let mut hasher = std::collections::hash_map::DefaultHasher::new();
351        addrs.len().hash(&mut hasher);
352        for a in &addrs {
353            a.host.hash(&mut hasher);
354            a.port.hash(&mut hasher);
355            a.kind.as_str().hash(&mut hasher);
356        }
357        flags.len().hash(&mut hasher);
358        for f in &flags {
359            f.hash(&mut hasher);
360        }
361        // A replaced or re-signed payment claim is a content change, so it must re-advertise.
362        match &self.payment {
363            Some(p) => p.wire_parts().hash(&mut hasher),
364            None => 0u8.hash(&mut hasher),
365        }
366        hasher.finish()
367    }
368}
369
370/// The receiver-side context an inbound entry is validated against (SPEC §3.3).
371#[derive(Debug, Clone, Copy)]
372pub struct ValidateCtx<'a> {
373    /// The receiver's own `peer_id` — an entry advertising it is skipped (SPEC §5.4).
374    pub receiver_peer_id: &'a str,
375    /// The link partner's (sender's) `peer_id` — an entry advertising it is skipped (SPEC §5.4).
376    pub sender_peer_id: &'a str,
377    /// The link's network — an entry on a different `network_id` is skipped.
378    pub network_id: &'a str,
379    /// The receiver's current time in Unix seconds — for the freshness check.
380    pub now_secs: u64,
381}
382
383/// Whether `s` is exactly 64 lowercase hexadecimal characters (`<64hex>`, SPEC §2).
384#[must_use]
385pub fn is_hex64(s: &str) -> bool {
386    s.len() == 64
387        && s.bytes()
388            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn hex(b: u8) -> String {
396        format!("{b:02x}").repeat(32)
397    }
398
399    fn ctx<'a>(recv: &'a str, send: &'a str, net: &'a str, now: u64) -> ValidateCtx<'a> {
400        ValidateCtx {
401            receiver_peer_id: recv,
402            sender_peer_id: send,
403            network_id: net,
404            now_secs: now,
405        }
406    }
407
408    #[test]
409    fn hex64_recognizer() {
410        assert!(is_hex64(&"a".repeat(64)));
411        assert!(is_hex64(&hex(0xab)));
412        assert!(!is_hex64(&"a".repeat(63)));
413        assert!(!is_hex64(&"A".repeat(64))); // uppercase not allowed
414        assert!(!is_hex64(&"g".repeat(64))); // non-hex
415    }
416
417    #[test]
418    fn kind_and_via_tokens_are_frozen_lowercase() {
419        assert_eq!(
420            serde_json::to_string(&AddressKind::Direct).unwrap(),
421            "\"direct\""
422        );
423        assert_eq!(
424            serde_json::to_string(&AddressKind::Relay).unwrap(),
425            "\"relay\""
426        );
427        assert_eq!(
428            serde_json::to_string(&Provenance::Introducer).unwrap(),
429            "\"introducer\""
430        );
431    }
432
433    #[test]
434    fn unknown_kind_and_via_decode_to_catch_all() {
435        let a: Address = serde_json::from_str(r#"{"host":"h","port":1,"kind":"quantum"}"#).unwrap();
436        assert_eq!(a.kind, AddressKind::Unknown);
437        let e: PeerEntry = serde_json::from_str(
438            r#"{"peer_id":"x","addresses":[],"network_id":"n","last_seen":1,"via":"teleport"}"#,
439        )
440        .unwrap();
441        assert_eq!(e.via, Provenance::Unknown);
442    }
443
444    #[test]
445    fn valid_entry_passes() {
446        let e = PeerEntry::new(hex(0x07), "mainnet", 1000, Provenance::Direct)
447            .with_address(Address::direct("203.0.113.7", 9444))
448            .with_flag("storage");
449        assert!(e
450            .validate(&ctx(&hex(0x01), &hex(0x02), "mainnet", 1000))
451            .is_ok());
452    }
453
454    #[test]
455    fn skip_reasons_match_spec() {
456        let recv = hex(0x01);
457        let send = hex(0x02);
458        // bad peer_id
459        let e = PeerEntry::new("nothex", "mainnet", 10, Provenance::Direct);
460        assert_eq!(
461            e.validate(&ctx(&recv, &send, "mainnet", 10)),
462            Err(EntrySkip::BadPeerId)
463        );
464        // self / partner
465        let e = PeerEntry::new(recv.clone(), "mainnet", 10, Provenance::Direct);
466        assert_eq!(
467            e.validate(&ctx(&recv, &send, "mainnet", 10)),
468            Err(EntrySkip::SelfOrPartner)
469        );
470        let e = PeerEntry::new(send.clone(), "mainnet", 10, Provenance::Direct);
471        assert_eq!(
472            e.validate(&ctx(&recv, &send, "mainnet", 10)),
473            Err(EntrySkip::SelfOrPartner)
474        );
475        // bad address (port 0)
476        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct)
477            .with_address(Address::new("h", 0, AddressKind::Direct));
478        assert_eq!(
479            e.validate(&ctx(&recv, &send, "mainnet", 10)),
480            Err(EntrySkip::BadAddress)
481        );
482        // unknown kind
483        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct)
484            .with_address(Address::new("h", 1, AddressKind::Unknown));
485        assert_eq!(
486            e.validate(&ctx(&recv, &send, "mainnet", 10)),
487            Err(EntrySkip::BadAddress)
488        );
489        // network mismatch
490        let e = PeerEntry::new(hex(0x07), "testnet", 10, Provenance::Direct);
491        assert_eq!(
492            e.validate(&ctx(&recv, &send, "mainnet", 10)),
493            Err(EntrySkip::NetworkMismatch)
494        );
495        // bad via
496        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Unknown);
497        assert_eq!(
498            e.validate(&ctx(&recv, &send, "mainnet", 10)),
499            Err(EntrySkip::BadVia)
500        );
501        // too old
502        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
503        assert_eq!(
504            e.validate(&ctx(&recv, &send, "mainnet", 2000)),
505            Err(EntrySkip::TooOld)
506        );
507    }
508
509    #[test]
510    fn too_many_addresses_and_flags() {
511        let recv = hex(0x01);
512        let send = hex(0x02);
513        let mut e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
514        for i in 0..9 {
515            e = e.with_address(Address::direct("h", 1000 + i));
516        }
517        assert_eq!(
518            e.validate(&ctx(&recv, &send, "mainnet", 10)),
519            Err(EntrySkip::TooManyAddresses)
520        );
521
522        let mut e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
523        for i in 0..9 {
524            e = e.with_flag(format!("f{i}"));
525        }
526        assert_eq!(
527            e.validate(&ctx(&recv, &send, "mainnet", 10)),
528            Err(EntrySkip::TooManyFlags)
529        );
530    }
531
532    #[test]
533    fn future_last_seen_is_clamped_not_skipped() {
534        let recv = hex(0x01);
535        let send = hex(0x02);
536        let e = PeerEntry::new(hex(0x07), "mainnet", 5000, Provenance::Direct);
537        // now=1000, last_seen=5000 (future) — valid, and clamped to now.
538        assert!(e.validate(&ctx(&recv, &send, "mainnet", 1000)).is_ok());
539        assert_eq!(e.clamped(1000).last_seen, 1000);
540        assert_eq!(e.clamped(6000).last_seen, 5000);
541    }
542
543    #[test]
544    fn fingerprint_ignores_last_seen_but_tracks_addresses_and_flags() {
545        let a = PeerEntry::new(hex(0x07), "mainnet", 100, Provenance::Direct)
546            .with_address(Address::direct("h", 1))
547            .with_flag("storage");
548        let a2 = PeerEntry::new(hex(0x07), "mainnet", 999, Provenance::Direct)
549            .with_address(Address::direct("h", 1))
550            .with_flag("storage");
551        assert_eq!(
552            a.fingerprint(),
553            a2.fingerprint(),
554            "last_seen must not affect fingerprint"
555        );
556        let b = a2.clone().with_flag("holepunch");
557        assert_ne!(
558            a.fingerprint(),
559            b.fingerprint(),
560            "a flag change must change fingerprint"
561        );
562    }
563
564    /// MEDIUM finding (#179): `fingerprint_hash()` is the allocation-free hot-path equality check
565    /// used in the delta loop (`told` stores this hash, not the `String` fingerprint). It MUST agree
566    /// with `fingerprint()`'s equality semantics: same addresses+flags (order-independent) and
567    /// `last_seen`-independence hash equal; a real content change hashes different.
568    #[test]
569    fn fingerprint_hash_matches_fingerprint_equality_semantics() {
570        let a = PeerEntry::new(hex(0x07), "mainnet", 100, Provenance::Direct)
571            .with_address(Address::direct("h", 1))
572            .with_flag("storage");
573        let a2 = PeerEntry::new(hex(0x07), "mainnet", 999, Provenance::Direct)
574            .with_address(Address::direct("h", 1))
575            .with_flag("storage");
576        assert_eq!(
577            a.fingerprint_hash(),
578            a2.fingerprint_hash(),
579            "last_seen must not affect fingerprint_hash"
580        );
581        assert_eq!(
582            a.fingerprint() == a2.fingerprint(),
583            a.fingerprint_hash() == a2.fingerprint_hash(),
584            "fingerprint_hash must agree with fingerprint on equality"
585        );
586
587        let b = a2.clone().with_flag("holepunch");
588        assert_ne!(
589            a.fingerprint_hash(),
590            b.fingerprint_hash(),
591            "a flag change must change fingerprint_hash"
592        );
593        assert_eq!(
594            a.fingerprint() == b.fingerprint(),
595            a.fingerprint_hash() == b.fingerprint_hash(),
596            "fingerprint_hash must agree with fingerprint on inequality"
597        );
598
599        // Multiple addresses/flags supplied in a different insertion order must still hash equal
600        // (the canonical form sorts both before hashing, same as `fingerprint()`).
601        let c = PeerEntry::new(hex(0x08), "mainnet", 1, Provenance::Direct)
602            .with_address(Address::direct("h1", 1))
603            .with_address(Address::direct("h2", 2))
604            .with_flag("storage")
605            .with_flag("holepunch");
606        let d = PeerEntry::new(hex(0x08), "mainnet", 2, Provenance::Direct)
607            .with_address(Address::direct("h2", 2))
608            .with_address(Address::direct("h1", 1))
609            .with_flag("holepunch")
610            .with_flag("storage");
611        assert_eq!(
612            c.fingerprint_hash(),
613            d.fingerprint_hash(),
614            "address/flag insertion order must not affect fingerprint_hash"
615        );
616        assert_eq!(c.fingerprint(), d.fingerprint());
617    }
618
619    #[test]
620    fn entry_round_trips_through_json() {
621        let e = PeerEntry::new(hex(0x07), "mainnet", 1_719_763_200, Provenance::Direct)
622            .with_address(Address::direct("203.0.113.7", 9444))
623            .with_flag("storage")
624            .with_flag("holepunch");
625        let json = serde_json::to_string(&e).unwrap();
626        assert!(json.contains("\"peer_id\":"));
627        assert!(json.contains("\"via\":\"direct\""));
628        assert!(json.contains("\"kind\":\"direct\""));
629        let back: PeerEntry = serde_json::from_str(&json).unwrap();
630        assert_eq!(e, back);
631    }
632}