Skip to main content

zakura_network/zakura/discovery/
protocol.rs

1//! Native Zakura discovery wire messages and signed node records.
2
3use std::{
4    cmp::Reverse,
5    collections::{HashMap, HashSet},
6    fmt,
7    io::{self, Cursor, Read, Write},
8    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
9    sync::Arc,
10    time::{Duration, SystemTime, UNIX_EPOCH},
11};
12
13use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
14use iroh::{NodeAddr, NodeId, SecretKey};
15use rand::seq::IteratorRandom;
16use thiserror::Error;
17use tokio::sync::{watch, Mutex};
18use zakura_chain::{
19    block,
20    primitives::ed25519::{Signature, SigningKey, VerificationKey},
21};
22
23use crate::zakura::{
24    BlockSyncStatus, ServiceAdmissionDecision, ServicePeerDirection, ServicePeerLimits,
25    ServicePeerSnapshot, ZakuraConnId, ZakuraNetworkId, ZakuraPeerId, MAX_BS_BLOCKS_PER_REQUEST,
26    MAX_BS_RESPONSE_BYTES,
27};
28
29/// Native discovery stream kind.
30pub const ZAKURA_STREAM_DISCOVERY: u16 = 4;
31/// Native discovery stream version.
32pub const ZAKURA_DISCOVERY_STREAM_VERSION: u16 = 1;
33
34/// Discovery hello message carrying the sender's signed record.
35pub const MSG_DISCOVERY_HELLO: u8 = 1;
36/// Discovery peer sample request.
37pub const MSG_DISCOVERY_GET_PEERS: u8 = 2;
38/// Discovery peer sample response.
39pub const MSG_DISCOVERY_PEERS: u8 = 3;
40/// First-party live service summary request.
41pub const MSG_DISCOVERY_GET_SERVICES: u8 = 4;
42/// First-party live service summary response.
43pub const MSG_DISCOVERY_SERVICES: u8 = 5;
44
45/// Maximum bytes in an encoded discovery message.
46pub const MAX_DISCOVERY_MESSAGE_BYTES: usize = 16 * 1024;
47/// Maximum bytes in an encoded node record body.
48pub const MAX_NODE_RECORD_BODY_BYTES: usize = 16 * 1024;
49/// Maximum direct addresses in a node record.
50pub const MAX_DIRECT_ADDRS_PER_RECORD: usize = 8;
51/// Maximum services in a node record or query.
52pub const MAX_SERVICES_PER_RECORD: usize = 32;
53/// Maximum services requested in a first-party live-service query.
54pub const MAX_GET_SERVICES_WANTED: usize = 32;
55/// Maximum first-party live summaries in a service response.
56pub const MAX_SERVICE_SUMMARIES_PER_RESPONSE: usize = 32;
57/// Maximum bytes in one length-delimited live summary payload.
58pub const MAX_SERVICE_SUMMARY_BYTES: usize = 1024;
59/// Maximum records in a discovery response.
60pub const MAX_DISCOVERY_RECORDS_PER_RESPONSE: usize = 32;
61/// Maximum excluded node ids in a discovery query.
62pub const MAX_DISCOVERY_EXCLUDED_NODE_IDS: usize = 256;
63/// Maximum bytes in a service id.
64pub const MAX_ZAKURA_SERVICE_ID_BYTES: usize = 64;
65/// Default lifetime for locally authored discovery records.
66pub const DEFAULT_DISCOVERY_RECORD_TTL: Duration = Duration::from_secs(24 * 60 * 60);
67/// Default minimum interval between active discovery refreshes.
68pub const DEFAULT_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(10 * 60);
69/// Default peer sample size requested by active discovery.
70pub const DEFAULT_DISCOVERY_PEER_SAMPLE_LIMIT: usize = 32;
71/// Default maximum accepted future TTL on imported records.
72pub const DEFAULT_DISCOVERY_MAX_RECORD_TTL: Duration = Duration::from_secs(24 * 60 * 60);
73/// Default expiry clock-skew tolerance for imported records.
74pub const DEFAULT_DISCOVERY_CLOCK_SKEW_TOLERANCE: Duration = Duration::from_secs(5 * 60);
75/// Default base backoff for discovery dials.
76pub const DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE: Duration = Duration::from_secs(60);
77/// Default maximum backoff for discovery dials.
78pub const DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX: Duration = Duration::from_secs(60 * 60);
79/// Default concurrent native discovery dial cap.
80pub const DEFAULT_MAX_CONCURRENT_DISCOVERY_DIALS: usize = 4;
81/// Default connection slots kept out of discovery dialing.
82pub const DEFAULT_DISCOVERY_CONNECTION_HEADROOM: usize = 4;
83/// Default Zakura connection cap used by standalone discovery-state tests.
84pub const DEFAULT_DISCOVERY_ZAKURA_MAX_CONNECTIONS: usize = 32;
85/// Default lifetime for first-party live service summaries.
86pub const DEFAULT_LIVE_SERVICE_SUMMARY_TTL: Duration = Duration::from_secs(30);
87
88/// Native peer discovery service id.
89pub const SERVICE_ID_DISCOVERY: &str = "zakura.discovery.v1";
90/// Native header-sync service id.
91///
92/// Generation `v2` is the request-id correlated header-sync service. Generation `v1`
93/// is retired, so a peer advertising only `v1` is not a header-sync candidate.
94pub const SERVICE_ID_HEADER_SYNC: &str = "zakura.header_sync.v2";
95/// Retired header-sync service id, kept only to keep a pre-upgrade peer's summaries
96/// wire-valid. Never advertised, never selected on by itself.
97pub const SERVICE_ID_HEADER_SYNC_RETIRED: &str = "zakura.header_sync.v1";
98/// Native block-sync service id.
99pub const SERVICE_ID_BLOCK_SYNC: &str = "zakura.block_sync.v1";
100/// Native legacy gossip service id.
101pub const SERVICE_ID_LEGACY_GOSSIP: &str = "zakura.legacy_gossip.v1";
102/// Native legacy requests service id.
103pub const SERVICE_ID_LEGACY_REQUESTS: &str = "zakura.legacy_requests.v1";
104/// Native service discovery service id.
105pub const SERVICE_ID_SERVICE_DISCOVERY: &str = "zakura.service_discovery.v1";
106/// Retired header-sync summary tag, bound to [`SERVICE_ID_HEADER_SYNC_RETIRED`].
107///
108/// A pre-upgrade peer still sends this tag with the retired service id. Keeping the
109/// binding lets its `Services` messages validate, so retiring header-sync v6 does not
110/// tear down discovery with peers that have not upgraded. We never emit it, and the
111/// summary is advisory only when the peer independently advertises the current
112/// header-sync service.
113pub const SUMMARY_TAG_HEADER_SYNC_RETIRED: u16 = 1;
114/// Summary tag for [`DiscoveryServiceSummary`].
115pub const SUMMARY_TAG_DISCOVERY_V1: u16 = 2;
116/// Summary tag for [`BlockSyncServiceSummary`].
117pub const SUMMARY_TAG_BLOCK_SYNC_V1: u16 = 3;
118/// Summary tag for [`HeaderSyncServiceSummary`].
119///
120/// A distinct tag rather than a reuse of [`SUMMARY_TAG_HEADER_SYNC_RETIRED`]: peers
121/// that predate it fall through to the unknown-tag path and ignore the summary,
122/// instead of rejecting it as a service/tag mismatch.
123pub const SUMMARY_TAG_HEADER_SYNC: u16 = 4;
124
125const SIGNATURE_BYTES: usize = 64;
126const NODE_ID_BYTES: usize = 32;
127const SOCKET_ADDR_V4: u8 = 4;
128const SOCKET_ADDR_V6: u8 = 6;
129const ZAKURA_NODE_RECORD_SIG_DOMAIN: &[u8] = b"zakura-node-record-v1";
130const ZAKURA_NODE_RECORD_FORMAT_VERSION: u16 = ZAKURA_DISCOVERY_STREAM_VERSION;
131
132/// A bounded ASCII Zakura service identifier.
133#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub struct ZakuraServiceId(String);
135
136impl ZakuraServiceId {
137    /// Creates a bounded ASCII service id.
138    pub fn new(value: impl Into<String>) -> Result<Self, DiscoveryWireError> {
139        let value = value.into();
140        validate_service_id(value.as_bytes())?;
141        Ok(Self(value))
142    }
143
144    /// Returns the service id as a string slice.
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148
149    /// Returns the native peer discovery service id.
150    pub fn discovery() -> Self {
151        Self::new(SERVICE_ID_DISCOVERY)
152            .expect("built-in Zakura discovery service id is non-empty bounded ASCII")
153    }
154
155    /// Returns the native header-sync service id.
156    pub fn header_sync() -> Self {
157        Self::new(SERVICE_ID_HEADER_SYNC)
158            .expect("built-in Zakura header-sync service id is non-empty bounded ASCII")
159    }
160
161    /// Returns the retired header-sync service id.
162    ///
163    /// Only used to validate a pre-upgrade peer's summaries; never advertised.
164    pub fn header_sync_retired() -> Self {
165        Self::new(SERVICE_ID_HEADER_SYNC_RETIRED)
166            .expect("built-in retired Zakura header-sync service id is non-empty bounded ASCII")
167    }
168
169    /// Returns the native block-sync service id.
170    pub fn block_sync() -> Self {
171        Self::new(SERVICE_ID_BLOCK_SYNC)
172            .expect("built-in Zakura block-sync service id is non-empty bounded ASCII")
173    }
174
175    /// Returns the native legacy gossip service id.
176    pub fn legacy_gossip() -> Self {
177        Self::new(SERVICE_ID_LEGACY_GOSSIP)
178            .expect("built-in Zakura legacy gossip service id is non-empty bounded ASCII")
179    }
180
181    /// Returns the native legacy requests service id.
182    pub fn legacy_requests() -> Self {
183        Self::new(SERVICE_ID_LEGACY_REQUESTS)
184            .expect("built-in Zakura legacy requests service id is non-empty bounded ASCII")
185    }
186
187    /// Returns the native service discovery service id.
188    pub fn service_discovery() -> Self {
189        Self::new(SERVICE_ID_SERVICE_DISCOVERY)
190            .expect("built-in Zakura service discovery id is non-empty bounded ASCII")
191    }
192}
193
194impl fmt::Display for ZakuraServiceId {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter.write_str(self.as_str())
197    }
198}
199
200impl TryFrom<&str> for ZakuraServiceId {
201    type Error = DiscoveryWireError;
202
203    fn try_from(value: &str) -> Result<Self, Self::Error> {
204        Self::new(value)
205    }
206}
207
208impl TryFrom<String> for ZakuraServiceId {
209    type Error = DiscoveryWireError;
210
211    fn try_from(value: String) -> Result<Self, Self::Error> {
212        Self::new(value)
213    }
214}
215
216/// The signed fields in a Zakura node record.
217#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct ZakuraNodeRecordBody {
219    /// The authoring iroh node id.
220    pub node_id: NodeId,
221    /// Direct dial addresses advertised by the author.
222    pub direct_addrs: Vec<SocketAddr>,
223    /// Native services advertised by the author.
224    pub services: Vec<ZakuraServiceId>,
225    /// Lowest supported Zakura protocol version.
226    pub zakura_protocol_min: u16,
227    /// Highest supported Zakura protocol version.
228    pub zakura_protocol_max: u16,
229    /// Zakura network id.
230    pub network_id: ZakuraNetworkId,
231    /// Genesis hash / chain id.
232    pub chain_id: [u8; 32],
233    /// Monotonic author sequence number.
234    pub sequence: u64,
235    /// Unix timestamp when this record expires.
236    pub expires_at_unix_secs: u64,
237}
238
239impl ZakuraNodeRecordBody {
240    /// Encodes this body into canonical bytes for signing and verification.
241    pub fn encode_for_signature(&self) -> Result<Vec<u8>, DiscoveryWireError> {
242        validate_record_body_bounds(self)?;
243
244        let mut bytes = Vec::new();
245        bytes.write_all(ZAKURA_NODE_RECORD_SIG_DOMAIN)?;
246        bytes.write_u16::<LittleEndian>(ZAKURA_NODE_RECORD_FORMAT_VERSION)?;
247        encode_record_body_to(self, &mut bytes)?;
248        if bytes.len() > MAX_NODE_RECORD_BODY_BYTES {
249            return Err(DiscoveryWireError::OversizedPayload {
250                actual: bytes.len(),
251                max: MAX_NODE_RECORD_BODY_BYTES,
252            });
253        }
254        Ok(bytes)
255    }
256}
257
258/// A self-signed Zakura node record.
259#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct ZakuraNodeRecord {
261    /// Signed record body.
262    pub body: ZakuraNodeRecordBody,
263    /// Ed25519 signature over [`ZakuraNodeRecordBody::encode_for_signature`].
264    pub signature: Signature,
265}
266
267impl ZakuraNodeRecord {
268    /// Signs `body` with the iroh secret key matching `body.node_id`.
269    pub fn sign(
270        body: ZakuraNodeRecordBody,
271        secret_key: &SecretKey,
272    ) -> Result<Self, DiscoveryWireError> {
273        if body.node_id != secret_key.public() {
274            return Err(DiscoveryWireError::SigningKeyMismatch);
275        }
276        let signing_key = SigningKey::from(secret_key.to_bytes());
277        let signature = signing_key.sign(&body.encode_for_signature()?);
278        Ok(Self { body, signature })
279    }
280
281    /// Verifies this record signature and advisory import context.
282    pub fn verify(
283        &self,
284        context: &DiscoveryRecordValidationContext,
285    ) -> Result<(), DiscoveryRecordError> {
286        validate_record_body_for_import(&self.body, context)?;
287
288        let verification_key = VerificationKey::try_from(&self.body.node_id.as_bytes()[..])
289            .map_err(|_| DiscoveryRecordError::MalformedNodeId)?;
290        let body_bytes = self
291            .body
292            .encode_for_signature()
293            .map_err(DiscoveryRecordError::Wire)?;
294        verification_key
295            .verify(&self.signature, &body_bytes)
296            .map_err(|_| DiscoveryRecordError::InvalidSignature)
297    }
298}
299
300/// Context used when validating a node record for import.
301#[derive(Clone, Debug, Eq, PartialEq)]
302pub struct DiscoveryRecordValidationContext {
303    /// Expected local network id.
304    pub expected_network_id: ZakuraNetworkId,
305    /// Expected local chain id.
306    pub expected_chain_id: [u8; 32],
307    /// Current Unix timestamp.
308    pub current_unix_secs: u64,
309    /// Lowest locally supported Zakura protocol version.
310    pub supported_protocol_min: u16,
311    /// Highest locally supported Zakura protocol version.
312    pub supported_protocol_max: u16,
313    /// Maximum accepted record TTL beyond `current_unix_secs`.
314    pub max_record_ttl: Duration,
315    /// Accepted wall-clock skew on expiry edges.
316    pub clock_skew_tolerance: Duration,
317}
318
319/// First-party live service summary request.
320#[derive(Clone, Debug, Eq, PartialEq)]
321pub struct GetServices {
322    /// Requested service ids, or empty for every locally available summary.
323    pub wanted_services: Vec<ZakuraServiceId>,
324}
325
326/// First-party live service summary response.
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct Services {
329    /// Node id of the responder that authored these live summaries.
330    pub node_id: NodeId,
331    /// Unix timestamp after which these live summaries are stale.
332    pub expires_at_unix_secs: u64,
333    /// Length-delimited live summaries for this responder only.
334    pub summaries: Vec<ServiceSummaryEnvelope>,
335}
336
337/// Length-delimited live service summary envelope.
338#[derive(Clone, Debug, Eq, PartialEq)]
339pub struct ServiceSummaryEnvelope {
340    /// Service this summary describes.
341    pub service_id: ZakuraServiceId,
342    /// Versioned summary tag for the payload.
343    pub summary_tag: u16,
344    /// Bounded encoded summary payload.
345    pub summary_bytes: Vec<u8>,
346}
347
348impl ServiceSummaryEnvelope {
349    /// Build a header-sync summary envelope.
350    pub fn header_sync(summary: &HeaderSyncServiceSummary) -> Result<Self, DiscoveryWireError> {
351        let mut summary_bytes = Vec::new();
352        encode_header_sync_summary(summary, &mut summary_bytes)?;
353        Ok(Self {
354            service_id: ZakuraServiceId::header_sync(),
355            summary_tag: SUMMARY_TAG_HEADER_SYNC,
356            summary_bytes,
357        })
358    }
359
360    /// Build a discovery summary envelope.
361    pub fn discovery(summary: &DiscoveryServiceSummary) -> Result<Self, DiscoveryWireError> {
362        let mut summary_bytes = Vec::new();
363        encode_discovery_summary(summary, &mut summary_bytes)?;
364        Ok(Self {
365            service_id: ZakuraServiceId::discovery(),
366            summary_tag: SUMMARY_TAG_DISCOVERY_V1,
367            summary_bytes,
368        })
369    }
370
371    /// Build a block-sync summary envelope.
372    pub fn block_sync(summary: &BlockSyncServiceSummary) -> Result<Self, DiscoveryWireError> {
373        let mut summary_bytes = Vec::new();
374        encode_block_sync_summary(summary, &mut summary_bytes)?;
375        Ok(Self {
376            service_id: ZakuraServiceId::block_sync(),
377            summary_tag: SUMMARY_TAG_BLOCK_SYNC_V1,
378            summary_bytes,
379        })
380    }
381
382    /// Decode this envelope as a header-sync summary when its tag/service pair matches.
383    pub fn decode_header_sync(
384        &self,
385    ) -> Result<Option<HeaderSyncServiceSummary>, DiscoveryWireError> {
386        let is_current_header_summary = self.summary_tag == SUMMARY_TAG_HEADER_SYNC
387            && self.service_id == ZakuraServiceId::header_sync();
388        let is_retired_header_summary = self.summary_tag == SUMMARY_TAG_HEADER_SYNC_RETIRED
389            && self.service_id == ZakuraServiceId::header_sync_retired();
390
391        if !is_current_header_summary && !is_retired_header_summary {
392            return Ok(None);
393        }
394
395        decode_header_sync_summary(&self.summary_bytes).map(Some)
396    }
397
398    /// Decode this envelope as a discovery summary when its tag matches.
399    pub fn decode_discovery(&self) -> Result<Option<DiscoveryServiceSummary>, DiscoveryWireError> {
400        if self.summary_tag == SUMMARY_TAG_DISCOVERY_V1 {
401            decode_discovery_summary(&self.summary_bytes).map(Some)
402        } else {
403            Ok(None)
404        }
405    }
406
407    /// Decode this envelope as a block-sync summary when its tag matches.
408    pub fn decode_block_sync(&self) -> Result<Option<BlockSyncServiceSummary>, DiscoveryWireError> {
409        if self.summary_tag == SUMMARY_TAG_BLOCK_SYNC_V1 {
410            decode_block_sync_summary(&self.summary_bytes).map(Some)
411        } else {
412            Ok(None)
413        }
414    }
415}
416
417/// Header-sync live summary used only as a dial/admission hint.
418#[derive(Copy, Clone, Debug, Eq, PartialEq)]
419pub struct HeaderSyncServiceSummary {
420    /// Best known header height.
421    pub best_height: block::Height,
422    /// Best known header hash.
423    pub best_hash: block::Hash,
424    /// Finalized height, when cheaply available.
425    pub finalized_height: Option<block::Height>,
426    /// Whether this node is currently serving headers.
427    pub serving_headers: bool,
428    /// Free inbound header-sync slots.
429    pub inbound_slots_free: u16,
430    /// Maximum inbound header-sync slots.
431    pub inbound_slots_max: u16,
432    /// Free outbound header-sync slots.
433    pub outbound_slots_free: u16,
434    /// Maximum outbound header-sync slots.
435    pub outbound_slots_max: u16,
436}
437
438impl HeaderSyncServiceSummary {
439    /// Build a summary from the header-sync reactor's cheap local snapshots.
440    pub fn from_snapshot(
441        best_height: block::Height,
442        best_hash: block::Hash,
443        finalized_height: Option<block::Height>,
444        serving_headers: bool,
445        peers: ServicePeerSnapshot,
446    ) -> Self {
447        Self {
448            best_height,
449            best_hash,
450            finalized_height,
451            serving_headers,
452            inbound_slots_free: saturating_u16(peers.inbound_slots_free),
453            inbound_slots_max: saturating_u16(
454                peers.inbound_peers.saturating_add(peers.inbound_slots_free),
455            ),
456            outbound_slots_free: saturating_u16(peers.outbound_slots_free),
457            outbound_slots_max: saturating_u16(
458                peers
459                    .outbound_peers
460                    .saturating_add(peers.outbound_slots_free),
461            ),
462        }
463    }
464}
465
466/// Discovery live summary used only as a dial/admission hint.
467#[derive(Copy, Clone, Debug, Eq, PartialEq)]
468pub struct DiscoveryServiceSummary {
469    /// Free inbound discovery exchange slots.
470    pub peer_exchange_slots_free: u16,
471    /// Maximum records this node will return in one peer response.
472    pub max_records_per_response: u16,
473    /// Whether this node expects to disconnect after one exchange.
474    pub expected_disconnect_after_exchange: bool,
475}
476
477impl DiscoveryServiceSummary {
478    /// Build a summary from the discovery runtime's cheap local snapshots.
479    pub fn from_snapshot(
480        peers: ServicePeerSnapshot,
481        max_records_per_response: usize,
482        expected_disconnect_after_exchange: bool,
483    ) -> Self {
484        Self {
485            peer_exchange_slots_free: saturating_u16(peers.inbound_slots_free),
486            max_records_per_response: saturating_u16(
487                max_records_per_response.min(MAX_DISCOVERY_RECORDS_PER_RESPONSE),
488            ),
489            expected_disconnect_after_exchange,
490        }
491    }
492}
493
494/// Block-sync live summary used only as a dial/admission hint.
495#[derive(Copy, Clone, Debug, Eq, PartialEq)]
496pub struct BlockSyncServiceSummary {
497    /// Earliest committed body height this node can serve.
498    pub servable_low: block::Height,
499    /// Highest committed body height this node can serve.
500    pub servable_high: block::Height,
501    /// Hash of [`servable_high`](Self::servable_high).
502    pub tip_hash: block::Hash,
503    /// Free serving slots for inbound `GetBlocks` requests.
504    pub free_slots: u16,
505    /// Maximum blocks this node will return in one block-sync response.
506    pub max_blocks_per_response: u32,
507    /// Maximum encoded bytes this node will return in one block-sync response.
508    pub max_response_bytes: u32,
509}
510
511impl BlockSyncServiceSummary {
512    /// Build a summary from the block-sync reactor's cheap local status and peer snapshots.
513    pub fn from_status_and_snapshot(status: BlockSyncStatus, peers: ServicePeerSnapshot) -> Self {
514        Self {
515            servable_low: status.servable_low,
516            servable_high: status.servable_high,
517            tip_hash: status.tip_hash,
518            free_slots: saturating_u16(peers.inbound_slots_free),
519            max_blocks_per_response: status.max_blocks_per_response,
520            max_response_bytes: status.max_response_bytes,
521        }
522    }
523
524    /// Returns true if this summary can serve every height in `gap`.
525    pub fn covers_gap(&self, gap: &[block::Height]) -> bool {
526        self.gap_coverage(gap) == BlockSyncGapCoverage::Whole
527    }
528
529    /// Returns how much of `gap` this summary can serve.
530    fn gap_coverage(&self, gap: &[block::Height]) -> BlockSyncGapCoverage {
531        if self.free_slots == 0 {
532            return BlockSyncGapCoverage::None;
533        }
534
535        let Some(first) = gap.iter().min().copied() else {
536            return BlockSyncGapCoverage::None;
537        };
538        let Some(last) = gap.iter().max().copied() else {
539            return BlockSyncGapCoverage::None;
540        };
541        if self.servable_low <= first && last <= self.servable_high {
542            BlockSyncGapCoverage::Whole
543        } else if self.servable_low <= first && first <= self.servable_high {
544            BlockSyncGapCoverage::LowPrefix
545        } else {
546            BlockSyncGapCoverage::None
547        }
548    }
549}
550
551#[derive(Copy, Clone, Debug, Eq, PartialEq)]
552enum BlockSyncGapCoverage {
553    None,
554    LowPrefix,
555    Whole,
556}
557
558/// Native discovery protocol messages.
559#[derive(Clone, Debug, Eq, PartialEq)]
560pub enum DiscoveryMessage {
561    /// Sender's signed self-record.
562    Hello {
563        /// Signed node record.
564        record: ZakuraNodeRecord,
565    },
566    /// Request a bounded peer sample.
567    GetPeers {
568        /// Maximum requested records.
569        limit: u16,
570        /// Optional service filter.
571        wanted_services: Vec<ZakuraServiceId>,
572        /// Node ids the responder should not return.
573        exclude_node_ids: Vec<NodeId>,
574    },
575    /// Bounded signed peer records.
576    Peers {
577        /// Signed node records.
578        records: Vec<ZakuraNodeRecord>,
579    },
580    /// Request first-party live service summaries.
581    GetServices(GetServices),
582    /// First-party live service summaries.
583    Services(Services),
584}
585
586impl DiscoveryMessage {
587    /// Encodes this message with deterministic length-prefixed fields.
588    pub fn encode(&self) -> Result<Vec<u8>, DiscoveryWireError> {
589        let mut bytes = Vec::new();
590        match self {
591            Self::Hello { record } => {
592                bytes.write_u8(MSG_DISCOVERY_HELLO)?;
593                encode_record(record, &mut bytes)?;
594            }
595            Self::GetPeers {
596                limit,
597                wanted_services,
598                exclude_node_ids,
599            } => {
600                validate_query_fields(*limit, wanted_services, exclude_node_ids)?;
601                bytes.write_u8(MSG_DISCOVERY_GET_PEERS)?;
602                encode_query_fields(*limit, wanted_services, exclude_node_ids, &mut bytes)?;
603            }
604            Self::Peers { records } => {
605                encode_records_message(
606                    MSG_DISCOVERY_PEERS,
607                    records,
608                    MAX_DISCOVERY_RECORDS_PER_RESPONSE,
609                    &mut bytes,
610                )?;
611            }
612            Self::GetServices(query) => {
613                validate_get_services(query)?;
614                bytes.write_u8(MSG_DISCOVERY_GET_SERVICES)?;
615                encode_service_ids_capped(
616                    &query.wanted_services,
617                    MAX_GET_SERVICES_WANTED,
618                    &mut bytes,
619                )?;
620            }
621            Self::Services(services) => {
622                validate_services(services)?;
623                bytes.write_u8(MSG_DISCOVERY_SERVICES)?;
624                encode_services_message(services, &mut bytes)?;
625            }
626        }
627
628        if bytes.len() > MAX_DISCOVERY_MESSAGE_BYTES {
629            return Err(DiscoveryWireError::OversizedPayload {
630                actual: bytes.len(),
631                max: MAX_DISCOVERY_MESSAGE_BYTES,
632            });
633        }
634
635        Ok(bytes)
636    }
637
638    /// Decodes a discovery message, rejecting oversize and trailing bytes.
639    pub fn decode(bytes: &[u8]) -> Result<Self, DiscoveryWireError> {
640        if bytes.len() > MAX_DISCOVERY_MESSAGE_BYTES {
641            return Err(DiscoveryWireError::OversizedPayload {
642                actual: bytes.len(),
643                max: MAX_DISCOVERY_MESSAGE_BYTES,
644            });
645        }
646
647        let mut reader = Cursor::new(bytes);
648        let message = match reader.read_u8()? {
649            MSG_DISCOVERY_HELLO => Self::Hello {
650                record: decode_record(&mut reader)?,
651            },
652            MSG_DISCOVERY_GET_PEERS => {
653                let (limit, wanted_services, exclude_node_ids) = decode_query_fields(&mut reader)?;
654                Self::GetPeers {
655                    limit,
656                    wanted_services,
657                    exclude_node_ids,
658                }
659            }
660            MSG_DISCOVERY_PEERS => Self::Peers {
661                records: decode_record_list(&mut reader, MAX_DISCOVERY_RECORDS_PER_RESPONSE)?,
662            },
663            MSG_DISCOVERY_GET_SERVICES => Self::GetServices(decode_get_services(&mut reader)?),
664            MSG_DISCOVERY_SERVICES => Self::Services(decode_services_message(&mut reader)?),
665            value => return Err(DiscoveryWireError::InvalidMessageType(value)),
666        };
667        reject_trailing(bytes, &reader)?;
668        Ok(message)
669    }
670}
671
672/// A malformed discovery wire message.
673#[derive(Error, Debug)]
674pub enum DiscoveryWireError {
675    /// A payload exceeded its hard cap.
676    #[error("Zakura discovery payload length {actual} exceeds hard cap {max}")]
677    OversizedPayload {
678        /// Actual payload length.
679        actual: usize,
680        /// Maximum allowed payload length.
681        max: usize,
682    },
683
684    /// An I/O error while encoding or decoding.
685    #[error("Zakura discovery wire I/O error: {0}")]
686    Io(#[from] io::Error),
687
688    /// A length-delimited field claimed more bytes than remain in the bounded buffer.
689    #[error("Zakura discovery field declared {declared} bytes with only {remaining} remaining")]
690    TruncatedPayload {
691        /// Declared field length.
692        declared: usize,
693        /// Remaining bytes in the bounded parent buffer.
694        remaining: usize,
695    },
696
697    /// The discovery message type is unknown.
698    #[error("invalid Zakura discovery message type {0}")]
699    InvalidMessageType(u8),
700
701    /// The network id is unknown.
702    #[error("invalid Zakura discovery network id {0}")]
703    InvalidNetworkId(u32),
704
705    /// A record address family is unknown.
706    #[error("invalid Zakura discovery socket address family {0}")]
707    InvalidAddressFamily(u8),
708
709    /// A node id did not decode into a valid iroh public key.
710    #[error("invalid Zakura discovery node id")]
711    InvalidNodeId,
712
713    /// A first-party live field described a different node than the authenticated peer.
714    #[error("Zakura discovery {field} does not match authenticated peer node id")]
715    MismatchedNodeId {
716        /// Field name.
717        field: &'static str,
718    },
719
720    /// A decoded payload had trailing bytes.
721    #[error("trailing bytes in Zakura discovery payload")]
722    TrailingBytes,
723
724    /// A required field was empty.
725    #[error("empty Zakura discovery {0}")]
726    Empty(&'static str),
727
728    /// A service id was not ASCII.
729    #[error("Zakura discovery service id is not ASCII")]
730    NonAsciiServiceId,
731
732    /// A boolean field was not encoded as 0 or 1.
733    #[error("invalid Zakura discovery boolean {field} value {value}")]
734    InvalidBoolean {
735        /// Field name.
736        field: &'static str,
737        /// Encoded value.
738        value: u8,
739    },
740
741    /// A known summary tag was attached to the wrong service id.
742    #[error("Zakura discovery summary tag {summary_tag} does not match service id {service_id}")]
743    MismatchedSummaryService {
744        /// Versioned summary payload tag.
745        summary_tag: u16,
746        /// Service id from the enclosing summary envelope.
747        service_id: ZakuraServiceId,
748    },
749
750    /// A block height exceeded Zebra's accepted height range.
751    #[error("invalid Zakura discovery block height {0}")]
752    InvalidHeight(u32),
753
754    /// A numeric conversion failed while handling bounded data.
755    #[error("numeric overflow while encoding Zakura discovery {0}")]
756    NumericOverflow(&'static str),
757
758    /// The signing key does not match the record author.
759    #[error("Zakura discovery signing key does not match record node id")]
760    SigningKeyMismatch,
761
762    /// The record has an invalid protocol range.
763    #[error("invalid Zakura discovery protocol range")]
764    InvalidProtocolRange,
765}
766
767/// A signed record failed validation for import.
768#[derive(Error, Debug)]
769pub enum DiscoveryRecordError {
770    /// The record wire shape or local bounds are invalid.
771    #[error(transparent)]
772    Wire(#[from] DiscoveryWireError),
773
774    /// The record's public key bytes are malformed.
775    #[error("malformed Zakura discovery node id")]
776    MalformedNodeId,
777
778    /// The record signature does not verify.
779    #[error("invalid Zakura discovery record signature")]
780    InvalidSignature,
781
782    /// The record is expired beyond clock skew tolerance.
783    #[error("expired Zakura discovery record")]
784    Expired,
785
786    /// The record expires too far in the future.
787    #[error("far-future Zakura discovery record expiry")]
788    FarFutureExpiry,
789
790    /// The record is for another network.
791    #[error("wrong Zakura discovery network")]
792    WrongNetwork,
793
794    /// The record is for another chain.
795    #[error("wrong Zakura discovery chain")]
796    WrongChain,
797
798    /// The record's protocol range is invalid or incompatible.
799    #[error("incompatible Zakura discovery protocol range")]
800    IncompatibleProtocol,
801}
802
803/// Default maximum discovery book entries learned from peers.
804pub const DEFAULT_MAX_DISCOVERY_BOOK_RECORDS: usize = 10_000;
805
806const HIGH_DIAL_FAILURE_COUNT: u32 = 3;
807
808/// Local storage limits for the native Zakura discovery book.
809#[derive(Copy, Clone, Debug, Eq, PartialEq)]
810pub struct ZakuraDiscoveryBookLimits {
811    /// Maximum non-static records learned from peers.
812    pub max_records: usize,
813    /// Maximum records imported from one peer response.
814    pub max_imported_records_per_response: usize,
815    /// Maximum direct addresses in an imported record.
816    pub max_direct_addrs_per_record: usize,
817    /// Maximum services in an imported record.
818    pub max_services_per_record: usize,
819    /// Maximum encoded record size accepted by the book.
820    pub max_encoded_record_bytes: usize,
821}
822
823impl Default for ZakuraDiscoveryBookLimits {
824    fn default() -> Self {
825        Self {
826            max_records: DEFAULT_MAX_DISCOVERY_BOOK_RECORDS,
827            max_imported_records_per_response: MAX_DISCOVERY_RECORDS_PER_RESPONSE,
828            max_direct_addrs_per_record: MAX_DIRECT_ADDRS_PER_RECORD,
829            max_services_per_record: MAX_SERVICES_PER_RECORD,
830            max_encoded_record_bytes: MAX_DISCOVERY_MESSAGE_BYTES,
831        }
832    }
833}
834
835/// A stored Zakura discovery record with local dial metadata.
836#[derive(Clone, Debug, Eq, PartialEq)]
837pub struct ZakuraDiscoveryEntry {
838    record: ZakuraNodeRecord,
839    source: Option<NodeId>,
840    is_static: bool,
841    last_seen: u64,
842    last_dial_attempt: Option<u64>,
843    last_success: Option<u64>,
844    last_short_lived_exchange: Option<u64>,
845    failure_count: u32,
846}
847
848impl ZakuraDiscoveryEntry {
849    /// Returns the latest valid signed record for this node.
850    pub fn record(&self) -> &ZakuraNodeRecord {
851        &self.record
852    }
853
854    /// Returns the peer that supplied this record, if any.
855    pub fn source(&self) -> Option<NodeId> {
856        self.source
857    }
858
859    /// Returns true when this entry came from static/bootstrap configuration.
860    pub fn is_static(&self) -> bool {
861        self.is_static
862    }
863
864    /// Returns when this entry was last seen as a Unix timestamp.
865    pub fn last_seen(&self) -> u64 {
866        self.last_seen
867    }
868
869    /// Returns the last dial attempt as a Unix timestamp.
870    pub fn last_dial_attempt(&self) -> Option<u64> {
871        self.last_dial_attempt
872    }
873
874    /// Returns the last successful dial as a Unix timestamp.
875    pub fn last_success(&self) -> Option<u64> {
876        self.last_success
877    }
878
879    /// Returns the last successful short-lived discovery exchange as a Unix timestamp.
880    pub fn last_short_lived_exchange(&self) -> Option<u64> {
881        self.last_short_lived_exchange
882    }
883
884    /// Returns the consecutive dial failure count.
885    pub fn failure_count(&self) -> u32 {
886        self.failure_count
887    }
888}
889
890/// A persistence-layer entry for the discovery book.
891///
892/// This type intentionally contains no filesystem behavior. Cache readers and
893/// writers can serialize these values, then re-import them through the book so
894/// every loaded record is re-validated before use.
895#[derive(Clone, Debug, Eq, PartialEq)]
896pub struct ZakuraDiscoveryPersistedEntry {
897    /// The signed node record.
898    pub record: ZakuraNodeRecord,
899    /// The peer that supplied the record, if any.
900    pub source: Option<NodeId>,
901    /// Whether this entry came from static/bootstrap configuration.
902    pub is_static: bool,
903    /// Last seen Unix timestamp.
904    pub last_seen: u64,
905    /// Last dial attempt Unix timestamp.
906    pub last_dial_attempt: Option<u64>,
907    /// Last successful dial Unix timestamp.
908    pub last_success: Option<u64>,
909    /// Consecutive dial failure count.
910    pub failure_count: u32,
911}
912
913/// A locally dialable discovery candidate.
914///
915/// Candidates may come from first-party confirmed signed discovery records or from trusted static
916/// bootstrap configuration. Only signed records are eligible for peer samples; unsigned static
917/// candidates are local dial hints.
918#[derive(Clone, Debug, Eq, PartialEq)]
919pub struct ZakuraDiscoveryDialCandidate {
920    /// Candidate iroh node id.
921    pub node_id: NodeId,
922    /// Direct addresses to pass to iroh for dialing.
923    pub direct_addrs: Vec<SocketAddr>,
924    /// Whether this candidate came from static/operator configuration.
925    pub is_static: bool,
926}
927
928impl ZakuraDiscoveryDialCandidate {
929    /// Converts this candidate into an iroh dial address.
930    pub fn node_addr(&self) -> NodeAddr {
931        NodeAddr::new(self.node_id).with_direct_addresses(self.direct_addrs.clone())
932    }
933}
934
935/// Service-aware peer candidates derived from signed discovery state.
936#[derive(Clone, Debug, Default, Eq, PartialEq)]
937pub struct ZakuraServiceCandidates {
938    /// Connected peers whose latest valid `Hello` advertised the requested service.
939    pub connected: Vec<NodeId>,
940    /// Dialable discovered peers whose signed record advertised the requested service.
941    pub discovered: Vec<ZakuraDiscoveryDialCandidate>,
942    /// Whether discovered candidates came from explicit fallback to general peers.
943    pub used_fallback: bool,
944}
945
946/// Header-sync candidate selection hints owned by the header-sync reactor.
947#[derive(Clone, Debug, Eq, PartialEq)]
948pub struct ZakuraHeaderSyncCandidateState {
949    /// Lowest header height that would make a new peer useful.
950    pub target_height: block::Height,
951    /// Peers already admitted by header sync; advisory "full" summaries do not remove them.
952    pub admitted_node_ids: Vec<NodeId>,
953    /// Peers in local, non-punitive advisory backoff after failing to confirm usefulness.
954    pub backed_off_node_ids: Vec<NodeId>,
955}
956
957impl Default for ZakuraHeaderSyncCandidateState {
958    fn default() -> Self {
959        Self {
960            target_height: block::Height::MIN,
961            admitted_node_ids: Vec::new(),
962            backed_off_node_ids: Vec::new(),
963        }
964    }
965}
966
967/// Block-sync candidate selection hints owned by the block-sync reactor.
968#[derive(Clone, Debug, Default, Eq, PartialEq)]
969pub struct ZakuraBlockSyncCandidateState {
970    /// Header-known body heights currently missing from local state.
971    pub missing_block_bodies: Vec<block::Height>,
972    /// Peers already admitted by block sync; advisory "full" summaries do not remove them.
973    pub admitted_node_ids: Vec<NodeId>,
974}
975
976/// The result of importing one signed discovery record.
977#[derive(Copy, Clone, Debug, Eq, PartialEq)]
978pub enum ImportOutcome {
979    /// A new node entry was inserted.
980    Added,
981    /// A newer signed record replaced the previous signed record.
982    Updated,
983    /// An equal-sequence record refreshed local metadata only.
984    MetadataUpdated,
985    /// An older record was valid but ignored.
986    IgnoredOlder,
987}
988
989/// Summary of importing a bounded batch of discovery records.
990#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
991pub struct ImportBatchOutcome {
992    /// Number of records considered before the per-response cap stopped import.
993    pub attempted: usize,
994    /// Number of newly added records.
995    pub added: usize,
996    /// Number of updated records.
997    pub updated: usize,
998    /// Number of metadata-only updates.
999    pub metadata_updated: usize,
1000    /// Number of older records ignored.
1001    pub ignored_older: usize,
1002    /// Number of records rejected by validation.
1003    pub rejected: usize,
1004    /// Number of records skipped because the response exceeded the import cap.
1005    pub dropped_for_limit: usize,
1006}
1007
1008impl ImportBatchOutcome {
1009    fn record_success(&mut self, outcome: ImportOutcome) {
1010        match outcome {
1011            ImportOutcome::Added => self.added += 1,
1012            ImportOutcome::Updated => self.updated += 1,
1013            ImportOutcome::MetadataUpdated => self.metadata_updated += 1,
1014            ImportOutcome::IgnoredOlder => self.ignored_older += 1,
1015        }
1016    }
1017}
1018
1019/// A discovery book import or storage error.
1020#[derive(Error, Debug)]
1021pub enum DiscoveryBookError {
1022    /// The signed record failed validation.
1023    #[error(transparent)]
1024    Record(#[from] DiscoveryRecordError),
1025
1026    /// The record describes the local node and self-record import was not requested.
1027    #[error("Zakura discovery book rejected local self-record")]
1028    SelfRecord,
1029
1030    /// A connected peer's self-record did not match its authenticated identity.
1031    #[error("Zakura discovery connected self-record author does not match authenticated peer")]
1032    MismatchedConnectedPeerRecord,
1033
1034    /// The record has no direct address usable for a future dial attempt.
1035    #[error("Zakura discovery record has no usable direct address")]
1036    NoUsableDirectAddress,
1037
1038    /// The record contains a direct address that must not be used for discovery.
1039    #[error("Zakura discovery record contains non-dialable direct address {addr}")]
1040    NonDialableDirectAddress {
1041        /// The rejected direct address.
1042        addr: SocketAddr,
1043    },
1044
1045    /// A record field exceeded a book-specific storage limit.
1046    #[error("Zakura discovery record {field} count {actual} exceeds book limit {max}")]
1047    Limit {
1048        /// The limited field.
1049        field: &'static str,
1050        /// Actual count or size.
1051        actual: usize,
1052        /// Maximum accepted count or size.
1053        max: usize,
1054    },
1055}
1056
1057/// In-memory storage for signed Zakura discovery records.
1058#[derive(Clone, Debug)]
1059pub struct ZakuraDiscoveryBook {
1060    entries: HashMap<NodeId, ZakuraDiscoveryEntry>,
1061    static_candidates: HashMap<NodeId, ZakuraStaticDiscoveryCandidate>,
1062    limits: ZakuraDiscoveryBookLimits,
1063    local_node_id: Option<NodeId>,
1064}
1065
1066/// Passive runtime limits and timing policy for native Zakura discovery.
1067#[derive(Clone, Debug, Eq, PartialEq)]
1068pub struct ZakuraDiscoveryConfig {
1069    /// Lifetime of locally authored self-records.
1070    pub record_ttl: Duration,
1071    /// Minimum interval for future active refresh logic.
1072    pub refresh_interval: Duration,
1073    /// Default peer sample size for future exchange logic.
1074    pub peer_sample_limit: usize,
1075    /// Discovery book storage limits.
1076    pub book_limits: ZakuraDiscoveryBookLimits,
1077    /// Maximum node ids accepted in an exclusion list.
1078    pub max_excluded_node_ids: usize,
1079    /// Base backoff for future discovery dials.
1080    pub dial_backoff_base: Duration,
1081    /// Maximum backoff for future discovery dials.
1082    pub dial_backoff_max: Duration,
1083    /// Maximum concurrent future discovery dials.
1084    pub max_concurrent_discovery_dials: usize,
1085    /// Slots reserved below the Zakura connection cap for maintained peers.
1086    pub discovery_connection_headroom: usize,
1087    /// Total Zakura connection cap used to derive the discovery soft cap.
1088    pub max_zakura_connections: usize,
1089    /// Discovery peer caps and queue limits owned by this runtime.
1090    pub peer_limits: ServicePeerLimits,
1091    /// Maximum accepted future TTL on imported records.
1092    pub max_record_ttl: Duration,
1093    /// Accepted clock skew around expiry checks.
1094    pub clock_skew_tolerance: Duration,
1095}
1096
1097impl Default for ZakuraDiscoveryConfig {
1098    fn default() -> Self {
1099        Self {
1100            record_ttl: DEFAULT_DISCOVERY_RECORD_TTL,
1101            refresh_interval: DEFAULT_DISCOVERY_REFRESH_INTERVAL,
1102            peer_sample_limit: DEFAULT_DISCOVERY_PEER_SAMPLE_LIMIT,
1103            book_limits: ZakuraDiscoveryBookLimits::default(),
1104            max_excluded_node_ids: MAX_DISCOVERY_EXCLUDED_NODE_IDS,
1105            dial_backoff_base: DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
1106            dial_backoff_max: DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
1107            max_concurrent_discovery_dials: DEFAULT_MAX_CONCURRENT_DISCOVERY_DIALS,
1108            discovery_connection_headroom: DEFAULT_DISCOVERY_CONNECTION_HEADROOM,
1109            max_zakura_connections: DEFAULT_DISCOVERY_ZAKURA_MAX_CONNECTIONS,
1110            peer_limits: ServicePeerLimits::default(),
1111            max_record_ttl: DEFAULT_DISCOVERY_MAX_RECORD_TTL,
1112            clock_skew_tolerance: DEFAULT_DISCOVERY_CLOCK_SKEW_TOLERANCE,
1113        }
1114    }
1115}
1116
1117/// Construction inputs for the locally authored Zakura discovery self-record.
1118#[derive(Clone)]
1119pub struct ZakuraDiscoveryLocalConfig {
1120    /// Local iroh secret key used to sign self-records.
1121    pub secret_key: SecretKey,
1122    /// Configured direct listener addresses to advertise if routable.
1123    pub direct_addrs: Vec<SocketAddr>,
1124    /// Locally supported native Zakura services.
1125    pub services: Vec<ZakuraServiceId>,
1126    /// Lowest supported Zakura protocol version.
1127    pub zakura_protocol_min: u16,
1128    /// Highest supported Zakura protocol version.
1129    pub zakura_protocol_max: u16,
1130    /// Local Zakura network id.
1131    pub network_id: ZakuraNetworkId,
1132    /// Local genesis hash / chain id.
1133    pub chain_id: [u8; 32],
1134    /// Last locally authored self-record sequence loaded from durable storage.
1135    ///
1136    /// Until cache persistence wires this seed at startup, self-record sequences are derived from
1137    /// wall-clock nanoseconds. They strictly increase across a same-key restart only while the
1138    /// system clock is monotonic; a backward clock step can still produce a non-increasing sequence.
1139    pub last_authored_sequence: Option<u64>,
1140}
1141
1142impl fmt::Debug for ZakuraDiscoveryLocalConfig {
1143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1144        formatter
1145            .debug_struct("ZakuraDiscoveryLocalConfig")
1146            .field("node_id", &self.secret_key.public())
1147            .field("direct_addrs", &self.direct_addrs)
1148            .field("services", &self.services)
1149            .field("zakura_protocol_min", &self.zakura_protocol_min)
1150            .field("zakura_protocol_max", &self.zakura_protocol_max)
1151            .field("network_id", &self.network_id)
1152            .field("chain_id", &self.chain_id)
1153            .field("last_authored_sequence", &self.last_authored_sequence)
1154            .finish()
1155    }
1156}
1157
1158/// Cloneable passive runtime handle for native Zakura discovery state.
1159#[derive(Clone)]
1160pub struct ZakuraDiscoveryHandle {
1161    inner: Arc<Mutex<ZakuraDiscoveryInner>>,
1162    connected: watch::Receiver<Vec<ZakuraPeerId>>,
1163    self_record: watch::Receiver<Arc<ZakuraNodeRecord>>,
1164    self_record_tx: watch::Sender<Arc<ZakuraNodeRecord>>,
1165    peer_snapshot: watch::Receiver<ServicePeerSnapshot>,
1166    peer_snapshot_tx: watch::Sender<ServicePeerSnapshot>,
1167    import_validation: DiscoveryImportValidation,
1168}
1169
1170/// Immutable inputs needed to validate and bound a peer record batch without holding the
1171/// global discovery mutex.
1172///
1173/// These mirror the fields [`ZakuraDiscoveryInner::validation_context`] reads, all of which
1174/// are fixed at construction (local identity/network parameters and import limits never
1175/// change at runtime). Snapshotting them on the handle lets [`ZakuraDiscoveryHandle::import_peer_records`]
1176/// run CPU-heavy Ed25519 verification outside the lock.
1177#[derive(Clone, Debug)]
1178struct DiscoveryImportValidation {
1179    expected_network_id: ZakuraNetworkId,
1180    expected_chain_id: [u8; 32],
1181    supported_protocol_min: u16,
1182    supported_protocol_max: u16,
1183    max_record_ttl: Duration,
1184    clock_skew_tolerance: Duration,
1185    max_imported_records_per_response: usize,
1186}
1187
1188impl DiscoveryImportValidation {
1189    fn context(&self, now: u64) -> DiscoveryRecordValidationContext {
1190        DiscoveryRecordValidationContext {
1191            expected_network_id: self.expected_network_id,
1192            expected_chain_id: self.expected_chain_id,
1193            current_unix_secs: now,
1194            supported_protocol_min: self.supported_protocol_min,
1195            supported_protocol_max: self.supported_protocol_max,
1196            max_record_ttl: self.max_record_ttl,
1197            clock_skew_tolerance: self.clock_skew_tolerance,
1198        }
1199    }
1200}
1201
1202impl fmt::Debug for ZakuraDiscoveryHandle {
1203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1204        formatter
1205            .debug_struct("ZakuraDiscoveryHandle")
1206            .field(
1207                "self_record_node_id",
1208                &self.self_record.borrow().body.node_id,
1209            )
1210            .finish_non_exhaustive()
1211    }
1212}
1213
1214#[derive(Debug)]
1215struct ZakuraDiscoveryInner {
1216    book: ZakuraDiscoveryBook,
1217    active_services: HashMap<NodeId, ZakuraActiveServiceEntry>,
1218    admitted_peers: HashMap<ZakuraPeerId, ZakuraDiscoveryAdmittedPeer>,
1219    last_connected_node_ids: HashSet<NodeId>,
1220    local: ZakuraLocalDiscoveryState,
1221    config: ZakuraDiscoveryConfig,
1222}
1223
1224#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1225struct ZakuraDiscoveryAdmittedPeer {
1226    conn_id: ZakuraConnId,
1227    direction: ServicePeerDirection,
1228}
1229
1230#[derive(Clone, Debug, Eq, PartialEq)]
1231struct ZakuraActiveServiceEntry {
1232    record: Option<ZakuraNodeRecord>,
1233    services: Vec<ZakuraServiceId>,
1234    live_summaries: Vec<ZakuraCachedLiveServiceSummary>,
1235}
1236
1237impl ZakuraActiveServiceEntry {
1238    fn from_record(record: ZakuraNodeRecord, services: Vec<ZakuraServiceId>) -> Self {
1239        Self {
1240            record: Some(record),
1241            services,
1242            live_summaries: Vec::new(),
1243        }
1244    }
1245
1246    fn from_live_summaries(summaries: Vec<ZakuraCachedLiveServiceSummary>) -> Self {
1247        let mut services = Vec::new();
1248        for summary in &summaries {
1249            push_unique_service(&mut services, summary.service_id.clone());
1250        }
1251        Self {
1252            record: None,
1253            services,
1254            live_summaries: summaries,
1255        }
1256    }
1257
1258    fn update_record(&mut self, record: ZakuraNodeRecord, services: Vec<ZakuraServiceId>) {
1259        self.record = Some(record);
1260        self.services = services;
1261    }
1262
1263    fn update_live_summaries(&mut self, summaries: Vec<ZakuraCachedLiveServiceSummary>) {
1264        self.live_summaries.retain(|cached| {
1265            !summaries.iter().any(|summary| {
1266                summary.service_id == cached.service_id && summary.summary_tag == cached.summary_tag
1267            })
1268        });
1269        for summary in summaries {
1270            self.live_summaries.push(summary);
1271        }
1272        if self.record.is_none() {
1273            self.refresh_live_derived_services();
1274        }
1275    }
1276
1277    fn prune_expired_live_summaries(&mut self, now_unix_secs: u64) {
1278        self.live_summaries
1279            .retain(|summary| summary.expires_at_unix_secs > now_unix_secs);
1280        if self.record.is_none() {
1281            self.refresh_live_derived_services();
1282        }
1283    }
1284
1285    fn refresh_live_derived_services(&mut self) {
1286        let mut services = Vec::new();
1287        for summary in &self.live_summaries {
1288            push_unique_service(&mut services, summary.service_id.clone());
1289        }
1290        self.services = services;
1291    }
1292
1293    fn is_empty_live_only(&self) -> bool {
1294        self.record.is_none() && self.live_summaries.is_empty()
1295    }
1296
1297    fn live_summary_preference(&self, service: &ZakuraServiceId, now_unix_secs: u64) -> u32 {
1298        self.live_summaries
1299            .iter()
1300            .filter(|summary| {
1301                self.summary_advises_service(summary, service)
1302                    && summary.expires_at_unix_secs > now_unix_secs
1303            })
1304            .map(ZakuraCachedLiveServiceSummary::preference_score)
1305            .max()
1306            .unwrap_or(0)
1307    }
1308
1309    fn fresh_header_sync_summary(&self, now_unix_secs: u64) -> Option<HeaderSyncServiceSummary> {
1310        let header_sync_service = ZakuraServiceId::header_sync();
1311        self.live_summaries.iter().find_map(|summary| {
1312            if !self.summary_advises_service(summary, &header_sync_service)
1313                || summary.expires_at_unix_secs <= now_unix_secs
1314            {
1315                return None;
1316            }
1317            match summary.summary {
1318                ZakuraLiveServiceSummary::HeaderSync(header_sync) => Some(header_sync),
1319                ZakuraLiveServiceSummary::BlockSync(_)
1320                | ZakuraLiveServiceSummary::Discovery(_)
1321                | ZakuraLiveServiceSummary::Unknown => None,
1322            }
1323        })
1324    }
1325
1326    fn summary_advises_service(
1327        &self,
1328        summary: &ZakuraCachedLiveServiceSummary,
1329        service: &ZakuraServiceId,
1330    ) -> bool {
1331        if summary.service_id == *service {
1332            return true;
1333        }
1334
1335        *service == ZakuraServiceId::header_sync()
1336            && summary.service_id == ZakuraServiceId::header_sync_retired()
1337            && self.services.contains(service)
1338    }
1339
1340    fn fresh_block_sync_summary(&self, now_unix_secs: u64) -> Option<BlockSyncServiceSummary> {
1341        self.live_summaries.iter().find_map(|summary| {
1342            if summary.service_id != ZakuraServiceId::block_sync()
1343                || summary.expires_at_unix_secs <= now_unix_secs
1344            {
1345                return None;
1346            }
1347            match summary.summary {
1348                ZakuraLiveServiceSummary::BlockSync(block_sync) => Some(block_sync),
1349                ZakuraLiveServiceSummary::HeaderSync(_)
1350                | ZakuraLiveServiceSummary::Discovery(_)
1351                | ZakuraLiveServiceSummary::Unknown => None,
1352            }
1353        })
1354    }
1355}
1356
1357/// A decoded first-party live service summary cached while its peer remains connected.
1358#[derive(Clone, Debug, Eq, PartialEq)]
1359pub struct ZakuraCachedLiveServiceSummary {
1360    /// Service this cached summary describes.
1361    pub service_id: ZakuraServiceId,
1362    /// Versioned summary payload tag.
1363    pub summary_tag: u16,
1364    /// Decoded payload for known service summary tags.
1365    pub summary: ZakuraLiveServiceSummary,
1366    /// Local Unix timestamp when this summary was observed.
1367    pub observed_at_unix_secs: u64,
1368    /// Effective local expiry, clamped to the local live-summary TTL.
1369    pub expires_at_unix_secs: u64,
1370}
1371
1372impl ZakuraCachedLiveServiceSummary {
1373    fn from_envelope(
1374        envelope: &ServiceSummaryEnvelope,
1375        observed_at_unix_secs: u64,
1376        expires_at_unix_secs: u64,
1377    ) -> Result<Self, DiscoveryWireError> {
1378        let summary = if let Some(summary) = envelope.decode_header_sync()? {
1379            ZakuraLiveServiceSummary::HeaderSync(summary)
1380        } else if let Some(summary) = envelope.decode_discovery()? {
1381            ZakuraLiveServiceSummary::Discovery(summary)
1382        } else if let Some(summary) = envelope.decode_block_sync()? {
1383            ZakuraLiveServiceSummary::BlockSync(summary)
1384        } else {
1385            ZakuraLiveServiceSummary::Unknown
1386        };
1387        Ok(Self {
1388            service_id: envelope.service_id.clone(),
1389            summary_tag: envelope.summary_tag,
1390            summary,
1391            observed_at_unix_secs,
1392            expires_at_unix_secs,
1393        })
1394    }
1395
1396    fn preference_score(&self) -> u32 {
1397        match self.summary {
1398            ZakuraLiveServiceSummary::HeaderSync(summary) => {
1399                if !summary.serving_headers {
1400                    return 0;
1401                }
1402                u32::from(summary.inbound_slots_free)
1403                    .saturating_add(u32::from(summary.outbound_slots_free))
1404            }
1405            ZakuraLiveServiceSummary::Discovery(summary) => {
1406                u32::from(summary.peer_exchange_slots_free)
1407            }
1408            ZakuraLiveServiceSummary::BlockSync(summary) => u32::from(summary.free_slots)
1409                .saturating_add(
1410                    summary
1411                        .max_blocks_per_response
1412                        .min(MAX_BS_BLOCKS_PER_REQUEST),
1413                ),
1414            ZakuraLiveServiceSummary::Unknown => 1,
1415        }
1416    }
1417}
1418
1419/// Decoded payload for a cached first-party live service summary.
1420#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1421pub enum ZakuraLiveServiceSummary {
1422    /// Header-sync dial/admission hint.
1423    HeaderSync(HeaderSyncServiceSummary),
1424    /// Discovery exchange hint.
1425    Discovery(DiscoveryServiceSummary),
1426    /// Block-sync dial/admission hint.
1427    BlockSync(BlockSyncServiceSummary),
1428    /// Unknown or reserved summary tag, kept only as a freshness hint.
1429    Unknown,
1430}
1431
1432#[derive(Clone)]
1433struct ZakuraLocalDiscoveryState {
1434    secret_key: SecretKey,
1435    node_id: NodeId,
1436    direct_addrs: Vec<SocketAddr>,
1437    services: Vec<ZakuraServiceId>,
1438    zakura_protocol_min: u16,
1439    zakura_protocol_max: u16,
1440    network_id: ZakuraNetworkId,
1441    chain_id: [u8; 32],
1442    next_sequence: u64,
1443}
1444
1445impl fmt::Debug for ZakuraLocalDiscoveryState {
1446    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1447        formatter
1448            .debug_struct("ZakuraLocalDiscoveryState")
1449            .field("node_id", &self.node_id)
1450            .field("direct_addrs", &self.direct_addrs)
1451            .field("services", &self.services)
1452            .field("zakura_protocol_min", &self.zakura_protocol_min)
1453            .field("zakura_protocol_max", &self.zakura_protocol_max)
1454            .field("network_id", &self.network_id)
1455            .field("chain_id", &self.chain_id)
1456            .field("next_sequence", &self.next_sequence)
1457            .finish()
1458    }
1459}
1460
1461impl ZakuraDiscoveryHandle {
1462    /// Creates passive discovery state using the current wall-clock sequence.
1463    pub fn new(
1464        local_config: ZakuraDiscoveryLocalConfig,
1465        config: ZakuraDiscoveryConfig,
1466        connected: watch::Receiver<Vec<ZakuraPeerId>>,
1467    ) -> Result<Self, DiscoveryWireError> {
1468        Self::new_at(
1469            local_config,
1470            config,
1471            connected,
1472            current_unix_secs(),
1473            current_sequence_tick(),
1474        )
1475    }
1476
1477    fn new_at(
1478        local_config: ZakuraDiscoveryLocalConfig,
1479        config: ZakuraDiscoveryConfig,
1480        connected: watch::Receiver<Vec<ZakuraPeerId>>,
1481        now_unix_secs: u64,
1482        wall_clock_sequence: u64,
1483    ) -> Result<Self, DiscoveryWireError> {
1484        let mut local = ZakuraLocalDiscoveryState::new(local_config, wall_clock_sequence);
1485        let self_record = Arc::new(local.build_self_record(
1486            now_unix_secs,
1487            wall_clock_sequence,
1488            config.record_ttl,
1489        )?);
1490        let (self_record_tx, self_record_rx) = watch::channel(self_record);
1491        let (peer_snapshot_tx, peer_snapshot_rx) =
1492            watch::channel(ServicePeerSnapshot::new(0, 0, config.peer_limits));
1493        let book = ZakuraDiscoveryBook::with_local_node_id(config.book_limits, local.node_id);
1494        let import_validation = DiscoveryImportValidation {
1495            expected_network_id: local.network_id,
1496            expected_chain_id: local.chain_id,
1497            supported_protocol_min: local.zakura_protocol_min,
1498            supported_protocol_max: local.zakura_protocol_max,
1499            max_record_ttl: config.max_record_ttl,
1500            clock_skew_tolerance: config.clock_skew_tolerance,
1501            max_imported_records_per_response: config.book_limits.max_imported_records_per_response,
1502        };
1503        Ok(Self {
1504            inner: Arc::new(Mutex::new(ZakuraDiscoveryInner {
1505                book,
1506                active_services: HashMap::new(),
1507                admitted_peers: HashMap::new(),
1508                last_connected_node_ids: HashSet::new(),
1509                local,
1510                config,
1511            })),
1512            connected,
1513            self_record: self_record_rx,
1514            self_record_tx,
1515            peer_snapshot: peer_snapshot_rx,
1516            peer_snapshot_tx,
1517            import_validation,
1518        })
1519    }
1520
1521    /// Returns the current signed local self-record without taking the state lock.
1522    pub fn current_self_record(&self) -> Arc<ZakuraNodeRecord> {
1523        self.self_record.borrow().clone()
1524    }
1525
1526    /// Returns the local node id used in authored self-records.
1527    pub fn local_node_id(&self) -> NodeId {
1528        self.self_record.borrow().body.node_id
1529    }
1530
1531    /// Returns the configured peer sample limit for active exchanges.
1532    pub async fn peer_sample_limit(&self) -> usize {
1533        let inner = self.inner.lock().await;
1534        inner.config.peer_sample_limit
1535    }
1536
1537    /// Returns the configured interval between discovery refresh exchanges.
1538    pub async fn refresh_interval(&self) -> Duration {
1539        let inner = self.inner.lock().await;
1540        inner.config.refresh_interval
1541    }
1542
1543    /// Return the currently cached discovery peer slot snapshot.
1544    pub fn peer_snapshot(&self) -> ServicePeerSnapshot {
1545        *self.peer_snapshot.borrow()
1546    }
1547
1548    /// Subscribe to discovery peer slot snapshots.
1549    pub fn subscribe_peer_snapshot(&self) -> watch::Receiver<ServicePeerSnapshot> {
1550        self.peer_snapshot.clone()
1551    }
1552
1553    /// Build this node's first-party discovery service summary.
1554    pub async fn local_discovery_summary(&self) -> DiscoveryServiceSummary {
1555        DiscoveryServiceSummary::from_snapshot(
1556            self.peer_snapshot(),
1557            self.peer_sample_limit().await,
1558            true,
1559        )
1560    }
1561
1562    /// Wrap first-party live summaries in a short-lived service response.
1563    pub fn local_services_response(&self, summaries: Vec<ServiceSummaryEnvelope>) -> Services {
1564        Services {
1565            node_id: self.local_node_id(),
1566            expires_at_unix_secs: current_unix_secs()
1567                .saturating_add(DEFAULT_LIVE_SERVICE_SUMMARY_TTL.as_secs()),
1568            summaries,
1569        }
1570    }
1571
1572    /// Admit a typed discovery session if the service-specific cap has room.
1573    pub async fn admit_peer(
1574        &self,
1575        conn_id: ZakuraConnId,
1576        peer_id: ZakuraPeerId,
1577        direction: ServicePeerDirection,
1578    ) -> ServiceAdmissionDecision {
1579        let mut inner = self.inner.lock().await;
1580        if let Some(peer) = inner.admitted_peers.get_mut(&peer_id) {
1581            if conn_id < peer.conn_id {
1582                return ServiceAdmissionDecision::RejectNotUseful;
1583            }
1584
1585            *peer = ZakuraDiscoveryAdmittedPeer { conn_id, direction };
1586            self.publish_peer_snapshot_locked(&inner);
1587            return ServiceAdmissionDecision::Admit;
1588        }
1589
1590        let admitted = inner
1591            .admitted_peers
1592            .values()
1593            .filter(|peer| peer.direction == direction)
1594            .count();
1595        let cap = match direction {
1596            ServicePeerDirection::Inbound => inner.config.peer_limits.max_inbound_peers,
1597            ServicePeerDirection::Outbound => inner.config.peer_limits.max_outbound_peers,
1598        };
1599
1600        if admitted >= cap {
1601            ServiceAdmissionDecision::RejectFull
1602        } else {
1603            inner
1604                .admitted_peers
1605                .insert(peer_id, ZakuraDiscoveryAdmittedPeer { conn_id, direction });
1606            self.publish_peer_snapshot_locked(&inner);
1607            ServiceAdmissionDecision::Admit
1608        }
1609    }
1610
1611    /// Remove a locally closed or disconnected discovery service session.
1612    pub async fn remove_peer(&self, peer_id: &ZakuraPeerId, conn_id: ZakuraConnId) {
1613        let mut inner = self.inner.lock().await;
1614        if inner
1615            .admitted_peers
1616            .get(peer_id)
1617            .is_some_and(|peer| peer.conn_id == conn_id)
1618        {
1619            inner.admitted_peers.remove(peer_id);
1620        }
1621        self.publish_peer_snapshot_locked(&inner);
1622    }
1623
1624    fn publish_peer_snapshot_locked(&self, inner: &ZakuraDiscoveryInner) {
1625        let inbound = inner
1626            .admitted_peers
1627            .values()
1628            .filter(|peer| peer.direction == ServicePeerDirection::Inbound)
1629            .count();
1630        let outbound = inner
1631            .admitted_peers
1632            .values()
1633            .filter(|peer| peer.direction == ServicePeerDirection::Outbound)
1634            .count();
1635        let _ = self.peer_snapshot_tx.send(ServicePeerSnapshot::new(
1636            inbound,
1637            outbound,
1638            inner.config.peer_limits,
1639        ));
1640    }
1641
1642    /// Returns bounded node ids to exclude from a peer-sample request.
1643    ///
1644    /// Exclusions include the local node, currently connected peers, and recent known records.
1645    pub async fn peer_sample_exclusions(&self) -> Vec<NodeId> {
1646        let connected = connected_peer_node_ids(&self.connected.borrow());
1647        let inner = self.inner.lock().await;
1648        let mut excluded = Vec::with_capacity(inner.config.max_excluded_node_ids);
1649        push_excluded_node_id(
1650            &mut excluded,
1651            inner.local.node_id,
1652            inner.config.max_excluded_node_ids,
1653        );
1654        for node_id in connected {
1655            push_excluded_node_id(&mut excluded, node_id, inner.config.max_excluded_node_ids);
1656        }
1657        for node_id in inner.book.recent_node_ids(
1658            inner
1659                .config
1660                .max_excluded_node_ids
1661                .saturating_sub(excluded.len()),
1662        ) {
1663            push_excluded_node_id(&mut excluded, node_id, inner.config.max_excluded_node_ids);
1664        }
1665        excluded
1666    }
1667
1668    /// Replaces the local advertised service set and signs a newer self-record.
1669    pub async fn update_advertised_services(
1670        &self,
1671        services: Vec<ZakuraServiceId>,
1672    ) -> Result<Arc<ZakuraNodeRecord>, DiscoveryWireError> {
1673        let services = normalize_services(services);
1674        if self.self_record.borrow().body.services == services {
1675            return Ok(self.current_self_record());
1676        }
1677
1678        let record = {
1679            let mut inner = self.inner.lock().await;
1680            inner.local.services = services;
1681            let record_ttl = inner.config.record_ttl;
1682            Arc::new(inner.local.build_self_record(
1683                current_unix_secs(),
1684                current_sequence_tick(),
1685                record_ttl,
1686            )?)
1687        };
1688        self.self_record_tx.send_replace(record.clone());
1689        Ok(record)
1690    }
1691
1692    /// Imports peer-supplied signed records through the discovery book validation path.
1693    ///
1694    /// Record signatures are verified OUTSIDE the global discovery mutex. Ed25519
1695    /// verification and signature-domain re-encoding are CPU-heavy and fully
1696    /// attacker-driven (an authenticated discovery peer can send a full `Peers` batch of
1697    /// records with valid or invalid signatures, repeatedly); running them while holding
1698    /// the shared async lock lets that peer stall unrelated discovery admission,
1699    /// sampling, and dial selection. Only the cheap book mutation (storage-limit recheck,
1700    /// address policy, map insert, eviction) runs under the lock, and the lock is skipped
1701    /// entirely when nothing survives verification. See finding
1702    /// `claude-discovery-expensive-work-under-global-mutex` (SR-2).
1703    pub async fn import_peer_records(
1704        &self,
1705        records: impl IntoIterator<Item = ZakuraNodeRecord>,
1706        source: Option<NodeId>,
1707    ) -> ImportBatchOutcome {
1708        let now = current_unix_secs();
1709        let context = self.import_validation.context(now);
1710        let max_records = self.import_validation.max_imported_records_per_response;
1711
1712        let mut outcome = ImportBatchOutcome::default();
1713        let mut verified = Vec::new();
1714        for record in records {
1715            if outcome.attempted >= max_records {
1716                outcome.dropped_for_limit += 1;
1717                continue;
1718            }
1719            outcome.attempted += 1;
1720            match record.verify(&context) {
1721                Ok(()) => verified.push(record),
1722                Err(_) => outcome.rejected += 1,
1723            }
1724        }
1725
1726        if !verified.is_empty() {
1727            let mut inner = self.inner.lock().await;
1728            inner
1729                .book
1730                .import_pre_verified_records(verified, source, now, &context, &mut outcome);
1731        }
1732
1733        outcome
1734    }
1735
1736    /// Imports one peer-supplied signed record through the discovery book validation path.
1737    pub async fn import_peer_record(
1738        &self,
1739        record: ZakuraNodeRecord,
1740        source: Option<NodeId>,
1741    ) -> Result<ImportOutcome, DiscoveryBookError> {
1742        let now = current_unix_secs();
1743        let mut inner = self.inner.lock().await;
1744        let context = inner.validation_context(now);
1745        inner.book.import_record(record, source, now, &context)
1746    }
1747
1748    /// Imports a connected peer's first-party live service summaries.
1749    ///
1750    /// Live summaries are accepted only from the authenticated peer they describe, are bounded by
1751    /// the connected-peer set, and expire independently of durable signed node records.
1752    pub async fn import_connected_peer_services(
1753        &self,
1754        services: Services,
1755        peer_node_id: NodeId,
1756    ) -> Result<(), DiscoveryWireError> {
1757        self.import_connected_peer_services_at(services, peer_node_id, current_unix_secs())
1758            .await
1759    }
1760
1761    async fn import_connected_peer_services_at(
1762        &self,
1763        services: Services,
1764        peer_node_id: NodeId,
1765        now: u64,
1766    ) -> Result<(), DiscoveryWireError> {
1767        if services.node_id != peer_node_id {
1768            return Err(DiscoveryWireError::MismatchedNodeId {
1769                field: "services node id",
1770            });
1771        }
1772
1773        let connected_node_ids = connected_peer_node_ids(&self.connected.borrow());
1774        let mut inner = self.inner.lock().await;
1775        inner.sync_active_services(&connected_node_ids, now);
1776        if !connected_node_ids.contains(&peer_node_id) {
1777            return Ok(());
1778        }
1779
1780        let Some(expires_at_unix_secs) =
1781            effective_live_summary_expiry(services.expires_at_unix_secs, now)
1782        else {
1783            if let Some(entry) = inner.active_services.get_mut(&peer_node_id) {
1784                entry.live_summaries.clear();
1785                if entry.record.is_none() {
1786                    entry.refresh_live_derived_services();
1787                }
1788            }
1789            inner
1790                .active_services
1791                .retain(|_, entry| !entry.is_empty_live_only());
1792            return Ok(());
1793        };
1794
1795        let mut live_summaries = Vec::with_capacity(services.summaries.len());
1796        for envelope in &services.summaries {
1797            live_summaries.push(ZakuraCachedLiveServiceSummary::from_envelope(
1798                envelope,
1799                now,
1800                expires_at_unix_secs,
1801            )?);
1802        }
1803
1804        inner
1805            .active_services
1806            .entry(peer_node_id)
1807            .and_modify(|entry| entry.update_live_summaries(live_summaries.clone()))
1808            .or_insert_with(|| ZakuraActiveServiceEntry::from_live_summaries(live_summaries));
1809        Ok(())
1810    }
1811
1812    /// Imports one trusted static signed record through the static validation path.
1813    pub async fn import_static_record(
1814        &self,
1815        record: ZakuraNodeRecord,
1816    ) -> Result<ImportOutcome, DiscoveryBookError> {
1817        let now = current_unix_secs();
1818        let mut inner = self.inner.lock().await;
1819        let context = inner.validation_context(now);
1820        inner.book.import_static_record(record, now, &context)
1821    }
1822
1823    /// Imports a connected peer's latest self-record and updates active service hints.
1824    ///
1825    /// Active service hints come only from the authenticated peer's own signed `Hello`.
1826    /// Addressless or locally non-dialable records can still update active services, because the
1827    /// live connection is already established; they are still rejected from dialable storage.
1828    pub async fn import_connected_peer_record(
1829        &self,
1830        record: ZakuraNodeRecord,
1831        peer_node_id: NodeId,
1832    ) -> Result<ImportOutcome, DiscoveryBookError> {
1833        if record.body.node_id != peer_node_id {
1834            return Err(DiscoveryBookError::MismatchedConnectedPeerRecord);
1835        }
1836
1837        let now = current_unix_secs();
1838        let services = normalize_services(record.body.services.clone());
1839        let mut inner = self.inner.lock().await;
1840        let context = inner.validation_context(now);
1841        record.verify(&context)?;
1842        if record.body.services.len() > inner.config.book_limits.max_services_per_record {
1843            return Err(DiscoveryBookError::Limit {
1844                field: "service",
1845                actual: record.body.services.len(),
1846                max: inner.config.book_limits.max_services_per_record,
1847            });
1848        }
1849
1850        let import_result =
1851            inner
1852                .book
1853                .import_record(record.clone(), Some(peer_node_id), now, &context);
1854        if import_result.is_ok()
1855            || matches!(import_result, Err(ref error) if is_direct_address_import_error(error))
1856        {
1857            inner.update_active_services_from_connected_record(
1858                peer_node_id,
1859                record,
1860                services,
1861                &import_result,
1862            );
1863        }
1864        import_result
1865    }
1866
1867    /// Imports records loaded from persistent storage through the validation path.
1868    pub async fn import_persisted_entries(
1869        &self,
1870        entries: impl IntoIterator<Item = ZakuraDiscoveryPersistedEntry>,
1871    ) -> ImportBatchOutcome {
1872        let now = current_unix_secs();
1873        let mut inner = self.inner.lock().await;
1874        let context = inner.validation_context(now);
1875        inner.book.import_persisted_entries(entries, now, &context)
1876    }
1877
1878    /// Inserts a trusted static/bootstrap dial candidate.
1879    pub async fn insert_static_candidate(
1880        &self,
1881        node_addr: NodeAddr,
1882    ) -> Result<(), DiscoveryBookError> {
1883        let mut inner = self.inner.lock().await;
1884        inner
1885            .book
1886            .insert_static_candidate(node_addr, current_unix_secs())
1887    }
1888
1889    /// Returns a bounded random sample of owned peer records.
1890    pub async fn sample_peers(
1891        &self,
1892        limit: usize,
1893        wanted_services: &[ZakuraServiceId],
1894        exclude_node_ids: &[NodeId],
1895    ) -> Vec<ZakuraNodeRecord> {
1896        let now = current_unix_secs();
1897        let inner = self.inner.lock().await;
1898        let wanted_services = bounded_services(wanted_services, inner.config.book_limits);
1899        let exclude_node_ids =
1900            bounded_node_ids(exclude_node_ids, inner.config.max_excluded_node_ids);
1901        let mut rng = rand::thread_rng();
1902        inner.book.sample_peers(
1903            limit.min(inner.config.peer_sample_limit),
1904            &wanted_services,
1905            &exclude_node_ids,
1906            now,
1907            &mut rng,
1908        )
1909    }
1910
1911    /// Returns owned dial candidates, excluding peers currently registered with the supervisor.
1912    pub async fn dial_candidates(
1913        &self,
1914        wanted_services: &[ZakuraServiceId],
1915        in_flight_node_ids: &[NodeId],
1916    ) -> Vec<ZakuraDiscoveryDialCandidate> {
1917        let connected = self.connected.borrow().clone();
1918        let (limit, dial_backoff_base, dial_backoff_max, book_limits) = {
1919            let inner = self.inner.lock().await;
1920            (
1921                discovery_dial_slot_limit(
1922                    connected.len(),
1923                    in_flight_node_ids.len(),
1924                    inner.config.max_zakura_connections,
1925                    inner.config.discovery_connection_headroom,
1926                    inner.config.max_concurrent_discovery_dials,
1927                ),
1928                inner.config.dial_backoff_base,
1929                inner.config.dial_backoff_max,
1930                inner.config.book_limits,
1931            )
1932        };
1933        if limit == 0 {
1934            return Vec::new();
1935        }
1936
1937        let connected_node_ids = connected_peer_node_ids(&connected);
1938        let now = current_unix_secs();
1939        let inner = self.inner.lock().await;
1940        let wanted_services = bounded_services(wanted_services, book_limits);
1941        let mut rng = rand::thread_rng();
1942        inner.book.dial_candidates(
1943            limit,
1944            &wanted_services,
1945            DialCandidateExclusions {
1946                connected_node_ids: &connected_node_ids,
1947                in_flight_node_ids,
1948            },
1949            now,
1950            (dial_backoff_base, dial_backoff_max),
1951            &mut rng,
1952        )
1953    }
1954
1955    /// Returns service-aware candidates, using fallback to general peers only when requested.
1956    pub async fn service_candidates(
1957        &self,
1958        service: &ZakuraServiceId,
1959        allow_fallback: bool,
1960        in_flight_node_ids: &[NodeId],
1961    ) -> ZakuraServiceCandidates {
1962        let connected = self.connected.borrow().clone();
1963        let connected_node_ids = connected_peer_node_ids(&connected);
1964        let (limit, dial_backoff_base, dial_backoff_max, book_limits) = {
1965            let inner = self.inner.lock().await;
1966            (
1967                discovery_dial_slot_limit(
1968                    connected.len(),
1969                    in_flight_node_ids.len(),
1970                    inner.config.max_zakura_connections,
1971                    inner.config.discovery_connection_headroom,
1972                    inner.config.max_concurrent_discovery_dials,
1973                ),
1974                inner.config.dial_backoff_base,
1975                inner.config.dial_backoff_max,
1976                inner.config.book_limits,
1977            )
1978        };
1979
1980        let now = current_unix_secs();
1981        let mut inner = self.inner.lock().await;
1982        inner.sync_active_services(&connected_node_ids, now);
1983
1984        let mut connected_entries: Vec<_> = inner
1985            .active_services
1986            .iter()
1987            .filter(|(node_id, entry)| {
1988                connected_node_ids.contains(node_id) && entry.services.contains(service)
1989            })
1990            .map(|(node_id, entry)| {
1991                (
1992                    *node_id,
1993                    entry.live_summary_preference(service, now),
1994                    node_id_sort_key(node_id),
1995                )
1996            })
1997            .collect();
1998        connected_entries
1999            .sort_by_key(|(_, preference, sort_key)| (Reverse(*preference), *sort_key));
2000        let connected: Vec<NodeId> = connected_entries
2001            .into_iter()
2002            .map(|(node_id, _, _)| node_id)
2003            .collect();
2004
2005        let wanted_services = bounded_services(std::slice::from_ref(service), book_limits);
2006        let mut rng = rand::thread_rng();
2007        let mut discovered = inner.book.dial_candidates(
2008            limit,
2009            &wanted_services,
2010            DialCandidateExclusions {
2011                connected_node_ids: &connected_node_ids,
2012                in_flight_node_ids,
2013            },
2014            now,
2015            (dial_backoff_base, dial_backoff_max),
2016            &mut rng,
2017        );
2018        let used_fallback = allow_fallback && connected.is_empty() && discovered.is_empty();
2019        if used_fallback {
2020            discovered = inner.book.dial_candidates(
2021                limit,
2022                &[],
2023                DialCandidateExclusions {
2024                    connected_node_ids: &connected_node_ids,
2025                    in_flight_node_ids,
2026                },
2027                now,
2028                (dial_backoff_base, dial_backoff_max),
2029                &mut rng,
2030            );
2031        }
2032
2033        ZakuraServiceCandidates {
2034            connected,
2035            discovered,
2036            used_fallback,
2037        }
2038    }
2039
2040    /// Returns header-sync candidates filtered and ordered by first-party advisory summaries.
2041    ///
2042    /// The returned peers are still advisory only: header-sync `Status` and header validation remain
2043    /// authoritative after admission.
2044    pub async fn header_sync_candidates(
2045        &self,
2046        header_sync: &ZakuraHeaderSyncCandidateState,
2047        allow_fallback: bool,
2048        in_flight_node_ids: &[NodeId],
2049    ) -> ZakuraServiceCandidates {
2050        let connected = self.connected.borrow().clone();
2051        let connected_node_ids = connected_peer_node_ids(&connected);
2052        let (limit, dial_backoff_base, dial_backoff_max, book_limits) = {
2053            let inner = self.inner.lock().await;
2054            (
2055                discovery_dial_slot_limit(
2056                    connected.len(),
2057                    in_flight_node_ids.len(),
2058                    inner.config.max_zakura_connections,
2059                    inner.config.discovery_connection_headroom,
2060                    inner.config.max_concurrent_discovery_dials,
2061                ),
2062                inner.config.dial_backoff_base,
2063                inner.config.dial_backoff_max,
2064                inner.config.book_limits,
2065            )
2066        };
2067
2068        let now = current_unix_secs();
2069        let mut inner = self.inner.lock().await;
2070        inner.sync_active_services(&connected_node_ids, now);
2071
2072        let admitted_node_ids: HashSet<_> = header_sync.admitted_node_ids.iter().copied().collect();
2073        let backed_off_node_ids: HashSet<_> =
2074            header_sync.backed_off_node_ids.iter().copied().collect();
2075        let header_sync_service = ZakuraServiceId::header_sync();
2076        let mut connected_entries: Vec<_> = inner
2077            .active_services
2078            .iter()
2079            .filter_map(|(node_id, entry)| {
2080                let is_admitted = admitted_node_ids.contains(node_id);
2081                if !connected_node_ids.contains(node_id)
2082                    || !entry.services.contains(&header_sync_service)
2083                    || (backed_off_node_ids.contains(node_id) && !is_admitted)
2084                {
2085                    return None;
2086                }
2087
2088                let summary = entry.fresh_header_sync_summary(now);
2089                if summary.is_some_and(|summary| {
2090                    summary.serving_headers && summary.inbound_slots_free == 0 && !is_admitted
2091                }) {
2092                    return None;
2093                }
2094
2095                Some((
2096                    *node_id,
2097                    header_sync_candidate_preference(summary, header_sync.target_height),
2098                    node_id_sort_key(node_id),
2099                ))
2100            })
2101            .collect();
2102        connected_entries
2103            .sort_by_key(|(_, preference, sort_key)| (Reverse(*preference), *sort_key));
2104        let connected: Vec<NodeId> = connected_entries
2105            .into_iter()
2106            .map(|(node_id, _, _)| node_id)
2107            .collect();
2108
2109        let wanted_services =
2110            bounded_services(std::slice::from_ref(&header_sync_service), book_limits);
2111        let mut excluded_node_ids = Vec::with_capacity(
2112            in_flight_node_ids
2113                .len()
2114                .saturating_add(header_sync.backed_off_node_ids.len()),
2115        );
2116        excluded_node_ids.extend_from_slice(in_flight_node_ids);
2117        excluded_node_ids.extend(
2118            header_sync
2119                .backed_off_node_ids
2120                .iter()
2121                .copied()
2122                .filter(|node_id| !admitted_node_ids.contains(node_id)),
2123        );
2124        let mut rng = rand::thread_rng();
2125        let mut discovered = inner.book.dial_candidates(
2126            limit,
2127            &wanted_services,
2128            DialCandidateExclusions {
2129                connected_node_ids: &connected_node_ids,
2130                in_flight_node_ids: &excluded_node_ids,
2131            },
2132            now,
2133            (dial_backoff_base, dial_backoff_max),
2134            &mut rng,
2135        );
2136        let used_fallback = allow_fallback && connected.is_empty() && discovered.is_empty();
2137        if used_fallback {
2138            discovered = inner.book.dial_candidates(
2139                limit,
2140                &[],
2141                DialCandidateExclusions {
2142                    connected_node_ids: &connected_node_ids,
2143                    in_flight_node_ids: &excluded_node_ids,
2144                },
2145                now,
2146                (dial_backoff_base, dial_backoff_max),
2147                &mut rng,
2148            );
2149        }
2150
2151        ZakuraServiceCandidates {
2152            connected,
2153            discovered,
2154            used_fallback,
2155        }
2156    }
2157
2158    /// Returns block-sync candidates filtered and ordered by first-party advisory summaries.
2159    ///
2160    /// The returned peers are still advisory only: block-sync `Status`, body hash validation, and
2161    /// consensus verification remain authoritative after admission.
2162    pub async fn block_sync_candidates(
2163        &self,
2164        block_sync: &ZakuraBlockSyncCandidateState,
2165        allow_fallback: bool,
2166        in_flight_node_ids: &[NodeId],
2167    ) -> ZakuraServiceCandidates {
2168        let connected = self.connected.borrow().clone();
2169        let connected_node_ids = connected_peer_node_ids(&connected);
2170        let (limit, dial_backoff_base, dial_backoff_max, book_limits) = {
2171            let inner = self.inner.lock().await;
2172            (
2173                discovery_dial_slot_limit(
2174                    connected.len(),
2175                    in_flight_node_ids.len(),
2176                    inner.config.max_zakura_connections,
2177                    inner.config.discovery_connection_headroom,
2178                    inner.config.max_concurrent_discovery_dials,
2179                ),
2180                inner.config.dial_backoff_base,
2181                inner.config.dial_backoff_max,
2182                inner.config.book_limits,
2183            )
2184        };
2185
2186        let now = current_unix_secs();
2187        let mut inner = self.inner.lock().await;
2188        inner.sync_active_services(&connected_node_ids, now);
2189
2190        let admitted_node_ids: HashSet<_> = block_sync.admitted_node_ids.iter().copied().collect();
2191        let block_sync_service = ZakuraServiceId::block_sync();
2192        let mut connected_entries: Vec<_> = inner
2193            .active_services
2194            .iter()
2195            .filter_map(|(node_id, entry)| {
2196                if !connected_node_ids.contains(node_id)
2197                    || !entry.services.contains(&block_sync_service)
2198                {
2199                    return None;
2200                }
2201
2202                if block_sync.missing_block_bodies.is_empty() {
2203                    return Some((
2204                        *node_id,
2205                        entry.live_summary_preference(&block_sync_service, now),
2206                        node_id_sort_key(node_id),
2207                    ));
2208                }
2209
2210                let is_admitted = admitted_node_ids.contains(node_id);
2211                let summary = entry.fresh_block_sync_summary(now);
2212                if summary.is_some_and(|summary| summary.free_slots == 0 && !is_admitted) {
2213                    return None;
2214                }
2215
2216                Some((
2217                    *node_id,
2218                    block_sync_candidate_preference(summary, &block_sync.missing_block_bodies),
2219                    node_id_sort_key(node_id),
2220                ))
2221            })
2222            .collect();
2223        connected_entries
2224            .sort_by_key(|(_, preference, sort_key)| (Reverse(*preference), *sort_key));
2225        let connected: Vec<NodeId> = connected_entries
2226            .into_iter()
2227            .map(|(node_id, _, _)| node_id)
2228            .collect();
2229
2230        let wanted_services =
2231            bounded_services(std::slice::from_ref(&block_sync_service), book_limits);
2232        let mut rng = rand::thread_rng();
2233        let mut discovered = inner.book.dial_candidates(
2234            limit,
2235            &wanted_services,
2236            DialCandidateExclusions {
2237                connected_node_ids: &connected_node_ids,
2238                in_flight_node_ids,
2239            },
2240            now,
2241            (dial_backoff_base, dial_backoff_max),
2242            &mut rng,
2243        );
2244        let used_fallback = allow_fallback && connected.is_empty() && discovered.is_empty();
2245        if used_fallback {
2246            discovered = inner.book.dial_candidates(
2247                limit,
2248                &[],
2249                DialCandidateExclusions {
2250                    connected_node_ids: &connected_node_ids,
2251                    in_flight_node_ids,
2252                },
2253                now,
2254                (dial_backoff_base, dial_backoff_max),
2255                &mut rng,
2256            );
2257        }
2258
2259        ZakuraServiceCandidates {
2260            connected,
2261            discovered,
2262            used_fallback,
2263        }
2264    }
2265
2266    /// Marks a discovery dial attempt for `node_id`.
2267    pub async fn mark_dial_attempt(&self, node_id: &NodeId) {
2268        let mut inner = self.inner.lock().await;
2269        inner.book.mark_dial_attempt(node_id, current_unix_secs());
2270    }
2271
2272    /// Marks a successful discovery dial for `node_id`.
2273    pub async fn mark_dial_success(&self, node_id: &NodeId) {
2274        let mut inner = self.inner.lock().await;
2275        inner.book.mark_dial_success(node_id, current_unix_secs());
2276    }
2277
2278    /// Marks a failed discovery dial for `node_id`.
2279    pub async fn mark_dial_failure(&self, node_id: &NodeId) {
2280        let mut inner = self.inner.lock().await;
2281        inner.book.mark_dial_failure(node_id, current_unix_secs());
2282    }
2283
2284    /// Marks a completed short-lived discovery exchange for local redial backoff.
2285    pub async fn mark_short_lived_exchange(&self, node_id: &NodeId) {
2286        let mut inner = self.inner.lock().await;
2287        inner
2288            .book
2289            .mark_short_lived_exchange(node_id, current_unix_secs());
2290    }
2291
2292    /// Returns a connected peer's advertised services as a derived supervisor-watch projection.
2293    pub async fn active_services(&self, node_id: NodeId) -> Option<Vec<ZakuraServiceId>> {
2294        let connected = connected_peer_node_ids(&self.connected.borrow());
2295        let now = current_unix_secs();
2296        let mut inner = self.inner.lock().await;
2297        inner.sync_active_services(&connected, now);
2298        if !connected.contains(&node_id) {
2299            return None;
2300        }
2301
2302        inner
2303            .active_services
2304            .get(&node_id)
2305            .map(|entry| entry.services.clone())
2306    }
2307
2308    /// Returns fresh first-party live summaries cached for a connected peer.
2309    pub async fn live_service_summaries(
2310        &self,
2311        node_id: NodeId,
2312    ) -> Option<Vec<ZakuraCachedLiveServiceSummary>> {
2313        self.live_service_summaries_at(node_id, current_unix_secs())
2314            .await
2315    }
2316
2317    async fn live_service_summaries_at(
2318        &self,
2319        node_id: NodeId,
2320        now: u64,
2321    ) -> Option<Vec<ZakuraCachedLiveServiceSummary>> {
2322        let connected = connected_peer_node_ids(&self.connected.borrow());
2323        let mut inner = self.inner.lock().await;
2324        inner.sync_active_services(&connected, now);
2325        if !connected.contains(&node_id) {
2326            return None;
2327        }
2328
2329        inner
2330            .active_services
2331            .get(&node_id)
2332            .map(|entry| entry.live_summaries.clone())
2333    }
2334
2335    /// Returns a stored discovery record for `node_id`, if present.
2336    pub async fn record_for(&self, node_id: NodeId) -> Option<ZakuraNodeRecord> {
2337        let inner = self.inner.lock().await;
2338        inner.book.get(&node_id).map(|entry| entry.record().clone())
2339    }
2340
2341    /// Returns owned entries suitable for a future Zakura-specific persistent cache writer.
2342    pub async fn persisted_entries(&self) -> Vec<ZakuraDiscoveryPersistedEntry> {
2343        let inner = self.inner.lock().await;
2344        inner.book.persisted_entries()
2345    }
2346}
2347
2348impl ZakuraDiscoveryInner {
2349    fn validation_context(&self, now: u64) -> DiscoveryRecordValidationContext {
2350        DiscoveryRecordValidationContext {
2351            expected_network_id: self.local.network_id,
2352            expected_chain_id: self.local.chain_id,
2353            current_unix_secs: now,
2354            supported_protocol_min: self.local.zakura_protocol_min,
2355            supported_protocol_max: self.local.zakura_protocol_max,
2356            max_record_ttl: self.config.max_record_ttl,
2357            clock_skew_tolerance: self.config.clock_skew_tolerance,
2358        }
2359    }
2360
2361    fn sync_active_services(&mut self, connected_node_ids: &[NodeId], now_unix_secs: u64) {
2362        let connected_node_ids: HashSet<_> = connected_node_ids.iter().copied().collect();
2363        self.active_services
2364            .retain(|node_id, _| connected_node_ids.contains(node_id));
2365        for entry in self.active_services.values_mut() {
2366            entry.prune_expired_live_summaries(now_unix_secs);
2367        }
2368        self.active_services
2369            .retain(|_, entry| !entry.is_empty_live_only());
2370        self.last_connected_node_ids = connected_node_ids;
2371    }
2372
2373    fn update_active_services_from_connected_record(
2374        &mut self,
2375        node_id: NodeId,
2376        record: ZakuraNodeRecord,
2377        services: Vec<ZakuraServiceId>,
2378        import_result: &Result<ImportOutcome, DiscoveryBookError>,
2379    ) {
2380        if self.active_services.get(&node_id).is_some_and(|active| {
2381            active
2382                .record
2383                .as_ref()
2384                .is_some_and(|active_record| record.body.sequence <= active_record.body.sequence)
2385        }) {
2386            return;
2387        }
2388
2389        let should_update = match import_result {
2390            Ok(ImportOutcome::Added | ImportOutcome::Updated) => true,
2391            Ok(ImportOutcome::MetadataUpdated) => self
2392                .book
2393                .get(&node_id)
2394                .is_some_and(|entry| entry.record() == &record),
2395            Ok(ImportOutcome::IgnoredOlder) => false,
2396            Err(error) if is_direct_address_import_error(error) => self
2397                .book
2398                .get(&node_id)
2399                .is_none_or(|entry| connected_record_is_fresh_for_stored_record(&record, entry)),
2400            Err(_) => false,
2401        };
2402
2403        if should_update {
2404            self.active_services
2405                .entry(node_id)
2406                .and_modify(|entry| entry.update_record(record.clone(), services.clone()))
2407                .or_insert_with(|| ZakuraActiveServiceEntry::from_record(record, services));
2408        }
2409    }
2410}
2411
2412impl ZakuraLocalDiscoveryState {
2413    fn new(config: ZakuraDiscoveryLocalConfig, wall_clock_sequence: u64) -> Self {
2414        let node_id = config.secret_key.public();
2415        let persisted_next_sequence = config
2416            .last_authored_sequence
2417            .map(|sequence| sequence.saturating_add(1))
2418            .unwrap_or(0);
2419        Self {
2420            secret_key: config.secret_key,
2421            node_id,
2422            direct_addrs: normalize_direct_addrs(config.direct_addrs),
2423            services: normalize_services(config.services),
2424            zakura_protocol_min: config.zakura_protocol_min,
2425            zakura_protocol_max: config.zakura_protocol_max,
2426            network_id: config.network_id,
2427            chain_id: config.chain_id,
2428            next_sequence: persisted_next_sequence.max(wall_clock_sequence),
2429        }
2430    }
2431
2432    fn build_self_record(
2433        &mut self,
2434        now_unix_secs: u64,
2435        wall_clock_sequence: u64,
2436        record_ttl: Duration,
2437    ) -> Result<ZakuraNodeRecord, DiscoveryWireError> {
2438        let sequence = self.next_sequence.max(wall_clock_sequence);
2439        self.next_sequence = sequence.saturating_add(1);
2440        ZakuraNodeRecord::sign(
2441            ZakuraNodeRecordBody {
2442                node_id: self.node_id,
2443                direct_addrs: self.direct_addrs.clone(),
2444                services: self.services.clone(),
2445                zakura_protocol_min: self.zakura_protocol_min,
2446                zakura_protocol_max: self.zakura_protocol_max,
2447                network_id: self.network_id,
2448                chain_id: self.chain_id,
2449                sequence,
2450                expires_at_unix_secs: now_unix_secs.saturating_add(record_ttl.as_secs()),
2451            },
2452            &self.secret_key,
2453        )
2454    }
2455}
2456
2457impl Default for ZakuraDiscoveryBook {
2458    fn default() -> Self {
2459        Self::new(ZakuraDiscoveryBookLimits::default())
2460    }
2461}
2462
2463impl ZakuraDiscoveryBook {
2464    /// Creates an empty discovery book with `limits`.
2465    pub fn new(limits: ZakuraDiscoveryBookLimits) -> Self {
2466        Self {
2467            entries: HashMap::new(),
2468            static_candidates: HashMap::new(),
2469            limits,
2470            local_node_id: None,
2471        }
2472    }
2473
2474    /// Creates an empty discovery book that rejects the local node's record.
2475    pub fn with_local_node_id(limits: ZakuraDiscoveryBookLimits, local_node_id: NodeId) -> Self {
2476        Self {
2477            entries: HashMap::new(),
2478            static_candidates: HashMap::new(),
2479            limits,
2480            local_node_id: Some(local_node_id),
2481        }
2482    }
2483
2484    /// Returns the storage limits.
2485    pub fn limits(&self) -> ZakuraDiscoveryBookLimits {
2486        self.limits
2487    }
2488
2489    /// Returns the total number of stored records, including static records.
2490    pub fn len(&self) -> usize {
2491        self.entries.len()
2492            + self
2493                .static_candidates
2494                .keys()
2495                .filter(|node_id| !self.entries.contains_key(*node_id))
2496                .count()
2497    }
2498
2499    /// Returns true when the book has no records.
2500    pub fn is_empty(&self) -> bool {
2501        self.entries.is_empty()
2502    }
2503
2504    /// Returns the number of non-static records learned from peers.
2505    pub fn discovered_len(&self) -> usize {
2506        self.entries
2507            .values()
2508            .filter(|entry| !entry.is_static)
2509            .count()
2510    }
2511
2512    /// Returns the entry for `node_id`, if present.
2513    pub fn get(&self, node_id: &NodeId) -> Option<&ZakuraDiscoveryEntry> {
2514        self.entries.get(node_id)
2515    }
2516
2517    /// Inserts or refreshes one trusted static/bootstrap dial candidate.
2518    ///
2519    /// Configured bootstrap peers are not signed discovery records, so they are stored separately
2520    /// from peer-supplied records and are never returned in peer samples.
2521    pub fn insert_static_candidate(
2522        &mut self,
2523        node_addr: NodeAddr,
2524        now: u64,
2525    ) -> Result<(), DiscoveryBookError> {
2526        if self.local_node_id == Some(node_addr.node_id) {
2527            return Err(DiscoveryBookError::SelfRecord);
2528        }
2529
2530        let mut direct_addrs: Vec<_> = node_addr.direct_addresses().copied().collect();
2531        direct_addrs.sort_unstable();
2532        direct_addrs.dedup();
2533        if direct_addrs.is_empty() {
2534            return Err(DiscoveryBookError::NoUsableDirectAddress);
2535        }
2536        for addr in &direct_addrs {
2537            if !is_static_discovery_configured_addr_usable(addr) {
2538                return Err(DiscoveryBookError::NonDialableDirectAddress { addr: *addr });
2539            }
2540        }
2541
2542        let candidate = self
2543            .static_candidates
2544            .entry(node_addr.node_id)
2545            .or_insert_with(|| ZakuraStaticDiscoveryCandidate {
2546                node_id: node_addr.node_id,
2547                direct_addrs: Vec::new(),
2548                last_seen: now,
2549                last_dial_attempt: None,
2550                last_success: None,
2551                last_short_lived_exchange: None,
2552                failure_count: 0,
2553            });
2554        candidate.direct_addrs = direct_addrs;
2555        candidate.last_seen = now;
2556        Ok(())
2557    }
2558
2559    /// Imports one signed peer record from a discovery source.
2560    pub fn import_record(
2561        &mut self,
2562        record: ZakuraNodeRecord,
2563        source: Option<NodeId>,
2564        now: u64,
2565        context: &DiscoveryRecordValidationContext,
2566    ) -> Result<ImportOutcome, DiscoveryBookError> {
2567        self.import_record_inner(record, source, false, false, now, context)
2568    }
2569
2570    /// Imports one signed static/bootstrap record.
2571    ///
2572    /// Static records are trusted-by-operator configuration, so this import path permits loopback
2573    /// and other local direct addresses needed by single-host regtest deployments. Untrusted
2574    /// peer/gossip imports through [`Self::import_record`] keep requiring globally dialable
2575    /// addresses.
2576    pub fn import_static_record(
2577        &mut self,
2578        record: ZakuraNodeRecord,
2579        now: u64,
2580        context: &DiscoveryRecordValidationContext,
2581    ) -> Result<ImportOutcome, DiscoveryBookError> {
2582        self.import_record_inner(record, None, true, false, now, context)
2583    }
2584
2585    /// Imports a batch of records whose signatures were already verified outside the lock.
2586    ///
2587    /// [`ZakuraDiscoveryHandle::import_peer_records`] performs signature verification and
2588    /// applies the per-response cap before taking the global discovery mutex, so this only
2589    /// runs the cheap storage-limit recheck, address-policy check, map mutation, and
2590    /// eviction under the lock. `attempted`/`dropped_for_limit` are owned by the caller;
2591    /// this updates the per-record success and rejection tallies on `outcome`.
2592    fn import_pre_verified_records(
2593        &mut self,
2594        records: impl IntoIterator<Item = ZakuraNodeRecord>,
2595        source: Option<NodeId>,
2596        now: u64,
2597        context: &DiscoveryRecordValidationContext,
2598        outcome: &mut ImportBatchOutcome,
2599    ) {
2600        for record in records {
2601            match self.import_record_inner(record, source, false, true, now, context) {
2602                Ok(import_outcome) => outcome.record_success(import_outcome),
2603                Err(_) => outcome.rejected += 1,
2604            }
2605        }
2606    }
2607
2608    /// Imports a bounded batch of signed peer records from a single response.
2609    pub fn import_records(
2610        &mut self,
2611        records: impl IntoIterator<Item = ZakuraNodeRecord>,
2612        source: Option<NodeId>,
2613        now: u64,
2614        context: &DiscoveryRecordValidationContext,
2615    ) -> ImportBatchOutcome {
2616        let mut outcome = ImportBatchOutcome::default();
2617        for (index, record) in records.into_iter().enumerate() {
2618            if index >= self.limits.max_imported_records_per_response {
2619                outcome.dropped_for_limit += 1;
2620                continue;
2621            }
2622            outcome.attempted += 1;
2623            match self.import_record(record, source, now, context) {
2624                Ok(import_outcome) => outcome.record_success(import_outcome),
2625                Err(_) => outcome.rejected += 1,
2626            }
2627        }
2628        outcome
2629    }
2630
2631    /// Returns a bounded random peer sample.
2632    pub fn sample_peers<R: rand::Rng + ?Sized>(
2633        &self,
2634        limit: usize,
2635        wanted_services: &[ZakuraServiceId],
2636        exclude_node_ids: &[NodeId],
2637        now: u64,
2638        rng: &mut R,
2639    ) -> Vec<ZakuraNodeRecord> {
2640        let exclude_node_ids: HashSet<_> = exclude_node_ids.iter().copied().collect();
2641        let limit = limit.min(self.limits.max_imported_records_per_response);
2642
2643        // Reservoir-sample references and clone only the chosen records. The book can hold
2644        // `max_records` (default 10_000) entries, each up to `max_encoded_record_bytes`, so cloning
2645        // every record that passes the filter — when at most `limit` (default 32) are ever returned
2646        // — was attacker-paced, allocation-heavy work performed while holding the global discovery
2647        // mutex. Per-call clone work is now bounded by `limit`, not the book size. See finding
2648        // `claude-discovery-expensive-work-under-global-mutex` (SR-2/SR-4).
2649        self.entries
2650            .iter()
2651            .filter(|(node_id, entry)| {
2652                !exclude_node_ids.contains(*node_id)
2653                    && self.local_node_id != Some(**node_id)
2654                    && !entry_is_expired(entry, now)
2655                    && has_wanted_services(&entry.record, wanted_services)
2656                    && has_discovery_dialable_direct_addrs(&entry.record)
2657            })
2658            .map(|(_, entry)| &entry.record)
2659            .choose_multiple(rng, limit)
2660            .into_iter()
2661            .cloned()
2662            .collect()
2663    }
2664
2665    /// Returns bounded dial candidates for later dial-loop code.
2666    pub(crate) fn dial_candidates<R: rand::Rng + ?Sized>(
2667        &self,
2668        limit: usize,
2669        wanted_services: &[ZakuraServiceId],
2670        exclusions: DialCandidateExclusions<'_>,
2671        now: u64,
2672        dial_backoff: (Duration, Duration),
2673        rng: &mut R,
2674    ) -> Vec<ZakuraDiscoveryDialCandidate> {
2675        let connected_node_ids: HashSet<_> =
2676            exclusions.connected_node_ids.iter().copied().collect();
2677        let in_flight_node_ids: HashSet<_> =
2678            exclusions.in_flight_node_ids.iter().copied().collect();
2679        let candidates: Vec<_> = self
2680            .entries
2681            .values()
2682            .filter(|entry| {
2683                !connected_node_ids.contains(&entry.record.body.node_id)
2684                    && !in_flight_node_ids.contains(&entry.record.body.node_id)
2685                    && self.local_node_id != Some(entry.record.body.node_id)
2686                    && !entry_is_expired(entry, now)
2687                    && !entry_in_dial_backoff(entry, now, dial_backoff.0, dial_backoff.1)
2688                    && !entry_in_short_lived_exchange_backoff(
2689                        entry,
2690                        now,
2691                        dial_backoff.0,
2692                        dial_backoff.1,
2693                    )
2694                    && entry_has_confirmed_dial_authority(entry)
2695                    && has_wanted_services(&entry.record, wanted_services)
2696                    && has_discovery_usable_direct_addrs(entry)
2697            })
2698            .map(DialCandidateRef::SignedRecord)
2699            .chain(self.static_candidates.values().filter_map(|candidate| {
2700                if !wanted_services.is_empty()
2701                    || connected_node_ids.contains(&candidate.node_id)
2702                    || in_flight_node_ids.contains(&candidate.node_id)
2703                    || self.local_node_id == Some(candidate.node_id)
2704                    || self.entries.contains_key(&candidate.node_id)
2705                    || entry_metadata_in_dial_backoff(
2706                        candidate.last_dial_attempt,
2707                        candidate.failure_count,
2708                        now,
2709                        dial_backoff.0,
2710                        dial_backoff.1,
2711                    )
2712                    || entry_metadata_in_short_lived_exchange_backoff(
2713                        candidate.last_short_lived_exchange,
2714                        now,
2715                        dial_backoff.0,
2716                        dial_backoff.1,
2717                    )
2718                {
2719                    return None;
2720                }
2721                Some(DialCandidateRef::StaticConfigured(candidate))
2722            }))
2723            .collect();
2724
2725        // Bounded top-k selection instead of a full sort. The book can hold `max_records`
2726        // (default 10_000) entries and this selection runs under the global discovery mutex on the
2727        // per-second candidate-dialer path, yet only `limit` candidates are ever returned. Sorting
2728        // the whole candidate set (O(n log n)) just to `take(limit)` is replaced with an O(n)
2729        // partial select of the best `limit` candidates plus an O(limit log limit) sort of the
2730        // survivors, keeping the order identical. See finding
2731        // `claude-discovery-expensive-work-under-global-mutex` (SR-2/SR-4).
2732        let mut keyed: Vec<(DialCandidateSortKey, DialCandidateRef<'_>)> = candidates
2733            .into_iter()
2734            .map(|candidate| (dial_candidate_sort_key(&candidate, rng), candidate))
2735            .collect();
2736
2737        let take = limit.min(keyed.len());
2738        if take < keyed.len() {
2739            keyed.select_nth_unstable_by(take, |a, b| a.0.cmp(&b.0));
2740            keyed.truncate(take);
2741        }
2742        keyed.sort_by_key(|a| a.0);
2743
2744        keyed
2745            .into_iter()
2746            .map(|(_, candidate)| candidate.into_candidate())
2747            .collect()
2748    }
2749
2750    /// Marks a dial attempt for `node_id`.
2751    pub fn mark_dial_attempt(&mut self, node_id: &NodeId, now: u64) {
2752        if let Some(entry) = self.entries.get_mut(node_id) {
2753            entry.last_dial_attempt = Some(now);
2754        }
2755        if let Some(candidate) = self.static_candidates.get_mut(node_id) {
2756            candidate.last_dial_attempt = Some(now);
2757        }
2758    }
2759
2760    /// Marks a successful dial for `node_id`.
2761    pub fn mark_dial_success(&mut self, node_id: &NodeId, now: u64) {
2762        if let Some(entry) = self.entries.get_mut(node_id) {
2763            entry.last_success = Some(now);
2764            entry.failure_count = 0;
2765        }
2766        if let Some(candidate) = self.static_candidates.get_mut(node_id) {
2767            candidate.last_success = Some(now);
2768            candidate.failure_count = 0;
2769        }
2770    }
2771
2772    /// Marks a failed dial for `node_id` without blacklisting it.
2773    pub fn mark_dial_failure(&mut self, node_id: &NodeId, _now: u64) {
2774        if let Some(entry) = self.entries.get_mut(node_id) {
2775            entry.failure_count = entry.failure_count.saturating_add(1);
2776        }
2777        if let Some(candidate) = self.static_candidates.get_mut(node_id) {
2778            candidate.failure_count = candidate.failure_count.saturating_add(1);
2779        }
2780    }
2781
2782    /// Marks a successful short-lived discovery exchange for `node_id`.
2783    pub fn mark_short_lived_exchange(&mut self, node_id: &NodeId, now: u64) {
2784        if let Some(entry) = self.entries.get_mut(node_id) {
2785            entry.last_short_lived_exchange = Some(now);
2786        }
2787        if let Some(candidate) = self.static_candidates.get_mut(node_id) {
2788            candidate.last_short_lived_exchange = Some(now);
2789        }
2790    }
2791
2792    /// Returns the advertised services for `node_id`.
2793    pub fn services_for(&self, node_id: &NodeId) -> Option<Vec<ZakuraServiceId>> {
2794        self.entries
2795            .get(node_id)
2796            .map(|entry| entry.record.body.services.clone())
2797    }
2798
2799    fn recent_node_ids(&self, limit: usize) -> Vec<NodeId> {
2800        let mut entries: Vec<_> = self.entries.values().collect();
2801        entries.sort_by_key(|entry| {
2802            (
2803                Reverse(entry.last_seen),
2804                node_id_sort_key(&entry.record.body.node_id),
2805            )
2806        });
2807        entries
2808            .into_iter()
2809            .take(limit)
2810            .map(|entry| entry.record.body.node_id)
2811            .collect()
2812    }
2813
2814    /// Returns entries suitable for a Zakura-specific persistent cache.
2815    pub fn persisted_entries(&self) -> Vec<ZakuraDiscoveryPersistedEntry> {
2816        let mut entries: Vec<_> = self
2817            .entries
2818            .values()
2819            .map(|entry| ZakuraDiscoveryPersistedEntry {
2820                record: entry.record.clone(),
2821                source: entry.source,
2822                is_static: entry.is_static,
2823                last_seen: entry.last_seen,
2824                last_dial_attempt: entry.last_dial_attempt,
2825                last_success: entry.last_success,
2826                failure_count: entry.failure_count,
2827            })
2828            .collect();
2829        entries.sort_by_key(|entry| node_id_sort_key(&entry.record.body.node_id));
2830        entries
2831    }
2832
2833    /// Re-validates and imports entries loaded from a persistent cache.
2834    pub fn import_persisted_entries(
2835        &mut self,
2836        entries: impl IntoIterator<Item = ZakuraDiscoveryPersistedEntry>,
2837        now: u64,
2838        context: &DiscoveryRecordValidationContext,
2839    ) -> ImportBatchOutcome {
2840        let mut outcome = ImportBatchOutcome::default();
2841        for entry in entries {
2842            outcome.attempted += 1;
2843            match self.import_persisted_entry(entry, now, context) {
2844                Ok(import_outcome) => outcome.record_success(import_outcome),
2845                Err(_) => outcome.rejected += 1,
2846            }
2847        }
2848        outcome
2849    }
2850
2851    fn import_persisted_entry(
2852        &mut self,
2853        entry: ZakuraDiscoveryPersistedEntry,
2854        now: u64,
2855        context: &DiscoveryRecordValidationContext,
2856    ) -> Result<ImportOutcome, DiscoveryBookError> {
2857        let metadata = DiscoveryEntryMetadata {
2858            source: entry.source,
2859            is_static: entry.is_static,
2860            last_seen: entry.last_seen,
2861            last_dial_attempt: entry.last_dial_attempt,
2862            last_success: entry.last_success,
2863            failure_count: entry.failure_count,
2864        };
2865        self.import_validated_record(entry.record, metadata, false, now, context)
2866    }
2867
2868    fn import_record_inner(
2869        &mut self,
2870        record: ZakuraNodeRecord,
2871        source: Option<NodeId>,
2872        is_static: bool,
2873        pre_verified: bool,
2874        now: u64,
2875        context: &DiscoveryRecordValidationContext,
2876    ) -> Result<ImportOutcome, DiscoveryBookError> {
2877        let metadata = DiscoveryEntryMetadata {
2878            source,
2879            is_static,
2880            last_seen: now,
2881            last_dial_attempt: None,
2882            last_success: None,
2883            failure_count: 0,
2884        };
2885        self.import_validated_record(record, metadata, pre_verified, now, context)
2886    }
2887
2888    /// Validates and stores one record.
2889    ///
2890    /// When `pre_verified` is set the signature/import-context check has already been run
2891    /// outside the global discovery mutex by [`ZakuraDiscoveryHandle::import_peer_records`],
2892    /// so it is not repeated here; the cheap storage-limit, address-policy, insertion, and
2893    /// eviction steps still run under the caller's lock.
2894    fn import_validated_record(
2895        &mut self,
2896        record: ZakuraNodeRecord,
2897        metadata: DiscoveryEntryMetadata,
2898        pre_verified: bool,
2899        now: u64,
2900        context: &DiscoveryRecordValidationContext,
2901    ) -> Result<ImportOutcome, DiscoveryBookError> {
2902        if self.local_node_id == Some(record.body.node_id) {
2903            return Err(DiscoveryBookError::SelfRecord);
2904        }
2905
2906        if !pre_verified {
2907            record.verify(context)?;
2908        }
2909        self.validate_record_storage_limits(&record)?;
2910        let direct_addr_policy = if metadata.is_static {
2911            DiscoveryDirectAddrPolicy::StaticConfigured
2912        } else {
2913            DiscoveryDirectAddrPolicy::UntrustedPeer
2914        };
2915        validate_discovery_direct_addrs(&record, direct_addr_policy)?;
2916
2917        let node_id = record.body.node_id;
2918        if let Some(entry) = self.entries.get_mut(&node_id) {
2919            return Ok(update_existing_entry(entry, record, metadata));
2920        }
2921
2922        self.entries.insert(
2923            node_id,
2924            ZakuraDiscoveryEntry {
2925                record,
2926                source: metadata.source,
2927                is_static: metadata.is_static,
2928                last_seen: metadata.last_seen,
2929                last_dial_attempt: metadata.last_dial_attempt,
2930                last_success: metadata.last_success,
2931                last_short_lived_exchange: None,
2932                failure_count: metadata.failure_count,
2933            },
2934        );
2935        self.evict_to_limits(now);
2936        Ok(ImportOutcome::Added)
2937    }
2938
2939    fn validate_record_storage_limits(
2940        &self,
2941        record: &ZakuraNodeRecord,
2942    ) -> Result<(), DiscoveryBookError> {
2943        if record.body.direct_addrs.len() > self.limits.max_direct_addrs_per_record {
2944            return Err(DiscoveryBookError::Limit {
2945                field: "direct address",
2946                actual: record.body.direct_addrs.len(),
2947                max: self.limits.max_direct_addrs_per_record,
2948            });
2949        }
2950        if record.body.services.len() > self.limits.max_services_per_record {
2951            return Err(DiscoveryBookError::Limit {
2952                field: "service",
2953                actual: record.body.services.len(),
2954                max: self.limits.max_services_per_record,
2955            });
2956        }
2957
2958        let mut encoded = Vec::new();
2959        encode_record(record, &mut encoded).map_err(DiscoveryRecordError::from)?;
2960        if encoded.len() > self.limits.max_encoded_record_bytes {
2961            return Err(DiscoveryBookError::Limit {
2962                field: "encoded record byte",
2963                actual: encoded.len(),
2964                max: self.limits.max_encoded_record_bytes,
2965            });
2966        }
2967
2968        Ok(())
2969    }
2970
2971    fn evict_to_limits(&mut self, now: u64) {
2972        while self.discovered_len() > self.limits.max_records {
2973            let Some(node_id) = self.next_eviction_candidate(now) else {
2974                break;
2975            };
2976            self.entries.remove(&node_id);
2977        }
2978    }
2979
2980    fn next_eviction_candidate(&self, now: u64) -> Option<NodeId> {
2981        self.entries
2982            .iter()
2983            .filter(|(_, entry)| !entry.is_static && entry_is_expired(entry, now))
2984            .min_by_key(|(node_id, entry)| {
2985                (
2986                    entry.record.body.expires_at_unix_secs,
2987                    node_id_sort_key(node_id),
2988                )
2989            })
2990            .map(|(node_id, _)| *node_id)
2991            .or_else(|| {
2992                self.entries
2993                    .iter()
2994                    .filter(|(_, entry)| {
2995                        !entry.is_static
2996                            && entry.last_success.is_none()
2997                            && entry.failure_count >= HIGH_DIAL_FAILURE_COUNT
2998                    })
2999                    .max_by_key(|(node_id, entry)| {
3000                        (
3001                            entry.failure_count,
3002                            Reverse(entry.last_seen),
3003                            Reverse(node_id_sort_key(node_id)),
3004                        )
3005                    })
3006                    .map(|(node_id, _)| *node_id)
3007            })
3008            .or_else(|| {
3009                self.entries
3010                    .iter()
3011                    .filter(|(_, entry)| !entry.is_static)
3012                    .min_by_key(|(node_id, entry)| {
3013                        (
3014                            entry.last_success.unwrap_or(0),
3015                            entry.last_seen,
3016                            node_id_sort_key(node_id),
3017                        )
3018                    })
3019                    .map(|(node_id, _)| *node_id)
3020            })
3021    }
3022}
3023
3024#[derive(Copy, Clone, Debug, Eq, PartialEq)]
3025struct DiscoveryEntryMetadata {
3026    source: Option<NodeId>,
3027    is_static: bool,
3028    last_seen: u64,
3029    last_dial_attempt: Option<u64>,
3030    last_success: Option<u64>,
3031    failure_count: u32,
3032}
3033
3034#[derive(Clone, Debug, Eq, PartialEq)]
3035struct ZakuraStaticDiscoveryCandidate {
3036    node_id: NodeId,
3037    direct_addrs: Vec<SocketAddr>,
3038    last_seen: u64,
3039    last_dial_attempt: Option<u64>,
3040    last_success: Option<u64>,
3041    last_short_lived_exchange: Option<u64>,
3042    failure_count: u32,
3043}
3044
3045#[derive(Copy, Clone, Debug, Eq, PartialEq)]
3046pub(crate) struct DialCandidateExclusions<'a> {
3047    connected_node_ids: &'a [NodeId],
3048    in_flight_node_ids: &'a [NodeId],
3049}
3050
3051#[derive(Copy, Clone, Debug, Eq, PartialEq)]
3052enum DiscoveryDirectAddrPolicy {
3053    UntrustedPeer,
3054    StaticConfigured,
3055}
3056
3057enum DialCandidateRef<'a> {
3058    SignedRecord(&'a ZakuraDiscoveryEntry),
3059    StaticConfigured(&'a ZakuraStaticDiscoveryCandidate),
3060}
3061
3062impl DialCandidateRef<'_> {
3063    fn node_id(&self) -> NodeId {
3064        match self {
3065            Self::SignedRecord(entry) => entry.record.body.node_id,
3066            Self::StaticConfigured(candidate) => candidate.node_id,
3067        }
3068    }
3069
3070    fn is_static(&self) -> bool {
3071        match self {
3072            Self::SignedRecord(entry) => entry.is_static,
3073            Self::StaticConfigured(_) => true,
3074        }
3075    }
3076
3077    fn last_seen(&self) -> u64 {
3078        match self {
3079            Self::SignedRecord(entry) => entry.last_seen,
3080            Self::StaticConfigured(candidate) => candidate.last_seen,
3081        }
3082    }
3083
3084    fn last_success(&self) -> Option<u64> {
3085        match self {
3086            Self::SignedRecord(entry) => entry.last_success,
3087            Self::StaticConfigured(candidate) => candidate.last_success,
3088        }
3089    }
3090
3091    fn failure_count(&self) -> u32 {
3092        match self {
3093            Self::SignedRecord(entry) => entry.failure_count,
3094            Self::StaticConfigured(candidate) => candidate.failure_count,
3095        }
3096    }
3097
3098    fn into_candidate(self) -> ZakuraDiscoveryDialCandidate {
3099        match self {
3100            Self::SignedRecord(entry) => ZakuraDiscoveryDialCandidate {
3101                node_id: entry.record.body.node_id,
3102                direct_addrs: entry.record.body.direct_addrs.clone(),
3103                is_static: entry.is_static,
3104            },
3105            Self::StaticConfigured(candidate) => ZakuraDiscoveryDialCandidate {
3106                node_id: candidate.node_id,
3107                direct_addrs: candidate.direct_addrs.clone(),
3108                is_static: true,
3109            },
3110        }
3111    }
3112}
3113
3114/// Total dial-priority key for one candidate: prefer signed records over static ones, then most
3115/// recent success, fewest failures, most recently seen, with a per-call random tie-break for
3116/// non-static candidates and a deterministic node-id tie-break for static ones.
3117type DialCandidateSortKey = (
3118    bool,
3119    Reverse<u64>,
3120    u32,
3121    Reverse<u64>,
3122    u64,
3123    [u8; NODE_ID_BYTES],
3124);
3125
3126/// Computes the dial-priority key used to select the best dial candidates.
3127///
3128/// Factored out of [`ZakuraDiscoveryBook::dial_candidates`] so the key can be computed once per
3129/// candidate and fed to a bounded top-k selection instead of a full sort, which keeps expensive
3130/// per-book ordering work bounded under the global discovery mutex (finding
3131/// `claude-discovery-expensive-work-under-global-mutex`).
3132fn dial_candidate_sort_key<R: rand::Rng + ?Sized>(
3133    candidate: &DialCandidateRef<'_>,
3134    rng: &mut R,
3135) -> DialCandidateSortKey {
3136    let non_static_random_tie = if !candidate.is_static() {
3137        rng.gen::<u64>()
3138    } else {
3139        0
3140    };
3141    let static_deterministic_tie = if candidate.is_static() {
3142        node_id_sort_key(&candidate.node_id())
3143    } else {
3144        [0; NODE_ID_BYTES]
3145    };
3146    (
3147        !candidate.is_static(),
3148        Reverse(candidate.last_success().unwrap_or(0)),
3149        candidate.failure_count(),
3150        Reverse(candidate.last_seen()),
3151        non_static_random_tie,
3152        static_deterministic_tie,
3153    )
3154}
3155
3156fn update_existing_entry(
3157    entry: &mut ZakuraDiscoveryEntry,
3158    record: ZakuraNodeRecord,
3159    metadata: DiscoveryEntryMetadata,
3160) -> ImportOutcome {
3161    let incoming_sequence = record.body.sequence;
3162    let stored_sequence = entry.record.body.sequence;
3163
3164    if incoming_sequence < stored_sequence {
3165        return ImportOutcome::IgnoredOlder;
3166    }
3167
3168    let preserve_first_party_source = incoming_sequence == stored_sequence
3169        && entry.source == Some(entry.record.body.node_id)
3170        && metadata.source != Some(entry.record.body.node_id);
3171
3172    if !preserve_first_party_source {
3173        entry.source = metadata.source;
3174    }
3175    entry.is_static |= metadata.is_static;
3176    entry.last_seen = metadata.last_seen;
3177
3178    if incoming_sequence == stored_sequence {
3179        return ImportOutcome::MetadataUpdated;
3180    }
3181
3182    entry.record = record;
3183    ImportOutcome::Updated
3184}
3185
3186fn connected_record_is_fresh_for_stored_record(
3187    record: &ZakuraNodeRecord,
3188    entry: &ZakuraDiscoveryEntry,
3189) -> bool {
3190    match record.body.sequence.cmp(&entry.record.body.sequence) {
3191        std::cmp::Ordering::Less => false,
3192        std::cmp::Ordering::Equal => entry.record() == record,
3193        std::cmp::Ordering::Greater => true,
3194    }
3195}
3196
3197fn is_direct_address_import_error(error: &DiscoveryBookError) -> bool {
3198    matches!(
3199        error,
3200        DiscoveryBookError::NoUsableDirectAddress
3201            | DiscoveryBookError::NonDialableDirectAddress { .. }
3202    )
3203}
3204
3205fn current_unix_secs() -> u64 {
3206    SystemTime::now()
3207        .duration_since(UNIX_EPOCH)
3208        .map(|duration| duration.as_secs())
3209        .unwrap_or(0)
3210}
3211
3212/// Interim wall-clock-nanosecond self-record sequence seed.
3213///
3214/// This strictly increases across a same-key restart only while the system clock is monotonic. A
3215/// backward clock step can still produce a non-increasing sequence; the durable fix is to persist
3216/// and reload `last_authored_sequence` in the cache-persistence task.
3217fn current_sequence_tick() -> u64 {
3218    SystemTime::now()
3219        .duration_since(UNIX_EPOCH)
3220        .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX))
3221        .unwrap_or(0)
3222}
3223
3224fn normalize_direct_addrs(addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
3225    let mut addrs: Vec<_> = addrs
3226        .into_iter()
3227        .filter(is_discovery_dialable_addr)
3228        .collect();
3229    addrs.sort_unstable();
3230    addrs.dedup();
3231    addrs.truncate(MAX_DIRECT_ADDRS_PER_RECORD);
3232    addrs
3233}
3234
3235fn normalize_services(services: Vec<ZakuraServiceId>) -> Vec<ZakuraServiceId> {
3236    let mut services = services;
3237    services.sort_unstable();
3238    services.dedup();
3239    services.truncate(MAX_SERVICES_PER_RECORD);
3240    services
3241}
3242
3243fn push_unique_service(services: &mut Vec<ZakuraServiceId>, service: ZakuraServiceId) {
3244    if services.iter().any(|existing| existing == &service) {
3245        return;
3246    }
3247    if services.len() < MAX_SERVICES_PER_RECORD {
3248        services.push(service);
3249        services.sort_unstable();
3250    }
3251}
3252
3253fn effective_live_summary_expiry(declared_expires_at: u64, now_unix_secs: u64) -> Option<u64> {
3254    if declared_expires_at <= now_unix_secs {
3255        return None;
3256    }
3257    Some(
3258        declared_expires_at
3259            .min(now_unix_secs.saturating_add(DEFAULT_LIVE_SERVICE_SUMMARY_TTL.as_secs())),
3260    )
3261}
3262
3263fn header_sync_candidate_preference(
3264    summary: Option<HeaderSyncServiceSummary>,
3265    target_height: block::Height,
3266) -> u32 {
3267    let Some(summary) = summary else {
3268        return 0;
3269    };
3270    if !summary.serving_headers
3271        || summary.inbound_slots_free == 0
3272        || summary.best_height < target_height
3273    {
3274        return 0;
3275    }
3276
3277    let useful_height_delta = summary.best_height.0.saturating_sub(target_height.0);
3278    1_000_000u32
3279        .saturating_add(u32::from(summary.inbound_slots_free))
3280        .saturating_add(u32::from(summary.outbound_slots_free))
3281        .saturating_add(useful_height_delta)
3282}
3283
3284fn block_sync_candidate_preference(
3285    summary: Option<BlockSyncServiceSummary>,
3286    missing_block_bodies: &[block::Height],
3287) -> u32 {
3288    let Some(summary) = summary else {
3289        return 0;
3290    };
3291
3292    let coverage_bonus = match summary.gap_coverage(missing_block_bodies) {
3293        BlockSyncGapCoverage::Whole => 1_000_000u32,
3294        BlockSyncGapCoverage::LowPrefix => 500_000u32,
3295        BlockSyncGapCoverage::None => return 0,
3296    };
3297
3298    coverage_bonus
3299        .saturating_add(u32::from(summary.free_slots))
3300        .saturating_add(
3301            summary
3302                .max_blocks_per_response
3303                .min(MAX_BS_BLOCKS_PER_REQUEST),
3304        )
3305}
3306
3307fn bounded_services(
3308    services: &[ZakuraServiceId],
3309    limits: ZakuraDiscoveryBookLimits,
3310) -> Vec<ZakuraServiceId> {
3311    services
3312        .iter()
3313        .take(limits.max_services_per_record)
3314        .cloned()
3315        .collect()
3316}
3317
3318fn bounded_node_ids(node_ids: &[NodeId], max_node_ids: usize) -> Vec<NodeId> {
3319    node_ids.iter().take(max_node_ids).copied().collect()
3320}
3321
3322fn push_excluded_node_id(excluded: &mut Vec<NodeId>, node_id: NodeId, max_node_ids: usize) {
3323    if excluded.len() < max_node_ids && !excluded.contains(&node_id) {
3324        excluded.push(node_id);
3325    }
3326}
3327
3328fn connected_peer_node_ids(connected: &[ZakuraPeerId]) -> Vec<NodeId> {
3329    connected
3330        .iter()
3331        .filter_map(|peer_id| {
3332            let bytes: [u8; NODE_ID_BYTES] = peer_id.as_bytes().try_into().ok()?;
3333            NodeId::from_bytes(&bytes).ok()
3334        })
3335        .collect()
3336}
3337
3338fn discovery_dial_slot_limit(
3339    connected_count: usize,
3340    in_flight_count: usize,
3341    max_connections: usize,
3342    connection_headroom: usize,
3343    max_concurrent_dials: usize,
3344) -> usize {
3345    let soft_cap = max_connections.saturating_sub(connection_headroom);
3346    let available_connection_slots = soft_cap.saturating_sub(connected_count);
3347    let available_dial_slots = max_concurrent_dials.saturating_sub(in_flight_count);
3348    available_connection_slots.min(available_dial_slots)
3349}
3350
3351fn has_wanted_services(record: &ZakuraNodeRecord, wanted_services: &[ZakuraServiceId]) -> bool {
3352    wanted_services
3353        .iter()
3354        .all(|wanted| record.body.services.iter().any(|service| service == wanted))
3355}
3356
3357/// Validates direct addresses for discovery-book storage.
3358///
3359/// Untrusted peer/gossip records must only contain globally routable addresses, preserving the
3360/// discovery security rule that gossiped records cannot inject loopback, link-local, multicast,
3361/// broadcast, RFC 1918 private, RFC 6598 shared (CGNAT), or RFC 4193 unique-local targets. Static
3362/// records are trusted-by-configuration bootstrap records, so they may use
3363/// local addresses for regtest and single-host deployments, but still reject empty, unspecified, and
3364/// port-0 targets that cannot be dialed as configured peers.
3365fn validate_discovery_direct_addrs(
3366    record: &ZakuraNodeRecord,
3367    policy: DiscoveryDirectAddrPolicy,
3368) -> Result<(), DiscoveryBookError> {
3369    if record.body.direct_addrs.is_empty() {
3370        return Err(DiscoveryBookError::NoUsableDirectAddress);
3371    }
3372
3373    for addr in &record.body.direct_addrs {
3374        let is_valid = match policy {
3375            DiscoveryDirectAddrPolicy::UntrustedPeer => is_discovery_dialable_addr(addr),
3376            DiscoveryDirectAddrPolicy::StaticConfigured => {
3377                is_static_discovery_configured_addr_usable(addr)
3378            }
3379        };
3380        if !is_valid {
3381            return Err(DiscoveryBookError::NonDialableDirectAddress { addr: *addr });
3382        }
3383    }
3384
3385    Ok(())
3386}
3387
3388fn has_discovery_dialable_direct_addrs(record: &ZakuraNodeRecord) -> bool {
3389    !record.body.direct_addrs.is_empty()
3390        && record
3391            .body
3392            .direct_addrs
3393            .iter()
3394            .all(is_discovery_dialable_addr)
3395}
3396
3397fn has_discovery_usable_direct_addrs(entry: &ZakuraDiscoveryEntry) -> bool {
3398    !entry.record.body.direct_addrs.is_empty()
3399        && entry.record.body.direct_addrs.iter().all(|addr| {
3400            if entry.is_static {
3401                is_static_discovery_configured_addr_usable(addr)
3402            } else {
3403                is_discovery_dialable_addr(addr)
3404            }
3405        })
3406}
3407
3408fn entry_has_confirmed_dial_authority(entry: &ZakuraDiscoveryEntry) -> bool {
3409    entry.is_static || entry.source == Some(entry.record.body.node_id)
3410}
3411
3412/// Returns true only for addresses that are safe to dial from untrusted discovery gossip.
3413///
3414/// A signed `ZakuraNodeRecord` proves control of the node key, not ownership of the advertised
3415/// `direct_addrs`. To stop an authenticated discovery peer from steering the candidate dialer at
3416/// arbitrary non-public targets, gossiped records must advertise only globally routable addresses.
3417/// Besides the obvious loopback/link-local/multicast/broadcast cases, this rejects RFC 1918 private,
3418/// RFC 6598 shared (CGNAT), and RFC 4193 unique-local ranges, which are reachable internal targets
3419/// the dialer would otherwise scan on the operator's network.
3420fn is_discovery_dialable_addr(addr: &SocketAddr) -> bool {
3421    if addr.port() == 0 {
3422        return false;
3423    }
3424
3425    match addr.ip() {
3426        IpAddr::V4(ip) => {
3427            !ip.is_unspecified()
3428                && !ip.is_loopback()
3429                && !ip.is_multicast()
3430                && !ip.is_broadcast()
3431                && !ip.is_link_local()
3432                && !ip.is_private()
3433                && !is_ipv4_shared(&ip)
3434        }
3435        IpAddr::V6(ip) => {
3436            !ip.is_unspecified()
3437                && !ip.is_loopback()
3438                && !ip.is_multicast()
3439                && !is_ipv6_unicast_link_local(&ip)
3440                && !is_ipv6_unique_local(&ip)
3441        }
3442    }
3443}
3444
3445fn is_static_discovery_configured_addr_usable(addr: &SocketAddr) -> bool {
3446    if addr.port() == 0 {
3447        return false;
3448    }
3449
3450    match addr.ip() {
3451        IpAddr::V4(ip) => !ip.is_unspecified() && !ip.is_multicast() && !ip.is_broadcast(),
3452        IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_multicast(),
3453    }
3454}
3455
3456fn is_ipv6_unicast_link_local(ip: &Ipv6Addr) -> bool {
3457    (ip.segments()[0] & 0xffc0) == 0xfe80
3458}
3459
3460// `Ipv4Addr::is_shared` is still unstable, so match the RFC 6598 100.64.0.0/10 range directly,
3461// mirroring `is_ipv6_unicast_link_local`.
3462fn is_ipv4_shared(ip: &Ipv4Addr) -> bool {
3463    let octets = ip.octets();
3464    octets[0] == 100 && (octets[1] & 0b1100_0000) == 0b0100_0000
3465}
3466
3467// `Ipv6Addr::is_unique_local` is still unstable, so match the RFC 4193 fc00::/7 range directly.
3468fn is_ipv6_unique_local(ip: &Ipv6Addr) -> bool {
3469    (ip.segments()[0] & 0xfe00) == 0xfc00
3470}
3471
3472// Import accepts records inside the clock-skew window, but runtime liveness is strict.
3473fn entry_is_expired(entry: &ZakuraDiscoveryEntry, now: u64) -> bool {
3474    entry.record.body.expires_at_unix_secs < now
3475}
3476
3477fn entry_in_dial_backoff(
3478    entry: &ZakuraDiscoveryEntry,
3479    now: u64,
3480    dial_backoff_base: Duration,
3481    dial_backoff_max: Duration,
3482) -> bool {
3483    entry_metadata_in_dial_backoff(
3484        entry.last_dial_attempt,
3485        entry.failure_count,
3486        now,
3487        dial_backoff_base,
3488        dial_backoff_max,
3489    )
3490}
3491
3492fn entry_metadata_in_dial_backoff(
3493    last_dial_attempt: Option<u64>,
3494    failure_count: u32,
3495    now: u64,
3496    dial_backoff_base: Duration,
3497    dial_backoff_max: Duration,
3498) -> bool {
3499    let Some(last_dial_attempt) = last_dial_attempt else {
3500        return false;
3501    };
3502    let backoff = dial_backoff_secs(
3503        failure_count,
3504        dial_backoff_base.as_secs(),
3505        dial_backoff_max.as_secs(),
3506    );
3507    now < last_dial_attempt.saturating_add(backoff)
3508}
3509
3510fn entry_in_short_lived_exchange_backoff(
3511    entry: &ZakuraDiscoveryEntry,
3512    now: u64,
3513    dial_backoff_base: Duration,
3514    dial_backoff_max: Duration,
3515) -> bool {
3516    entry_metadata_in_short_lived_exchange_backoff(
3517        entry.last_short_lived_exchange,
3518        now,
3519        dial_backoff_base,
3520        dial_backoff_max,
3521    )
3522}
3523
3524fn entry_metadata_in_short_lived_exchange_backoff(
3525    last_short_lived_exchange: Option<u64>,
3526    now: u64,
3527    dial_backoff_base: Duration,
3528    dial_backoff_max: Duration,
3529) -> bool {
3530    let Some(last_short_lived_exchange) = last_short_lived_exchange else {
3531        return false;
3532    };
3533    let backoff = dial_backoff_secs(1, dial_backoff_base.as_secs(), dial_backoff_max.as_secs());
3534    now < last_short_lived_exchange.saturating_add(backoff)
3535}
3536
3537fn dial_backoff_secs(failure_count: u32, base_secs: u64, max_secs: u64) -> u64 {
3538    if failure_count == 0 {
3539        return 0;
3540    }
3541
3542    let shift = failure_count.saturating_sub(1).min(10);
3543    base_secs.saturating_mul(1u64 << shift).min(max_secs)
3544}
3545
3546fn node_id_sort_key(node_id: &NodeId) -> [u8; NODE_ID_BYTES] {
3547    *node_id.as_bytes()
3548}
3549
3550fn validate_record_body_for_import(
3551    body: &ZakuraNodeRecordBody,
3552    context: &DiscoveryRecordValidationContext,
3553) -> Result<(), DiscoveryRecordError> {
3554    validate_record_body_bounds(body)?;
3555    if body.network_id != context.expected_network_id {
3556        return Err(DiscoveryRecordError::WrongNetwork);
3557    }
3558    if body.chain_id != context.expected_chain_id {
3559        return Err(DiscoveryRecordError::WrongChain);
3560    }
3561    if body.zakura_protocol_min > body.zakura_protocol_max
3562        || context.supported_protocol_min > context.supported_protocol_max
3563        || body.zakura_protocol_max < context.supported_protocol_min
3564        || context.supported_protocol_max < body.zakura_protocol_min
3565    {
3566        return Err(DiscoveryRecordError::IncompatibleProtocol);
3567    }
3568
3569    let skew = context.clock_skew_tolerance.as_secs();
3570    if body.expires_at_unix_secs.saturating_add(skew) < context.current_unix_secs {
3571        return Err(DiscoveryRecordError::Expired);
3572    }
3573
3574    let max_future = context
3575        .current_unix_secs
3576        .saturating_add(context.max_record_ttl.as_secs())
3577        .saturating_add(skew);
3578    if body.expires_at_unix_secs > max_future {
3579        return Err(DiscoveryRecordError::FarFutureExpiry);
3580    }
3581
3582    Ok(())
3583}
3584
3585fn validate_record_body_bounds(body: &ZakuraNodeRecordBody) -> Result<(), DiscoveryWireError> {
3586    if body.direct_addrs.len() > MAX_DIRECT_ADDRS_PER_RECORD {
3587        return Err(DiscoveryWireError::OversizedPayload {
3588            actual: body.direct_addrs.len(),
3589            max: MAX_DIRECT_ADDRS_PER_RECORD,
3590        });
3591    }
3592    if body.services.len() > MAX_SERVICES_PER_RECORD {
3593        return Err(DiscoveryWireError::OversizedPayload {
3594            actual: body.services.len(),
3595            max: MAX_SERVICES_PER_RECORD,
3596        });
3597    }
3598    for service in &body.services {
3599        validate_service_id(service.as_str().as_bytes())?;
3600    }
3601    if body.zakura_protocol_min > body.zakura_protocol_max {
3602        return Err(DiscoveryWireError::InvalidProtocolRange);
3603    }
3604    Ok(())
3605}
3606
3607fn validate_query_fields(
3608    limit: u16,
3609    wanted_services: &[ZakuraServiceId],
3610    exclude_node_ids: &[NodeId],
3611) -> Result<(), DiscoveryWireError> {
3612    if usize::from(limit) > MAX_DISCOVERY_RECORDS_PER_RESPONSE {
3613        return Err(DiscoveryWireError::OversizedPayload {
3614            actual: usize::from(limit),
3615            max: MAX_DISCOVERY_RECORDS_PER_RESPONSE,
3616        });
3617    }
3618    if wanted_services.len() > MAX_SERVICES_PER_RECORD {
3619        return Err(DiscoveryWireError::OversizedPayload {
3620            actual: wanted_services.len(),
3621            max: MAX_SERVICES_PER_RECORD,
3622        });
3623    }
3624    if exclude_node_ids.len() > MAX_DISCOVERY_EXCLUDED_NODE_IDS {
3625        return Err(DiscoveryWireError::OversizedPayload {
3626            actual: exclude_node_ids.len(),
3627            max: MAX_DISCOVERY_EXCLUDED_NODE_IDS,
3628        });
3629    }
3630    for service in wanted_services {
3631        validate_service_id(service.as_str().as_bytes())?;
3632    }
3633    Ok(())
3634}
3635
3636fn validate_get_services(query: &GetServices) -> Result<(), DiscoveryWireError> {
3637    validate_service_ids(&query.wanted_services, MAX_GET_SERVICES_WANTED)
3638}
3639
3640fn validate_services(services: &Services) -> Result<(), DiscoveryWireError> {
3641    if services.summaries.len() > MAX_SERVICE_SUMMARIES_PER_RESPONSE {
3642        return Err(DiscoveryWireError::OversizedPayload {
3643            actual: services.summaries.len(),
3644            max: MAX_SERVICE_SUMMARIES_PER_RESPONSE,
3645        });
3646    }
3647    for summary in &services.summaries {
3648        validate_summary_envelope(summary)?;
3649    }
3650    Ok(())
3651}
3652
3653fn validate_summary_envelope(envelope: &ServiceSummaryEnvelope) -> Result<(), DiscoveryWireError> {
3654    validate_service_id(envelope.service_id.as_str().as_bytes())?;
3655    if envelope.summary_bytes.len() > MAX_SERVICE_SUMMARY_BYTES {
3656        return Err(DiscoveryWireError::OversizedPayload {
3657            actual: envelope.summary_bytes.len(),
3658            max: MAX_SERVICE_SUMMARY_BYTES,
3659        });
3660    }
3661    validate_summary_service_binding(envelope.summary_tag, &envelope.service_id)?;
3662    validate_known_summary_payload(envelope.summary_tag, &envelope.summary_bytes)?;
3663    Ok(())
3664}
3665
3666fn validate_summary_service_binding(
3667    summary_tag: u16,
3668    service_id: &ZakuraServiceId,
3669) -> Result<(), DiscoveryWireError> {
3670    let expected = match summary_tag {
3671        SUMMARY_TAG_HEADER_SYNC => Some(ZakuraServiceId::header_sync()),
3672        SUMMARY_TAG_HEADER_SYNC_RETIRED => Some(ZakuraServiceId::header_sync_retired()),
3673        SUMMARY_TAG_DISCOVERY_V1 => Some(ZakuraServiceId::discovery()),
3674        SUMMARY_TAG_BLOCK_SYNC_V1 => Some(ZakuraServiceId::block_sync()),
3675        _ => None,
3676    };
3677
3678    if expected
3679        .as_ref()
3680        .is_some_and(|expected| expected != service_id)
3681    {
3682        return Err(DiscoveryWireError::MismatchedSummaryService {
3683            summary_tag,
3684            service_id: service_id.clone(),
3685        });
3686    }
3687
3688    Ok(())
3689}
3690
3691fn validate_known_summary_payload(
3692    summary_tag: u16,
3693    summary_bytes: &[u8],
3694) -> Result<(), DiscoveryWireError> {
3695    match summary_tag {
3696        SUMMARY_TAG_HEADER_SYNC | SUMMARY_TAG_HEADER_SYNC_RETIRED => {
3697            decode_header_sync_summary(summary_bytes)?;
3698        }
3699        SUMMARY_TAG_DISCOVERY_V1 => {
3700            decode_discovery_summary(summary_bytes)?;
3701        }
3702        SUMMARY_TAG_BLOCK_SYNC_V1 => {
3703            decode_block_sync_summary(summary_bytes)?;
3704        }
3705        _ => {}
3706    }
3707    Ok(())
3708}
3709
3710fn validate_service_id(bytes: &[u8]) -> Result<(), DiscoveryWireError> {
3711    if bytes.is_empty() {
3712        return Err(DiscoveryWireError::Empty("service id"));
3713    }
3714    if bytes.len() > MAX_ZAKURA_SERVICE_ID_BYTES {
3715        return Err(DiscoveryWireError::OversizedPayload {
3716            actual: bytes.len(),
3717            max: MAX_ZAKURA_SERVICE_ID_BYTES,
3718        });
3719    }
3720    if !bytes.is_ascii() {
3721        return Err(DiscoveryWireError::NonAsciiServiceId);
3722    }
3723    Ok(())
3724}
3725
3726fn validate_service_ids(
3727    services: &[ZakuraServiceId],
3728    max_count: usize,
3729) -> Result<(), DiscoveryWireError> {
3730    if services.len() > max_count {
3731        return Err(DiscoveryWireError::OversizedPayload {
3732            actual: services.len(),
3733            max: max_count,
3734        });
3735    }
3736    for service in services {
3737        validate_service_id(service.as_str().as_bytes())?;
3738    }
3739    Ok(())
3740}
3741
3742fn encode_record(
3743    record: &ZakuraNodeRecord,
3744    writer: &mut impl Write,
3745) -> Result<(), DiscoveryWireError> {
3746    validate_record_body_bounds(&record.body)?;
3747    let mut body_bytes = Vec::new();
3748    encode_record_body_to(&record.body, &mut body_bytes)?;
3749    if body_bytes.len() > MAX_NODE_RECORD_BODY_BYTES {
3750        return Err(DiscoveryWireError::OversizedPayload {
3751            actual: body_bytes.len(),
3752            max: MAX_NODE_RECORD_BODY_BYTES,
3753        });
3754    }
3755    writer.write_u32::<LittleEndian>(u32_from_usize(body_bytes.len(), "record body length")?)?;
3756    writer.write_all(&body_bytes)?;
3757    writer.write_all(&record.signature.to_bytes())?;
3758    Ok(())
3759}
3760
3761fn decode_record(reader: &mut impl Read) -> Result<ZakuraNodeRecord, DiscoveryWireError> {
3762    let body_len = usize_from_u32(reader.read_u32::<LittleEndian>()?, "record body length")?;
3763    if body_len > MAX_NODE_RECORD_BODY_BYTES {
3764        return Err(DiscoveryWireError::OversizedPayload {
3765            actual: body_len,
3766            max: MAX_NODE_RECORD_BODY_BYTES,
3767        });
3768    }
3769
3770    let body_bytes = read_exact_vec(reader, body_len)?;
3771    let body = decode_record_body(&body_bytes)?;
3772
3773    let mut signature_bytes = [0u8; SIGNATURE_BYTES];
3774    reader.read_exact(&mut signature_bytes)?;
3775    let signature = Signature::from(signature_bytes);
3776
3777    Ok(ZakuraNodeRecord { body, signature })
3778}
3779
3780fn encode_records_message(
3781    message_type: u8,
3782    records: &[ZakuraNodeRecord],
3783    max_count: usize,
3784    writer: &mut impl Write,
3785) -> Result<(), DiscoveryWireError> {
3786    if records.len() > max_count {
3787        return Err(DiscoveryWireError::OversizedPayload {
3788            actual: records.len(),
3789            max: max_count,
3790        });
3791    }
3792    writer.write_u8(message_type)?;
3793    writer.write_u16::<LittleEndian>(u16_from_usize(records.len(), "record count")?)?;
3794    for record in records {
3795        encode_record(record, writer)?;
3796    }
3797    Ok(())
3798}
3799
3800fn decode_record_list(
3801    reader: &mut impl Read,
3802    max_count: usize,
3803) -> Result<Vec<ZakuraNodeRecord>, DiscoveryWireError> {
3804    let count = usize::from(reader.read_u16::<LittleEndian>()?);
3805    if count > max_count {
3806        return Err(DiscoveryWireError::OversizedPayload {
3807            actual: count,
3808            max: max_count,
3809        });
3810    }
3811    let mut records = Vec::with_capacity(count);
3812    for _ in 0..count {
3813        records.push(decode_record(reader)?);
3814    }
3815    Ok(records)
3816}
3817
3818fn encode_query_fields(
3819    limit: u16,
3820    wanted_services: &[ZakuraServiceId],
3821    exclude_node_ids: &[NodeId],
3822    writer: &mut impl Write,
3823) -> Result<(), DiscoveryWireError> {
3824    writer.write_u16::<LittleEndian>(limit)?;
3825    encode_service_ids(wanted_services, writer)?;
3826    encode_node_ids(exclude_node_ids, writer)?;
3827    Ok(())
3828}
3829
3830fn decode_query_fields(
3831    reader: &mut impl Read,
3832) -> Result<(u16, Vec<ZakuraServiceId>, Vec<NodeId>), DiscoveryWireError> {
3833    let limit = reader.read_u16::<LittleEndian>()?;
3834    let wanted_services = decode_service_ids(reader, MAX_SERVICES_PER_RECORD)?;
3835    let exclude_node_ids = decode_node_ids(reader, MAX_DISCOVERY_EXCLUDED_NODE_IDS)?;
3836    validate_query_fields(limit, &wanted_services, &exclude_node_ids)?;
3837    Ok((limit, wanted_services, exclude_node_ids))
3838}
3839
3840fn decode_get_services(reader: &mut impl Read) -> Result<GetServices, DiscoveryWireError> {
3841    let wanted_services = decode_service_ids(reader, MAX_GET_SERVICES_WANTED)?;
3842    Ok(GetServices { wanted_services })
3843}
3844
3845fn encode_services_message(
3846    services: &Services,
3847    writer: &mut impl Write,
3848) -> Result<(), DiscoveryWireError> {
3849    writer.write_all(services.node_id.as_bytes())?;
3850    writer.write_u64::<LittleEndian>(services.expires_at_unix_secs)?;
3851    writer.write_u16::<LittleEndian>(u16_from_usize(
3852        services.summaries.len(),
3853        "service summary count",
3854    )?)?;
3855    for summary in &services.summaries {
3856        encode_summary_envelope(summary, writer)?;
3857    }
3858    Ok(())
3859}
3860
3861fn decode_services_message(reader: &mut Cursor<&[u8]>) -> Result<Services, DiscoveryWireError> {
3862    let node_id = decode_node_id(reader)?;
3863    let expires_at_unix_secs = reader.read_u64::<LittleEndian>()?;
3864    let count = usize::from(reader.read_u16::<LittleEndian>()?);
3865    if count > MAX_SERVICE_SUMMARIES_PER_RESPONSE {
3866        return Err(DiscoveryWireError::OversizedPayload {
3867            actual: count,
3868            max: MAX_SERVICE_SUMMARIES_PER_RESPONSE,
3869        });
3870    }
3871
3872    let mut summaries = Vec::with_capacity(count);
3873    for _ in 0..count {
3874        summaries.push(decode_summary_envelope(reader)?);
3875    }
3876
3877    let services = Services {
3878        node_id,
3879        expires_at_unix_secs,
3880        summaries,
3881    };
3882    validate_services(&services)?;
3883    Ok(services)
3884}
3885
3886fn encode_summary_envelope(
3887    envelope: &ServiceSummaryEnvelope,
3888    writer: &mut impl Write,
3889) -> Result<(), DiscoveryWireError> {
3890    validate_summary_envelope(envelope)?;
3891    encode_service_id(&envelope.service_id, writer)?;
3892    writer.write_u16::<LittleEndian>(envelope.summary_tag)?;
3893    writer.write_u16::<LittleEndian>(u16_from_usize(
3894        envelope.summary_bytes.len(),
3895        "service summary length",
3896    )?)?;
3897    writer.write_all(&envelope.summary_bytes)?;
3898    Ok(())
3899}
3900
3901fn decode_summary_envelope(
3902    reader: &mut Cursor<&[u8]>,
3903) -> Result<ServiceSummaryEnvelope, DiscoveryWireError> {
3904    let service_id = decode_service_id(reader)?;
3905    let summary_tag = reader.read_u16::<LittleEndian>()?;
3906    let summary_len = usize::from(reader.read_u16::<LittleEndian>()?);
3907    if summary_len > MAX_SERVICE_SUMMARY_BYTES {
3908        return Err(DiscoveryWireError::OversizedPayload {
3909            actual: summary_len,
3910            max: MAX_SERVICE_SUMMARY_BYTES,
3911        });
3912    }
3913    let remaining = cursor_remaining(reader)?;
3914    if summary_len > remaining {
3915        return Err(DiscoveryWireError::TruncatedPayload {
3916            declared: summary_len,
3917            remaining,
3918        });
3919    }
3920    let summary_bytes = read_exact_vec(reader, summary_len)?;
3921    validate_known_summary_payload(summary_tag, &summary_bytes)?;
3922    Ok(ServiceSummaryEnvelope {
3923        service_id,
3924        summary_tag,
3925        summary_bytes,
3926    })
3927}
3928
3929fn encode_record_body_to(
3930    body: &ZakuraNodeRecordBody,
3931    writer: &mut impl Write,
3932) -> Result<(), DiscoveryWireError> {
3933    writer.write_all(body.node_id.as_bytes())?;
3934    encode_socket_addrs(&body.direct_addrs, writer)?;
3935    encode_service_ids(&body.services, writer)?;
3936    writer.write_u16::<LittleEndian>(body.zakura_protocol_min)?;
3937    writer.write_u16::<LittleEndian>(body.zakura_protocol_max)?;
3938    writer.write_u32::<LittleEndian>(body.network_id.code())?;
3939    writer.write_all(&body.chain_id)?;
3940    writer.write_u64::<LittleEndian>(body.sequence)?;
3941    writer.write_u64::<LittleEndian>(body.expires_at_unix_secs)?;
3942    Ok(())
3943}
3944
3945fn decode_record_body(bytes: &[u8]) -> Result<ZakuraNodeRecordBody, DiscoveryWireError> {
3946    if bytes.len() > MAX_NODE_RECORD_BODY_BYTES {
3947        return Err(DiscoveryWireError::OversizedPayload {
3948            actual: bytes.len(),
3949            max: MAX_NODE_RECORD_BODY_BYTES,
3950        });
3951    }
3952    let mut reader = Cursor::new(bytes);
3953    let mut node_id_bytes = [0u8; NODE_ID_BYTES];
3954    reader.read_exact(&mut node_id_bytes)?;
3955    let node_id =
3956        NodeId::from_bytes(&node_id_bytes).map_err(|_| DiscoveryWireError::InvalidNodeId)?;
3957    let direct_addrs = decode_socket_addrs(&mut reader)?;
3958    let services = decode_service_ids(&mut reader, MAX_SERVICES_PER_RECORD)?;
3959    let zakura_protocol_min = reader.read_u16::<LittleEndian>()?;
3960    let zakura_protocol_max = reader.read_u16::<LittleEndian>()?;
3961    let network_id = network_id_from_code(reader.read_u32::<LittleEndian>()?)?;
3962    let mut chain_id = [0u8; 32];
3963    reader.read_exact(&mut chain_id)?;
3964    let sequence = reader.read_u64::<LittleEndian>()?;
3965    let expires_at_unix_secs = reader.read_u64::<LittleEndian>()?;
3966    reject_trailing(bytes, &reader)?;
3967
3968    let body = ZakuraNodeRecordBody {
3969        node_id,
3970        direct_addrs,
3971        services,
3972        zakura_protocol_min,
3973        zakura_protocol_max,
3974        network_id,
3975        chain_id,
3976        sequence,
3977        expires_at_unix_secs,
3978    };
3979    validate_record_body_bounds(&body)?;
3980    Ok(body)
3981}
3982
3983fn encode_socket_addrs(
3984    addrs: &[SocketAddr],
3985    writer: &mut impl Write,
3986) -> Result<(), DiscoveryWireError> {
3987    if addrs.len() > MAX_DIRECT_ADDRS_PER_RECORD {
3988        return Err(DiscoveryWireError::OversizedPayload {
3989            actual: addrs.len(),
3990            max: MAX_DIRECT_ADDRS_PER_RECORD,
3991        });
3992    }
3993    writer.write_u16::<LittleEndian>(u16_from_usize(addrs.len(), "direct address count")?)?;
3994    for addr in addrs {
3995        match addr {
3996            SocketAddr::V4(addr) => {
3997                writer.write_u8(SOCKET_ADDR_V4)?;
3998                writer.write_all(&addr.ip().octets())?;
3999                writer.write_u16::<LittleEndian>(addr.port())?;
4000            }
4001            SocketAddr::V6(addr) => {
4002                writer.write_u8(SOCKET_ADDR_V6)?;
4003                writer.write_all(&addr.ip().octets())?;
4004                writer.write_u16::<LittleEndian>(addr.port())?;
4005                writer.write_u32::<LittleEndian>(addr.flowinfo())?;
4006                writer.write_u32::<LittleEndian>(addr.scope_id())?;
4007            }
4008        }
4009    }
4010    Ok(())
4011}
4012
4013fn decode_socket_addrs(reader: &mut impl Read) -> Result<Vec<SocketAddr>, DiscoveryWireError> {
4014    let count = usize::from(reader.read_u16::<LittleEndian>()?);
4015    if count > MAX_DIRECT_ADDRS_PER_RECORD {
4016        return Err(DiscoveryWireError::OversizedPayload {
4017            actual: count,
4018            max: MAX_DIRECT_ADDRS_PER_RECORD,
4019        });
4020    }
4021    let mut addrs = Vec::with_capacity(count);
4022    for _ in 0..count {
4023        let addr = match reader.read_u8()? {
4024            SOCKET_ADDR_V4 => {
4025                let mut octets = [0u8; 4];
4026                reader.read_exact(&mut octets)?;
4027                let port = reader.read_u16::<LittleEndian>()?;
4028                SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(octets), port))
4029            }
4030            SOCKET_ADDR_V6 => {
4031                let mut octets = [0u8; 16];
4032                reader.read_exact(&mut octets)?;
4033                let port = reader.read_u16::<LittleEndian>()?;
4034                let flowinfo = reader.read_u32::<LittleEndian>()?;
4035                let scope_id = reader.read_u32::<LittleEndian>()?;
4036                SocketAddr::V6(SocketAddrV6::new(
4037                    Ipv6Addr::from(octets),
4038                    port,
4039                    flowinfo,
4040                    scope_id,
4041                ))
4042            }
4043            family => return Err(DiscoveryWireError::InvalidAddressFamily(family)),
4044        };
4045        addrs.push(addr);
4046    }
4047    Ok(addrs)
4048}
4049
4050fn encode_service_ids(
4051    services: &[ZakuraServiceId],
4052    writer: &mut impl Write,
4053) -> Result<(), DiscoveryWireError> {
4054    encode_service_ids_capped(services, MAX_SERVICES_PER_RECORD, writer)
4055}
4056
4057fn encode_service_ids_capped(
4058    services: &[ZakuraServiceId],
4059    max_count: usize,
4060    writer: &mut impl Write,
4061) -> Result<(), DiscoveryWireError> {
4062    if services.len() > max_count {
4063        return Err(DiscoveryWireError::OversizedPayload {
4064            actual: services.len(),
4065            max: max_count,
4066        });
4067    }
4068    writer.write_u16::<LittleEndian>(u16_from_usize(services.len(), "service count")?)?;
4069    for service in services {
4070        encode_service_id(service, writer)?;
4071    }
4072    Ok(())
4073}
4074
4075fn encode_service_id(
4076    service: &ZakuraServiceId,
4077    writer: &mut impl Write,
4078) -> Result<(), DiscoveryWireError> {
4079    let bytes = service.as_str().as_bytes();
4080    validate_service_id(bytes)?;
4081    writer.write_u16::<LittleEndian>(u16_from_usize(bytes.len(), "service id length")?)?;
4082    writer.write_all(bytes)?;
4083    Ok(())
4084}
4085
4086fn decode_service_ids(
4087    reader: &mut impl Read,
4088    max_count: usize,
4089) -> Result<Vec<ZakuraServiceId>, DiscoveryWireError> {
4090    let count = usize::from(reader.read_u16::<LittleEndian>()?);
4091    if count > max_count {
4092        return Err(DiscoveryWireError::OversizedPayload {
4093            actual: count,
4094            max: max_count,
4095        });
4096    }
4097    let mut services = Vec::with_capacity(count);
4098    for _ in 0..count {
4099        services.push(decode_service_id(reader)?);
4100    }
4101    Ok(services)
4102}
4103
4104fn decode_service_id(reader: &mut impl Read) -> Result<ZakuraServiceId, DiscoveryWireError> {
4105    let len = usize::from(reader.read_u16::<LittleEndian>()?);
4106    if len > MAX_ZAKURA_SERVICE_ID_BYTES {
4107        return Err(DiscoveryWireError::OversizedPayload {
4108            actual: len,
4109            max: MAX_ZAKURA_SERVICE_ID_BYTES,
4110        });
4111    }
4112    let bytes = read_exact_vec(reader, len)?;
4113    validate_service_id(&bytes)?;
4114    let service = String::from_utf8(bytes).map_err(|_| DiscoveryWireError::NonAsciiServiceId)?;
4115    Ok(ZakuraServiceId(service))
4116}
4117
4118fn encode_node_ids(node_ids: &[NodeId], writer: &mut impl Write) -> Result<(), DiscoveryWireError> {
4119    if node_ids.len() > MAX_DISCOVERY_EXCLUDED_NODE_IDS {
4120        return Err(DiscoveryWireError::OversizedPayload {
4121            actual: node_ids.len(),
4122            max: MAX_DISCOVERY_EXCLUDED_NODE_IDS,
4123        });
4124    }
4125    writer.write_u16::<LittleEndian>(u16_from_usize(node_ids.len(), "node id count")?)?;
4126    for node_id in node_ids {
4127        writer.write_all(node_id.as_bytes())?;
4128    }
4129    Ok(())
4130}
4131
4132fn decode_node_ids(
4133    reader: &mut impl Read,
4134    max_count: usize,
4135) -> Result<Vec<NodeId>, DiscoveryWireError> {
4136    let count = usize::from(reader.read_u16::<LittleEndian>()?);
4137    if count > max_count {
4138        return Err(DiscoveryWireError::OversizedPayload {
4139            actual: count,
4140            max: max_count,
4141        });
4142    }
4143    let mut node_ids = Vec::with_capacity(count);
4144    for _ in 0..count {
4145        node_ids.push(decode_node_id(reader)?);
4146    }
4147    Ok(node_ids)
4148}
4149
4150fn decode_node_id(reader: &mut impl Read) -> Result<NodeId, DiscoveryWireError> {
4151    let mut bytes = [0u8; NODE_ID_BYTES];
4152    reader.read_exact(&mut bytes)?;
4153    NodeId::from_bytes(&bytes).map_err(|_| DiscoveryWireError::InvalidNodeId)
4154}
4155
4156fn network_id_from_code(value: u32) -> Result<ZakuraNetworkId, DiscoveryWireError> {
4157    match value {
4158        1 => Ok(ZakuraNetworkId::Mainnet),
4159        2 => Ok(ZakuraNetworkId::Testnet),
4160        3 => Ok(ZakuraNetworkId::Regtest),
4161        4 => Ok(ZakuraNetworkId::Configured),
4162        _ => Err(DiscoveryWireError::InvalidNetworkId(value)),
4163    }
4164}
4165
4166fn encode_header_sync_summary(
4167    summary: &HeaderSyncServiceSummary,
4168    writer: &mut impl Write,
4169) -> Result<(), DiscoveryWireError> {
4170    write_height(writer, summary.best_height)?;
4171    writer.write_all(&summary.best_hash.0)?;
4172    write_optional_height(writer, summary.finalized_height)?;
4173    write_bool(writer, summary.serving_headers)?;
4174    writer.write_u16::<LittleEndian>(summary.inbound_slots_free)?;
4175    writer.write_u16::<LittleEndian>(summary.inbound_slots_max)?;
4176    writer.write_u16::<LittleEndian>(summary.outbound_slots_free)?;
4177    writer.write_u16::<LittleEndian>(summary.outbound_slots_max)?;
4178    Ok(())
4179}
4180
4181fn decode_header_sync_summary(
4182    bytes: &[u8],
4183) -> Result<HeaderSyncServiceSummary, DiscoveryWireError> {
4184    if bytes.len() > MAX_SERVICE_SUMMARY_BYTES {
4185        return Err(DiscoveryWireError::OversizedPayload {
4186            actual: bytes.len(),
4187            max: MAX_SERVICE_SUMMARY_BYTES,
4188        });
4189    }
4190    let mut reader = Cursor::new(bytes);
4191    let best_height = read_height(&mut reader)?;
4192    let mut best_hash = [0u8; 32];
4193    reader.read_exact(&mut best_hash)?;
4194    let finalized_height = read_optional_height(&mut reader)?;
4195    let serving_headers = read_bool(&mut reader, "header sync serving_headers")?;
4196    let inbound_slots_free = reader.read_u16::<LittleEndian>()?;
4197    let inbound_slots_max = reader.read_u16::<LittleEndian>()?;
4198    let outbound_slots_free = reader.read_u16::<LittleEndian>()?;
4199    let outbound_slots_max = reader.read_u16::<LittleEndian>()?;
4200    reject_trailing(bytes, &reader)?;
4201    Ok(HeaderSyncServiceSummary {
4202        best_height,
4203        best_hash: block::Hash(best_hash),
4204        finalized_height,
4205        serving_headers,
4206        inbound_slots_free,
4207        inbound_slots_max,
4208        outbound_slots_free,
4209        outbound_slots_max,
4210    })
4211}
4212
4213fn encode_discovery_summary(
4214    summary: &DiscoveryServiceSummary,
4215    writer: &mut impl Write,
4216) -> Result<(), DiscoveryWireError> {
4217    writer.write_u16::<LittleEndian>(summary.peer_exchange_slots_free)?;
4218    writer.write_u16::<LittleEndian>(summary.max_records_per_response)?;
4219    write_bool(writer, summary.expected_disconnect_after_exchange)?;
4220    Ok(())
4221}
4222
4223fn decode_discovery_summary(bytes: &[u8]) -> Result<DiscoveryServiceSummary, DiscoveryWireError> {
4224    if bytes.len() > MAX_SERVICE_SUMMARY_BYTES {
4225        return Err(DiscoveryWireError::OversizedPayload {
4226            actual: bytes.len(),
4227            max: MAX_SERVICE_SUMMARY_BYTES,
4228        });
4229    }
4230    let mut reader = Cursor::new(bytes);
4231    let peer_exchange_slots_free = reader.read_u16::<LittleEndian>()?;
4232    let max_records_per_response = reader.read_u16::<LittleEndian>()?;
4233    let expected_disconnect_after_exchange =
4234        read_bool(&mut reader, "discovery expected_disconnect_after_exchange")?;
4235    reject_trailing(bytes, &reader)?;
4236    Ok(DiscoveryServiceSummary {
4237        peer_exchange_slots_free,
4238        max_records_per_response,
4239        expected_disconnect_after_exchange,
4240    })
4241}
4242
4243fn encode_block_sync_summary(
4244    summary: &BlockSyncServiceSummary,
4245    writer: &mut impl Write,
4246) -> Result<(), DiscoveryWireError> {
4247    validate_block_sync_summary(summary)?;
4248    write_height(writer, summary.servable_low)?;
4249    write_height(writer, summary.servable_high)?;
4250    writer.write_all(&summary.tip_hash.0)?;
4251    writer.write_u16::<LittleEndian>(summary.free_slots)?;
4252    writer.write_u32::<LittleEndian>(summary.max_blocks_per_response)?;
4253    writer.write_u32::<LittleEndian>(summary.max_response_bytes)?;
4254    Ok(())
4255}
4256
4257fn decode_block_sync_summary(bytes: &[u8]) -> Result<BlockSyncServiceSummary, DiscoveryWireError> {
4258    if bytes.len() > MAX_SERVICE_SUMMARY_BYTES {
4259        return Err(DiscoveryWireError::OversizedPayload {
4260            actual: bytes.len(),
4261            max: MAX_SERVICE_SUMMARY_BYTES,
4262        });
4263    }
4264    let mut reader = Cursor::new(bytes);
4265    let servable_low = read_height(&mut reader)?;
4266    let servable_high = read_height(&mut reader)?;
4267    let mut tip_hash = [0u8; 32];
4268    reader.read_exact(&mut tip_hash)?;
4269    let free_slots = reader.read_u16::<LittleEndian>()?;
4270    let max_blocks_per_response = reader.read_u32::<LittleEndian>()?;
4271    let max_response_bytes = reader.read_u32::<LittleEndian>()?;
4272    reject_trailing(bytes, &reader)?;
4273    let summary = BlockSyncServiceSummary {
4274        servable_low,
4275        servable_high,
4276        tip_hash: block::Hash(tip_hash),
4277        free_slots,
4278        max_blocks_per_response,
4279        max_response_bytes,
4280    };
4281    validate_block_sync_summary(&summary)?;
4282    Ok(summary)
4283}
4284
4285fn validate_block_sync_summary(
4286    summary: &BlockSyncServiceSummary,
4287) -> Result<(), DiscoveryWireError> {
4288    if summary.servable_low > summary.servable_high {
4289        return Err(DiscoveryWireError::InvalidProtocolRange);
4290    }
4291    if summary.max_blocks_per_response == 0
4292        || summary.max_blocks_per_response > MAX_BS_BLOCKS_PER_REQUEST
4293    {
4294        return Err(DiscoveryWireError::OversizedPayload {
4295            actual: usize::try_from(summary.max_blocks_per_response).unwrap_or(usize::MAX),
4296            max: usize::try_from(MAX_BS_BLOCKS_PER_REQUEST).unwrap_or(usize::MAX),
4297        });
4298    }
4299    if summary.max_response_bytes == 0 || summary.max_response_bytes > MAX_BS_RESPONSE_BYTES {
4300        return Err(DiscoveryWireError::OversizedPayload {
4301            actual: usize::try_from(summary.max_response_bytes).unwrap_or(usize::MAX),
4302            max: usize::try_from(MAX_BS_RESPONSE_BYTES).unwrap_or(usize::MAX),
4303        });
4304    }
4305    Ok(())
4306}
4307
4308fn write_height(writer: &mut impl Write, height: block::Height) -> Result<(), DiscoveryWireError> {
4309    writer.write_u32::<LittleEndian>(height.0)?;
4310    Ok(())
4311}
4312
4313fn read_height(reader: &mut impl Read) -> Result<block::Height, DiscoveryWireError> {
4314    let height = reader.read_u32::<LittleEndian>()?;
4315    block::Height::try_from(height).map_err(|_| DiscoveryWireError::InvalidHeight(height))
4316}
4317
4318fn write_optional_height(
4319    writer: &mut impl Write,
4320    height: Option<block::Height>,
4321) -> Result<(), DiscoveryWireError> {
4322    match height {
4323        Some(height) => {
4324            writer.write_u8(1)?;
4325            write_height(writer, height)?;
4326        }
4327        None => writer.write_u8(0)?,
4328    }
4329    Ok(())
4330}
4331
4332fn read_optional_height(
4333    reader: &mut impl Read,
4334) -> Result<Option<block::Height>, DiscoveryWireError> {
4335    match reader.read_u8()? {
4336        0 => Ok(None),
4337        1 => read_height(reader).map(Some),
4338        value => Err(DiscoveryWireError::InvalidBoolean {
4339            field: "optional height present",
4340            value,
4341        }),
4342    }
4343}
4344
4345fn write_bool(writer: &mut impl Write, value: bool) -> Result<(), DiscoveryWireError> {
4346    writer.write_u8(u8::from(value))?;
4347    Ok(())
4348}
4349
4350fn read_bool(reader: &mut impl Read, field: &'static str) -> Result<bool, DiscoveryWireError> {
4351    match reader.read_u8()? {
4352        0 => Ok(false),
4353        1 => Ok(true),
4354        value => Err(DiscoveryWireError::InvalidBoolean { field, value }),
4355    }
4356}
4357
4358fn saturating_u16(value: usize) -> u16 {
4359    u16::try_from(value).unwrap_or(u16::MAX)
4360}
4361
4362fn cursor_remaining(reader: &Cursor<&[u8]>) -> Result<usize, DiscoveryWireError> {
4363    let position = usize::try_from(reader.position())
4364        .map_err(|_| DiscoveryWireError::NumericOverflow("cursor position"))?;
4365    Ok(reader.get_ref().len().saturating_sub(position))
4366}
4367
4368fn read_exact_vec(reader: &mut impl Read, len: usize) -> Result<Vec<u8>, DiscoveryWireError> {
4369    let mut bytes = vec![0; len];
4370    reader.read_exact(&mut bytes)?;
4371    Ok(bytes)
4372}
4373
4374fn reject_trailing(bytes: &[u8], reader: &Cursor<&[u8]>) -> Result<(), DiscoveryWireError> {
4375    let consumed = usize::try_from(reader.position())
4376        .map_err(|_| DiscoveryWireError::NumericOverflow("cursor position"))?;
4377    if consumed != bytes.len() {
4378        return Err(DiscoveryWireError::TrailingBytes);
4379    }
4380    Ok(())
4381}
4382
4383fn usize_from_u32(value: u32, field: &'static str) -> Result<usize, DiscoveryWireError> {
4384    usize::try_from(value).map_err(|_| DiscoveryWireError::NumericOverflow(field))
4385}
4386
4387fn u32_from_usize(value: usize, field: &'static str) -> Result<u32, DiscoveryWireError> {
4388    u32::try_from(value).map_err(|_| DiscoveryWireError::NumericOverflow(field))
4389}
4390
4391fn u16_from_usize(value: usize, field: &'static str) -> Result<u16, DiscoveryWireError> {
4392    u16::try_from(value).map_err(|_| DiscoveryWireError::NumericOverflow(field))
4393}
4394
4395#[cfg(test)]
4396mod tests {
4397    use std::{net::IpAddr, time::Duration};
4398
4399    use iroh::SecretKey;
4400    use rand::{rngs::OsRng, rngs::StdRng, SeedableRng};
4401
4402    use super::*;
4403
4404    const NOW: u64 = 1_700_000_000;
4405    const CHAIN_ID: [u8; 32] = [7; 32];
4406
4407    fn secret_key() -> SecretKey {
4408        SecretKey::generate(OsRng)
4409    }
4410
4411    fn service(index: usize) -> ZakuraServiceId {
4412        ZakuraServiceId::new(format!("zakura.test.{index}.v1")).expect("test service id is valid")
4413    }
4414
4415    fn context() -> DiscoveryRecordValidationContext {
4416        DiscoveryRecordValidationContext {
4417            expected_network_id: ZakuraNetworkId::Regtest,
4418            expected_chain_id: CHAIN_ID,
4419            current_unix_secs: NOW,
4420            supported_protocol_min: 1,
4421            supported_protocol_max: 1,
4422            max_record_ttl: Duration::from_secs(24 * 60 * 60),
4423            clock_skew_tolerance: Duration::from_secs(300),
4424        }
4425    }
4426
4427    fn body(secret_key: &SecretKey) -> ZakuraNodeRecordBody {
4428        ZakuraNodeRecordBody {
4429            node_id: secret_key.public(),
4430            direct_addrs: vec![
4431                SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 8233),
4432                SocketAddr::new(
4433                    IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 10)),
4434                    18233,
4435                ),
4436            ],
4437            services: vec![
4438                ZakuraServiceId::discovery(),
4439                ZakuraServiceId::legacy_gossip(),
4440                ZakuraServiceId::legacy_requests(),
4441            ],
4442            zakura_protocol_min: 1,
4443            zakura_protocol_max: 1,
4444            network_id: ZakuraNetworkId::Regtest,
4445            chain_id: CHAIN_ID,
4446            sequence: 42,
4447            expires_at_unix_secs: NOW + 60,
4448        }
4449    }
4450
4451    fn signed_record() -> ZakuraNodeRecord {
4452        let secret_key = secret_key();
4453        ZakuraNodeRecord::sign(body(&secret_key), &secret_key).expect("test record signs")
4454    }
4455
4456    fn sign_record_with_prefix(
4457        body: ZakuraNodeRecordBody,
4458        secret_key: &SecretKey,
4459        domain: &[u8],
4460        record_format_version: u16,
4461    ) -> ZakuraNodeRecord {
4462        let mut bytes = Vec::new();
4463        bytes.write_all(domain).expect("test domain writes");
4464        bytes
4465            .write_u16::<LittleEndian>(record_format_version)
4466            .expect("test record format version writes");
4467        encode_record_body_to(&body, &mut bytes).expect("test record body encodes");
4468
4469        let signing_key = SigningKey::from(secret_key.to_bytes());
4470        let signature = signing_key.sign(&bytes);
4471        ZakuraNodeRecord { body, signature }
4472    }
4473
4474    fn signed_record_with(
4475        sequence: u64,
4476        service: ZakuraServiceId,
4477        addr: SocketAddr,
4478    ) -> ZakuraNodeRecord {
4479        signed_record_with_addrs(sequence, service, vec![addr])
4480    }
4481
4482    fn signed_record_with_addrs(
4483        sequence: u64,
4484        service: ZakuraServiceId,
4485        addrs: Vec<SocketAddr>,
4486    ) -> ZakuraNodeRecord {
4487        let secret_key = secret_key();
4488        let mut body = body(&secret_key);
4489        body.sequence = sequence;
4490        body.direct_addrs = addrs;
4491        body.services = vec![service];
4492        ZakuraNodeRecord::sign(body, &secret_key).expect("test record signs")
4493    }
4494
4495    fn record_with_secret(
4496        secret_key: &SecretKey,
4497        sequence: u64,
4498        service: ZakuraServiceId,
4499    ) -> ZakuraNodeRecord {
4500        let mut body = body(secret_key);
4501        body.sequence = sequence;
4502        body.services = vec![service];
4503        ZakuraNodeRecord::sign(body, secret_key).expect("test record signs")
4504    }
4505
4506    fn test_addr(index: u8) -> SocketAddr {
4507        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, index)), 8233)
4508    }
4509
4510    fn candidate_for(record: &ZakuraNodeRecord, is_static: bool) -> ZakuraDiscoveryDialCandidate {
4511        ZakuraDiscoveryDialCandidate {
4512            node_id: record.body.node_id,
4513            direct_addrs: record.body.direct_addrs.clone(),
4514            is_static,
4515        }
4516    }
4517
4518    fn import_confirmed_record(
4519        book: &mut ZakuraDiscoveryBook,
4520        record: ZakuraNodeRecord,
4521    ) -> Result<ImportOutcome, DiscoveryBookError> {
4522        let source = record.body.node_id;
4523        book.import_record(record, Some(source), NOW, &context())
4524    }
4525
4526    fn small_book(max_records: usize) -> ZakuraDiscoveryBook {
4527        ZakuraDiscoveryBook::new(ZakuraDiscoveryBookLimits {
4528            max_records,
4529            ..ZakuraDiscoveryBookLimits::default()
4530        })
4531    }
4532
4533    fn runtime_record_with(
4534        sequence: u64,
4535        service: ZakuraServiceId,
4536        addr: SocketAddr,
4537    ) -> ZakuraNodeRecord {
4538        let secret_key = secret_key();
4539        runtime_record_with_secret(&secret_key, sequence, service, addr)
4540    }
4541
4542    fn runtime_record_with_services(
4543        sequence: u64,
4544        services: Vec<ZakuraServiceId>,
4545        addr: SocketAddr,
4546    ) -> ZakuraNodeRecord {
4547        let secret_key = secret_key();
4548        let mut body = body(&secret_key);
4549        body.sequence = sequence;
4550        body.direct_addrs = vec![addr];
4551        body.services = services;
4552        body.expires_at_unix_secs = current_unix_secs() + DEFAULT_DISCOVERY_RECORD_TTL.as_secs();
4553        ZakuraNodeRecord::sign(body, &secret_key).expect("runtime test record signs")
4554    }
4555
4556    fn runtime_record_with_secret(
4557        secret_key: &SecretKey,
4558        sequence: u64,
4559        service: ZakuraServiceId,
4560        addr: SocketAddr,
4561    ) -> ZakuraNodeRecord {
4562        runtime_record_with_secret_and_addrs(secret_key, sequence, service, vec![addr])
4563    }
4564
4565    fn runtime_record_with_secret_and_addrs(
4566        secret_key: &SecretKey,
4567        sequence: u64,
4568        service: ZakuraServiceId,
4569        addrs: Vec<SocketAddr>,
4570    ) -> ZakuraNodeRecord {
4571        let mut body = body(secret_key);
4572        body.sequence = sequence;
4573        body.direct_addrs = addrs;
4574        body.services = vec![service];
4575        body.expires_at_unix_secs = current_unix_secs() + DEFAULT_DISCOVERY_RECORD_TTL.as_secs();
4576        ZakuraNodeRecord::sign(body, secret_key).expect("runtime test record signs")
4577    }
4578
4579    fn local_config_with(
4580        secret_key: SecretKey,
4581        direct_addrs: Vec<SocketAddr>,
4582        services: Vec<ZakuraServiceId>,
4583    ) -> ZakuraDiscoveryLocalConfig {
4584        ZakuraDiscoveryLocalConfig {
4585            secret_key,
4586            direct_addrs,
4587            services,
4588            zakura_protocol_min: 1,
4589            zakura_protocol_max: 1,
4590            network_id: ZakuraNetworkId::Regtest,
4591            chain_id: CHAIN_ID,
4592            last_authored_sequence: None,
4593        }
4594    }
4595
4596    fn discovery_handle_at(
4597        local_config: ZakuraDiscoveryLocalConfig,
4598        config: ZakuraDiscoveryConfig,
4599        connected: watch::Receiver<Vec<ZakuraPeerId>>,
4600        now: u64,
4601    ) -> ZakuraDiscoveryHandle {
4602        ZakuraDiscoveryHandle::new_at(local_config, config, connected, now, now)
4603            .expect("test discovery state constructs")
4604    }
4605
4606    fn discovery_handle_at_with_sequence(
4607        local_config: ZakuraDiscoveryLocalConfig,
4608        config: ZakuraDiscoveryConfig,
4609        connected: watch::Receiver<Vec<ZakuraPeerId>>,
4610        now: u64,
4611        wall_clock_sequence: u64,
4612    ) -> ZakuraDiscoveryHandle {
4613        ZakuraDiscoveryHandle::new_at(local_config, config, connected, now, wall_clock_sequence)
4614            .expect("test discovery state constructs")
4615    }
4616
4617    fn discovery_handle_with_connected(
4618        connected: watch::Receiver<Vec<ZakuraPeerId>>,
4619    ) -> ZakuraDiscoveryHandle {
4620        discovery_handle_at(
4621            local_config_with(
4622                secret_key(),
4623                vec![test_addr(200)],
4624                vec![ZakuraServiceId::discovery()],
4625            ),
4626            ZakuraDiscoveryConfig::default(),
4627            connected,
4628            NOW,
4629        )
4630    }
4631
4632    fn peer_id_for(node_id: NodeId) -> ZakuraPeerId {
4633        ZakuraPeerId::new(node_id.as_bytes().to_vec()).expect("node id is a valid peer id")
4634    }
4635
4636    fn header_summary() -> HeaderSyncServiceSummary {
4637        HeaderSyncServiceSummary {
4638            best_height: block::Height(42),
4639            best_hash: block::Hash([7; 32]),
4640            finalized_height: Some(block::Height(40)),
4641            serving_headers: true,
4642            inbound_slots_free: 3,
4643            inbound_slots_max: 10,
4644            outbound_slots_free: 4,
4645            outbound_slots_max: 11,
4646        }
4647    }
4648
4649    fn retired_header_sync_envelope(summary: &HeaderSyncServiceSummary) -> ServiceSummaryEnvelope {
4650        let mut summary_bytes = Vec::new();
4651        encode_header_sync_summary(summary, &mut summary_bytes)
4652            .expect("test header summary encodes");
4653        ServiceSummaryEnvelope {
4654            service_id: ZakuraServiceId::header_sync_retired(),
4655            summary_tag: SUMMARY_TAG_HEADER_SYNC_RETIRED,
4656            summary_bytes,
4657        }
4658    }
4659
4660    fn discovery_summary() -> DiscoveryServiceSummary {
4661        DiscoveryServiceSummary {
4662            peer_exchange_slots_free: 5,
4663            max_records_per_response: 12,
4664            expected_disconnect_after_exchange: true,
4665        }
4666    }
4667
4668    fn block_sync_summary() -> BlockSyncServiceSummary {
4669        BlockSyncServiceSummary {
4670            servable_low: block::Height(10),
4671            servable_high: block::Height(42),
4672            tip_hash: block::Hash([9; 32]),
4673            free_slots: 3,
4674            max_blocks_per_response: 16,
4675            max_response_bytes: MAX_BS_RESPONSE_BYTES,
4676        }
4677    }
4678
4679    fn block_sync_status() -> BlockSyncStatus {
4680        BlockSyncStatus {
4681            servable_low: block::Height(10),
4682            servable_high: block::Height(42),
4683            tip_hash: block::Hash([9; 32]),
4684            max_blocks_per_response: 16,
4685            max_inflight_requests: 4,
4686            max_response_bytes: MAX_BS_RESPONSE_BYTES,
4687        }
4688    }
4689
4690    fn services_message(summaries: Vec<ServiceSummaryEnvelope>) -> DiscoveryMessage {
4691        DiscoveryMessage::Services(Services {
4692            node_id: secret_key().public(),
4693            expires_at_unix_secs: NOW + 30,
4694            summaries,
4695        })
4696    }
4697
4698    fn first_party_services(
4699        node_id: NodeId,
4700        expires_at_unix_secs: u64,
4701        summaries: Vec<ServiceSummaryEnvelope>,
4702    ) -> Services {
4703        Services {
4704            node_id,
4705            expires_at_unix_secs,
4706            summaries,
4707        }
4708    }
4709
4710    #[test]
4711    fn service_id_accepts_valid_ids_and_rejects_invalid_ids() {
4712        assert_eq!(
4713            ZakuraServiceId::new("zakura.custom.v1").unwrap().as_str(),
4714            "zakura.custom.v1"
4715        );
4716        assert!(matches!(
4717            ZakuraServiceId::new(""),
4718            Err(DiscoveryWireError::Empty("service id"))
4719        ));
4720        assert!(matches!(
4721            ZakuraServiceId::new("zakura.\u{2603}.v1"),
4722            Err(DiscoveryWireError::NonAsciiServiceId)
4723        ));
4724        assert!(matches!(
4725            ZakuraServiceId::new("a".repeat(MAX_ZAKURA_SERVICE_ID_BYTES + 1)),
4726            Err(DiscoveryWireError::OversizedPayload { .. })
4727        ));
4728    }
4729
4730    #[test]
4731    fn block_sync_summary_uses_live_peer_slots() {
4732        let limits = ServicePeerLimits {
4733            max_inbound_peers: 2,
4734            ..ServicePeerLimits::default()
4735        };
4736        let status = block_sync_status();
4737
4738        let open = BlockSyncServiceSummary::from_status_and_snapshot(
4739            status,
4740            ServicePeerSnapshot::new(1, 0, limits),
4741        );
4742        assert_eq!(open.free_slots, 1);
4743
4744        let full = BlockSyncServiceSummary::from_status_and_snapshot(
4745            status,
4746            ServicePeerSnapshot::new(2, 0, limits),
4747        );
4748        assert_eq!(full.free_slots, 0);
4749    }
4750
4751    #[test]
4752    fn node_record_encode_decode_roundtrip() {
4753        let record = signed_record();
4754        let signed_bytes = record.body.encode_for_signature().expect("record signs");
4755        let mut signed_bytes_reader =
4756            Cursor::new(&signed_bytes[ZAKURA_NODE_RECORD_SIG_DOMAIN.len()..]);
4757        assert!(signed_bytes.starts_with(ZAKURA_NODE_RECORD_SIG_DOMAIN));
4758        assert_eq!(
4759            signed_bytes_reader.read_u16::<LittleEndian>().unwrap(),
4760            ZAKURA_NODE_RECORD_FORMAT_VERSION
4761        );
4762
4763        let mut body_bytes = Vec::new();
4764        encode_record_body_to(&record.body, &mut body_bytes).expect("record body encodes");
4765        assert_eq!(
4766            &signed_bytes[ZAKURA_NODE_RECORD_SIG_DOMAIN.len() + 2..],
4767            body_bytes.as_slice()
4768        );
4769
4770        let mut bytes = Vec::new();
4771        encode_record(&record, &mut bytes).expect("record encodes");
4772        let encoded_body_len =
4773            usize::try_from(Cursor::new(&bytes).read_u32::<LittleEndian>().unwrap())
4774                .expect("encoded record body length fits in usize");
4775        assert_eq!(encoded_body_len, body_bytes.len());
4776
4777        let decoded = decode_record(&mut Cursor::new(&bytes)).expect("record decodes");
4778
4779        assert_eq!(decoded, record);
4780        assert_eq!(decoded.body.encode_for_signature().unwrap(), signed_bytes);
4781        decoded.verify(&context()).expect("record verifies");
4782    }
4783
4784    #[test]
4785    fn discovery_message_roundtrips_every_variant() {
4786        let record = signed_record();
4787        let other = signed_record();
4788        let services = vec![ZakuraServiceId::discovery(), service(1)];
4789        let excluded = vec![record.body.node_id, other.body.node_id];
4790        let header_envelope =
4791            ServiceSummaryEnvelope::header_sync(&header_summary()).expect("summary encodes");
4792        let discovery_envelope =
4793            ServiceSummaryEnvelope::discovery(&discovery_summary()).expect("summary encodes");
4794        let block_envelope =
4795            ServiceSummaryEnvelope::block_sync(&block_sync_summary()).expect("summary encodes");
4796        let messages = vec![
4797            DiscoveryMessage::Hello {
4798                record: record.clone(),
4799            },
4800            DiscoveryMessage::GetPeers {
4801                limit: 2,
4802                wanted_services: services.clone(),
4803                exclude_node_ids: excluded.clone(),
4804            },
4805            DiscoveryMessage::Peers {
4806                records: vec![record.clone(), other.clone()],
4807            },
4808            DiscoveryMessage::GetServices(GetServices {
4809                wanted_services: services,
4810            }),
4811            services_message(vec![header_envelope, discovery_envelope, block_envelope]),
4812        ];
4813
4814        for message in messages {
4815            let encoded = message.encode().expect("message encodes");
4816            assert_eq!(DiscoveryMessage::decode(&encoded).unwrap(), message);
4817        }
4818    }
4819
4820    #[test]
4821    fn service_summary_envelopes_roundtrip_and_unknown_tags_are_skipped() {
4822        let header = header_summary();
4823        let discovery = discovery_summary();
4824        let block = block_sync_summary();
4825        let header_envelope =
4826            ServiceSummaryEnvelope::header_sync(&header).expect("header summary encodes");
4827        let discovery_envelope =
4828            ServiceSummaryEnvelope::discovery(&discovery).expect("discovery summary encodes");
4829        let block_envelope =
4830            ServiceSummaryEnvelope::block_sync(&block).expect("block summary encodes");
4831        let unknown_envelope = ServiceSummaryEnvelope {
4832            service_id: service(99),
4833            summary_tag: 999,
4834            summary_bytes: vec![1, 2, 3, 4],
4835        };
4836
4837        assert_eq!(header_envelope.decode_header_sync().unwrap(), Some(header));
4838        assert_eq!(
4839            discovery_envelope.decode_discovery().unwrap(),
4840            Some(discovery)
4841        );
4842        assert_eq!(block_envelope.decode_block_sync().unwrap(), Some(block));
4843
4844        let message = services_message(vec![
4845            unknown_envelope.clone(),
4846            header_envelope.clone(),
4847            discovery_envelope.clone(),
4848            block_envelope.clone(),
4849        ]);
4850        let decoded = DiscoveryMessage::decode(&message.encode().expect("message encodes"))
4851            .expect("message decodes");
4852
4853        let DiscoveryMessage::Services(services) = decoded else {
4854            panic!("expected service response");
4855        };
4856        assert_eq!(services.summaries[0], unknown_envelope);
4857        assert_eq!(
4858            services.summaries[1].decode_header_sync().unwrap(),
4859            Some(header)
4860        );
4861        assert_eq!(
4862            services.summaries[2].decode_discovery().unwrap(),
4863            Some(discovery)
4864        );
4865        assert_eq!(
4866            services.summaries[3].decode_block_sync().unwrap(),
4867            Some(block)
4868        );
4869    }
4870
4871    #[test]
4872    fn service_summary_decode_rejects_lying_truncated_and_oversized_lengths() {
4873        let node_id = secret_key().public();
4874        let mut lying = vec![MSG_DISCOVERY_SERVICES];
4875        lying.write_all(node_id.as_bytes()).unwrap();
4876        lying.write_u64::<LittleEndian>(NOW + 30).unwrap();
4877        lying.write_u16::<LittleEndian>(1).unwrap();
4878        encode_service_id(&service(1), &mut lying).unwrap();
4879        lying.write_u16::<LittleEndian>(999).unwrap();
4880        lying.write_u16::<LittleEndian>(4).unwrap();
4881        lying.write_u8(1).unwrap();
4882
4883        assert!(matches!(
4884            DiscoveryMessage::decode(&lying),
4885            Err(DiscoveryWireError::TruncatedPayload { .. })
4886        ));
4887
4888        let mut truncated_known = vec![MSG_DISCOVERY_SERVICES];
4889        truncated_known.write_all(node_id.as_bytes()).unwrap();
4890        truncated_known.write_u64::<LittleEndian>(NOW + 30).unwrap();
4891        truncated_known.write_u16::<LittleEndian>(1).unwrap();
4892        encode_service_id(&ZakuraServiceId::header_sync(), &mut truncated_known).unwrap();
4893        truncated_known
4894            .write_u16::<LittleEndian>(SUMMARY_TAG_HEADER_SYNC)
4895            .unwrap();
4896        truncated_known.write_u16::<LittleEndian>(1).unwrap();
4897        truncated_known.write_u8(0).unwrap();
4898
4899        assert!(DiscoveryMessage::decode(&truncated_known).is_err());
4900
4901        let mut truncated_block = vec![MSG_DISCOVERY_SERVICES];
4902        truncated_block.write_all(node_id.as_bytes()).unwrap();
4903        truncated_block.write_u64::<LittleEndian>(NOW + 30).unwrap();
4904        truncated_block.write_u16::<LittleEndian>(1).unwrap();
4905        encode_service_id(&ZakuraServiceId::block_sync(), &mut truncated_block).unwrap();
4906        truncated_block
4907            .write_u16::<LittleEndian>(SUMMARY_TAG_BLOCK_SYNC_V1)
4908            .unwrap();
4909        truncated_block.write_u16::<LittleEndian>(8).unwrap();
4910        write_height(&mut truncated_block, block::Height(10)).unwrap();
4911        write_height(&mut truncated_block, block::Height(42)).unwrap();
4912
4913        assert!(DiscoveryMessage::decode(&truncated_block).is_err());
4914
4915        let mut oversized = vec![MSG_DISCOVERY_SERVICES];
4916        oversized.write_all(node_id.as_bytes()).unwrap();
4917        oversized.write_u64::<LittleEndian>(NOW + 30).unwrap();
4918        oversized.write_u16::<LittleEndian>(1).unwrap();
4919        encode_service_id(&ZakuraServiceId::block_sync(), &mut oversized).unwrap();
4920        oversized
4921            .write_u16::<LittleEndian>(SUMMARY_TAG_BLOCK_SYNC_V1)
4922            .unwrap();
4923        oversized
4924            .write_u16::<LittleEndian>(
4925                u16::try_from(MAX_SERVICE_SUMMARY_BYTES + 1).expect("test length fits"),
4926            )
4927            .unwrap();
4928
4929        assert!(matches!(
4930            DiscoveryMessage::decode(&oversized),
4931            Err(DiscoveryWireError::OversizedPayload { .. })
4932        ));
4933    }
4934
4935    #[test]
4936    fn known_service_summary_payload_trailing_bytes_are_rejected() {
4937        let mut envelope =
4938            ServiceSummaryEnvelope::header_sync(&header_summary()).expect("summary encodes");
4939        envelope.summary_bytes.push(0);
4940
4941        assert!(matches!(
4942            services_message(vec![envelope.clone()]).encode(),
4943            Err(DiscoveryWireError::TrailingBytes)
4944        ));
4945
4946        let node_id = secret_key().public();
4947        let mut encoded = vec![MSG_DISCOVERY_SERVICES];
4948        encoded.write_all(node_id.as_bytes()).unwrap();
4949        encoded.write_u64::<LittleEndian>(NOW + 30).unwrap();
4950        encoded.write_u16::<LittleEndian>(1).unwrap();
4951        encode_service_id(&envelope.service_id, &mut encoded).unwrap();
4952        encoded
4953            .write_u16::<LittleEndian>(envelope.summary_tag)
4954            .unwrap();
4955        encoded
4956            .write_u16::<LittleEndian>(
4957                u16::try_from(envelope.summary_bytes.len()).expect("test length fits"),
4958            )
4959            .unwrap();
4960        encoded.write_all(&envelope.summary_bytes).unwrap();
4961
4962        assert!(matches!(
4963            DiscoveryMessage::decode(&encoded),
4964            Err(DiscoveryWireError::TrailingBytes)
4965        ));
4966
4967        let mut block_envelope =
4968            ServiceSummaryEnvelope::block_sync(&block_sync_summary()).expect("summary encodes");
4969        block_envelope.summary_bytes.push(0);
4970
4971        assert!(matches!(
4972            services_message(vec![block_envelope]).encode(),
4973            Err(DiscoveryWireError::TrailingBytes)
4974        ));
4975    }
4976
4977    #[test]
4978    fn signature_verifies_for_author_and_fails_after_mutation() {
4979        let record = signed_record();
4980        record.verify(&context()).expect("record verifies");
4981        assert!(record
4982            .body
4983            .encode_for_signature()
4984            .expect("record pre-image encodes")
4985            .starts_with(ZAKURA_NODE_RECORD_SIG_DOMAIN));
4986
4987        let mutations: Vec<
4988            Box<dyn FnOnce(&mut ZakuraNodeRecord, &mut DiscoveryRecordValidationContext)>,
4989        > = vec![
4990            Box::new(|record, _context| {
4991                record
4992                    .body
4993                    .direct_addrs
4994                    .push("198.51.100.1:8233".parse().unwrap())
4995            }),
4996            Box::new(|record, _context| record.body.services.push(service(9))),
4997            Box::new(|record, _context| record.body.zakura_protocol_min = 0),
4998            Box::new(|record, _context| record.body.zakura_protocol_max = 2),
4999            Box::new(|record, context| {
5000                record.body.network_id = ZakuraNetworkId::Mainnet;
5001                context.expected_network_id = ZakuraNetworkId::Mainnet;
5002            }),
5003            Box::new(|record, context| {
5004                record.body.chain_id[0] ^= 1;
5005                context.expected_chain_id = record.body.chain_id;
5006            }),
5007            Box::new(|record, _context| record.body.sequence += 1),
5008            Box::new(|record, _context| record.body.expires_at_unix_secs += 1),
5009        ];
5010
5011        for mutate in mutations {
5012            let mut mutated = record.clone();
5013            let mut context = context();
5014            mutate(&mut mutated, &mut context);
5015            assert!(matches!(
5016                mutated.verify(&context),
5017                Err(DiscoveryRecordError::InvalidSignature)
5018            ));
5019        }
5020
5021        let other_secret = secret_key();
5022        let mut mutated = record;
5023        mutated.body.node_id = other_secret.public();
5024        assert!(matches!(
5025            mutated.verify(&context()),
5026            Err(DiscoveryRecordError::InvalidSignature)
5027        ));
5028    }
5029
5030    #[test]
5031    fn signature_fails_with_wrong_domain_or_record_format_version() {
5032        let secret_key = secret_key();
5033        let body = body(&secret_key);
5034
5035        let wrong_domain = sign_record_with_prefix(
5036            body.clone(),
5037            &secret_key,
5038            b"zakura-other-record-v1",
5039            ZAKURA_NODE_RECORD_FORMAT_VERSION,
5040        );
5041        assert!(matches!(
5042            wrong_domain.verify(&context()),
5043            Err(DiscoveryRecordError::InvalidSignature)
5044        ));
5045
5046        let wrong_version = sign_record_with_prefix(
5047            body,
5048            &secret_key,
5049            ZAKURA_NODE_RECORD_SIG_DOMAIN,
5050            ZAKURA_NODE_RECORD_FORMAT_VERSION + 1,
5051        );
5052        assert!(matches!(
5053            wrong_version.verify(&context()),
5054            Err(DiscoveryRecordError::InvalidSignature)
5055        ));
5056    }
5057
5058    #[test]
5059    fn discovery_network_id_decode_matches_handshake_wire_codes() {
5060        for network_id in [
5061            ZakuraNetworkId::Mainnet,
5062            ZakuraNetworkId::Testnet,
5063            ZakuraNetworkId::Regtest,
5064            ZakuraNetworkId::Configured,
5065        ] {
5066            assert_eq!(
5067                network_id_from_code(network_id.code()).expect("network id code is valid"),
5068                network_id
5069            );
5070        }
5071    }
5072
5073    #[test]
5074    fn record_validation_rejects_expired_wrong_network_wrong_chain_and_protocol() {
5075        let record = signed_record();
5076
5077        let mut expired = record.clone();
5078        expired.body.expires_at_unix_secs = NOW - context().clock_skew_tolerance.as_secs() - 1;
5079        assert!(matches!(
5080            expired.verify(&context()),
5081            Err(DiscoveryRecordError::Expired)
5082        ));
5083
5084        let mut wrong_network_context = context();
5085        wrong_network_context.expected_network_id = ZakuraNetworkId::Mainnet;
5086        assert!(matches!(
5087            record.verify(&wrong_network_context),
5088            Err(DiscoveryRecordError::WrongNetwork)
5089        ));
5090
5091        let mut wrong_chain_context = context();
5092        wrong_chain_context.expected_chain_id[0] ^= 1;
5093        assert!(matches!(
5094            record.verify(&wrong_chain_context),
5095            Err(DiscoveryRecordError::WrongChain)
5096        ));
5097
5098        let mut incompatible_context = context();
5099        incompatible_context.supported_protocol_min = 2;
5100        incompatible_context.supported_protocol_max = 2;
5101        assert!(matches!(
5102            record.verify(&incompatible_context),
5103            Err(DiscoveryRecordError::IncompatibleProtocol)
5104        ));
5105    }
5106
5107    #[test]
5108    fn record_bounds_reject_too_many_addresses_and_services() {
5109        let secret_key = secret_key();
5110        let mut too_many_addrs = body(&secret_key);
5111        too_many_addrs.direct_addrs =
5112            vec!["192.0.2.1:8233".parse().unwrap(); MAX_DIRECT_ADDRS_PER_RECORD + 1];
5113        assert!(ZakuraNodeRecord::sign(too_many_addrs, &secret_key).is_err());
5114
5115        let mut too_many_services = body(&secret_key);
5116        too_many_services.services = (0..=MAX_SERVICES_PER_RECORD).map(service).collect();
5117        assert!(ZakuraNodeRecord::sign(too_many_services, &secret_key).is_err());
5118    }
5119
5120    #[test]
5121    fn response_and_query_bounds_are_enforced() {
5122        let records = vec![signed_record(); MAX_DISCOVERY_RECORDS_PER_RESPONSE + 1];
5123        assert!(DiscoveryMessage::Peers {
5124            records: records.clone()
5125        }
5126        .encode()
5127        .is_err());
5128
5129        let wanted_services = (0..=MAX_SERVICES_PER_RECORD).map(service).collect();
5130        assert!(DiscoveryMessage::GetPeers {
5131            limit: 1,
5132            wanted_services,
5133            exclude_node_ids: Vec::new(),
5134        }
5135        .encode()
5136        .is_err());
5137
5138        let wanted_services = (0..=MAX_GET_SERVICES_WANTED).map(service).collect();
5139        assert!(
5140            DiscoveryMessage::GetServices(GetServices { wanted_services })
5141                .encode()
5142                .is_err()
5143        );
5144
5145        assert!(DiscoveryMessage::GetPeers {
5146            limit: u16::try_from(MAX_DISCOVERY_RECORDS_PER_RESPONSE + 1)
5147                .expect("test limit fits in u16"),
5148            wanted_services: Vec::new(),
5149            exclude_node_ids: Vec::new(),
5150        }
5151        .encode()
5152        .is_err());
5153
5154        let too_many_summaries = vec![
5155            ServiceSummaryEnvelope {
5156                service_id: service(1),
5157                summary_tag: 999,
5158                summary_bytes: Vec::new(),
5159            };
5160            MAX_SERVICE_SUMMARIES_PER_RESPONSE + 1
5161        ];
5162        assert!(services_message(too_many_summaries).encode().is_err());
5163
5164        let oversized_summary = ServiceSummaryEnvelope {
5165            service_id: service(1),
5166            summary_tag: 999,
5167            summary_bytes: vec![0; MAX_SERVICE_SUMMARY_BYTES + 1],
5168        };
5169        assert!(services_message(vec![oversized_summary]).encode().is_err());
5170
5171        let large_summary = ServiceSummaryEnvelope {
5172            service_id: service(1),
5173            summary_tag: 999,
5174            summary_bytes: vec![0; MAX_SERVICE_SUMMARY_BYTES],
5175        };
5176        let total_oversized = vec![large_summary; 16];
5177        assert!(matches!(
5178            services_message(total_oversized).encode(),
5179            Err(DiscoveryWireError::OversizedPayload { .. })
5180        ));
5181    }
5182
5183    /// Retiring header-sync v6 must not break discovery with peers that have not
5184    /// upgraded. A pre-upgrade peer still sends the retired tag with the retired
5185    /// service id; that pairing must stay wire-valid, or every `Services` exchange
5186    /// with such a peer fails as a protocol violation and takes discovery down with
5187    /// it. The payload remains a valid advisory summary for peers that independently
5188    /// advertise the current header-sync service.
5189    #[test]
5190    fn retired_header_sync_summary_from_a_pre_upgrade_peer_decodes_as_advisory() {
5191        let summary = header_summary();
5192        let retired = retired_header_sync_envelope(&summary);
5193
5194        let encoded = services_message(vec![retired.clone()])
5195            .encode()
5196            .expect("a pre-upgrade peer's header-sync summary must still encode");
5197        assert!(
5198            DiscoveryMessage::decode(&encoded).is_ok(),
5199            "a pre-upgrade peer's Services message must not be a wire protocol violation"
5200        );
5201
5202        assert_eq!(
5203            retired
5204                .decode_header_sync()
5205                .expect("the retired payload is well-formed"),
5206            Some(summary),
5207            "a retired-generation summary remains an advisory payload"
5208        );
5209    }
5210
5211    #[test]
5212    fn known_summary_tags_must_match_their_service_ids() {
5213        let mismatched_header = ServiceSummaryEnvelope {
5214            service_id: ZakuraServiceId::discovery(),
5215            summary_tag: SUMMARY_TAG_HEADER_SYNC,
5216            summary_bytes: {
5217                let mut bytes = Vec::new();
5218                encode_header_sync_summary(&header_summary(), &mut bytes)
5219                    .expect("test header summary encodes");
5220                bytes
5221            },
5222        };
5223        assert!(matches!(
5224            services_message(vec![mismatched_header.clone()]).encode(),
5225            Err(DiscoveryWireError::MismatchedSummaryService {
5226                summary_tag: SUMMARY_TAG_HEADER_SYNC,
5227                ..
5228            })
5229        ));
5230        let mut mismatched_header_wire = vec![MSG_DISCOVERY_SERVICES];
5231        mismatched_header_wire
5232            .write_all(secret_key().public().as_bytes())
5233            .expect("test node id writes");
5234        mismatched_header_wire
5235            .write_u64::<LittleEndian>(NOW + 30)
5236            .expect("test expiry writes");
5237        mismatched_header_wire
5238            .write_u16::<LittleEndian>(1)
5239            .expect("test summary count writes");
5240        encode_service_id(&ZakuraServiceId::discovery(), &mut mismatched_header_wire)
5241            .expect("test service id writes");
5242        mismatched_header_wire
5243            .write_u16::<LittleEndian>(SUMMARY_TAG_HEADER_SYNC)
5244            .expect("test summary tag writes");
5245        mismatched_header_wire
5246            .write_u16::<LittleEndian>(
5247                u16::try_from(mismatched_header.summary_bytes.len())
5248                    .expect("test summary length fits in u16"),
5249            )
5250            .expect("test summary length writes");
5251        mismatched_header_wire
5252            .write_all(&mismatched_header.summary_bytes)
5253            .expect("test summary payload writes");
5254        assert!(matches!(
5255            DiscoveryMessage::decode(&mismatched_header_wire),
5256            Err(DiscoveryWireError::MismatchedSummaryService {
5257                summary_tag: SUMMARY_TAG_HEADER_SYNC,
5258                ..
5259            })
5260        ));
5261
5262        let mismatched_discovery = ServiceSummaryEnvelope {
5263            service_id: ZakuraServiceId::header_sync(),
5264            summary_tag: SUMMARY_TAG_DISCOVERY_V1,
5265            summary_bytes: {
5266                let mut bytes = Vec::new();
5267                encode_discovery_summary(&discovery_summary(), &mut bytes)
5268                    .expect("test discovery summary encodes");
5269                bytes
5270            },
5271        };
5272        assert!(matches!(
5273            services_message(vec![mismatched_discovery]).encode(),
5274            Err(DiscoveryWireError::MismatchedSummaryService {
5275                summary_tag: SUMMARY_TAG_DISCOVERY_V1,
5276                ..
5277            })
5278        ));
5279
5280        let mismatched_block = ServiceSummaryEnvelope {
5281            service_id: ZakuraServiceId::header_sync(),
5282            summary_tag: SUMMARY_TAG_BLOCK_SYNC_V1,
5283            summary_bytes: {
5284                let mut bytes = Vec::new();
5285                encode_block_sync_summary(&block_sync_summary(), &mut bytes)
5286                    .expect("test block summary encodes");
5287                bytes
5288            },
5289        };
5290        assert!(matches!(
5291            services_message(vec![mismatched_block]).encode(),
5292            Err(DiscoveryWireError::MismatchedSummaryService {
5293                summary_tag: SUMMARY_TAG_BLOCK_SYNC_V1,
5294                ..
5295            })
5296        ));
5297    }
5298
5299    #[test]
5300    fn decode_rejects_too_many_records_services_and_excluded_ids_before_allocation() {
5301        let mut peers = vec![MSG_DISCOVERY_PEERS];
5302        peers
5303            .write_u16::<LittleEndian>(
5304                u16::try_from(MAX_DISCOVERY_RECORDS_PER_RESPONSE + 1)
5305                    .expect("test record count fits in u16"),
5306            )
5307            .unwrap();
5308        assert!(matches!(
5309            DiscoveryMessage::decode(&peers),
5310            Err(DiscoveryWireError::OversizedPayload { .. })
5311        ));
5312
5313        let mut query = vec![MSG_DISCOVERY_GET_PEERS];
5314        query.write_u16::<LittleEndian>(1).unwrap();
5315        query
5316            .write_u16::<LittleEndian>(
5317                u16::try_from(MAX_SERVICES_PER_RECORD + 1).expect("test service count fits in u16"),
5318            )
5319            .unwrap();
5320        assert!(matches!(
5321            DiscoveryMessage::decode(&query),
5322            Err(DiscoveryWireError::OversizedPayload { .. })
5323        ));
5324
5325        let mut query = vec![MSG_DISCOVERY_GET_PEERS];
5326        query.write_u16::<LittleEndian>(1).unwrap();
5327        query.write_u16::<LittleEndian>(0).unwrap();
5328        query
5329            .write_u16::<LittleEndian>(
5330                u16::try_from(MAX_DISCOVERY_EXCLUDED_NODE_IDS + 1)
5331                    .expect("test excluded count fits in u16"),
5332            )
5333            .unwrap();
5334        assert!(matches!(
5335            DiscoveryMessage::decode(&query),
5336            Err(DiscoveryWireError::OversizedPayload { .. })
5337        ));
5338
5339        let mut service_query = vec![MSG_DISCOVERY_GET_SERVICES];
5340        service_query
5341            .write_u16::<LittleEndian>(
5342                u16::try_from(MAX_GET_SERVICES_WANTED + 1).expect("test service count fits in u16"),
5343            )
5344            .unwrap();
5345        assert!(matches!(
5346            DiscoveryMessage::decode(&service_query),
5347            Err(DiscoveryWireError::OversizedPayload { .. })
5348        ));
5349
5350        let mut service_response = vec![MSG_DISCOVERY_SERVICES];
5351        service_response
5352            .write_all(secret_key().public().as_bytes())
5353            .unwrap();
5354        service_response
5355            .write_u64::<LittleEndian>(NOW + 30)
5356            .unwrap();
5357        service_response
5358            .write_u16::<LittleEndian>(
5359                u16::try_from(MAX_SERVICE_SUMMARIES_PER_RESPONSE + 1)
5360                    .expect("test summary count fits in u16"),
5361            )
5362            .unwrap();
5363        assert!(matches!(
5364            DiscoveryMessage::decode(&service_response),
5365            Err(DiscoveryWireError::OversizedPayload { .. })
5366        ));
5367
5368        let mut oversized_summary = vec![MSG_DISCOVERY_SERVICES];
5369        oversized_summary
5370            .write_all(secret_key().public().as_bytes())
5371            .unwrap();
5372        oversized_summary
5373            .write_u64::<LittleEndian>(NOW + 30)
5374            .unwrap();
5375        oversized_summary.write_u16::<LittleEndian>(1).unwrap();
5376        encode_service_id(&service(1), &mut oversized_summary).unwrap();
5377        oversized_summary.write_u16::<LittleEndian>(999).unwrap();
5378        oversized_summary
5379            .write_u16::<LittleEndian>(
5380                u16::try_from(MAX_SERVICE_SUMMARY_BYTES + 1).expect("summary cap fits in u16"),
5381            )
5382            .unwrap();
5383        assert!(matches!(
5384            DiscoveryMessage::decode(&oversized_summary),
5385            Err(DiscoveryWireError::OversizedPayload { .. })
5386        ));
5387    }
5388
5389    #[test]
5390    fn peers_response_cannot_encode_live_summaries_structurally() {
5391        let message = DiscoveryMessage::Peers {
5392            records: vec![signed_record()],
5393        };
5394        let encoded = message.encode().expect("peers message encodes");
5395        let decoded = DiscoveryMessage::decode(&encoded).expect("peers message decodes");
5396
5397        match decoded {
5398            DiscoveryMessage::Peers { records } => assert_eq!(records.len(), 1),
5399            DiscoveryMessage::Services(_) => panic!("PEERS decoded as SERVICES"),
5400            _ => panic!("unexpected discovery message variant"),
5401        }
5402    }
5403
5404    #[test]
5405    fn decode_rejects_malformed_record_body_counts_before_allocation() {
5406        let record = signed_record();
5407        let mut body_bytes = Vec::new();
5408        body_bytes
5409            .write_all(record.body.node_id.as_bytes())
5410            .unwrap();
5411        body_bytes
5412            .write_u16::<LittleEndian>(
5413                u16::try_from(MAX_DIRECT_ADDRS_PER_RECORD + 1)
5414                    .expect("test address count fits in u16"),
5415            )
5416            .unwrap();
5417
5418        let mut encoded = vec![MSG_DISCOVERY_HELLO];
5419        encoded
5420            .write_u32::<LittleEndian>(
5421                u32::try_from(body_bytes.len()).expect("test body length fits in u32"),
5422            )
5423            .unwrap();
5424        encoded.write_all(&body_bytes).unwrap();
5425        encoded.write_all(&record.signature.to_bytes()).unwrap();
5426
5427        assert!(matches!(
5428            DiscoveryMessage::decode(&encoded),
5429            Err(DiscoveryWireError::OversizedPayload { .. })
5430        ));
5431
5432        let record = signed_record();
5433        let mut body_bytes = Vec::new();
5434        body_bytes
5435            .write_all(record.body.node_id.as_bytes())
5436            .unwrap();
5437        body_bytes.write_u16::<LittleEndian>(0).unwrap();
5438        body_bytes
5439            .write_u16::<LittleEndian>(
5440                u16::try_from(MAX_SERVICES_PER_RECORD + 1).expect("test service count fits in u16"),
5441            )
5442            .unwrap();
5443
5444        let mut encoded = vec![MSG_DISCOVERY_HELLO];
5445        encoded
5446            .write_u32::<LittleEndian>(
5447                u32::try_from(body_bytes.len()).expect("test body length fits in u32"),
5448            )
5449            .unwrap();
5450        encoded.write_all(&body_bytes).unwrap();
5451        encoded.write_all(&record.signature.to_bytes()).unwrap();
5452
5453        assert!(matches!(
5454            DiscoveryMessage::decode(&encoded),
5455            Err(DiscoveryWireError::OversizedPayload { .. })
5456        ));
5457    }
5458
5459    #[test]
5460    fn signature_fails_when_signed_vectors_are_reordered() {
5461        let record = signed_record();
5462        let signed_bytes = record
5463            .body
5464            .encode_for_signature()
5465            .expect("record pre-image encodes");
5466
5467        let mut reordered_services = record.clone();
5468        reordered_services.body.services.swap(0, 1);
5469        assert_ne!(
5470            reordered_services
5471                .body
5472                .encode_for_signature()
5473                .expect("reordered services pre-image encodes"),
5474            signed_bytes
5475        );
5476        assert!(matches!(
5477            reordered_services.verify(&context()),
5478            Err(DiscoveryRecordError::InvalidSignature)
5479        ));
5480
5481        let mut reordered_addrs = record;
5482        reordered_addrs.body.direct_addrs.swap(0, 1);
5483        assert_ne!(
5484            reordered_addrs
5485                .body
5486                .encode_for_signature()
5487                .expect("reordered addrs pre-image encodes"),
5488            signed_bytes
5489        );
5490        assert!(matches!(
5491            reordered_addrs.verify(&context()),
5492            Err(DiscoveryRecordError::InvalidSignature)
5493        ));
5494    }
5495
5496    #[test]
5497    fn far_future_expiry_and_clock_skew_edges_are_enforced() {
5498        let mut record = signed_record();
5499
5500        record.body.expires_at_unix_secs =
5501            NOW + context().max_record_ttl.as_secs() + context().clock_skew_tolerance.as_secs();
5502        let signing_secret = secret_key();
5503        record = ZakuraNodeRecord::sign(
5504            ZakuraNodeRecordBody {
5505                node_id: signing_secret.public(),
5506                ..record.body
5507            },
5508            &signing_secret,
5509        )
5510        .expect("record signs");
5511        record.verify(&context()).expect("skew edge is accepted");
5512
5513        let signing_secret = secret_key();
5514        let mut too_far = body(&signing_secret);
5515        too_far.expires_at_unix_secs =
5516            NOW + context().max_record_ttl.as_secs() + context().clock_skew_tolerance.as_secs() + 1;
5517        let too_far = ZakuraNodeRecord::sign(too_far, &signing_secret).expect("record signs");
5518        assert!(matches!(
5519            too_far.verify(&context()),
5520            Err(DiscoveryRecordError::FarFutureExpiry)
5521        ));
5522
5523        let signing_secret = secret_key();
5524        let mut just_expired = body(&signing_secret);
5525        just_expired.expires_at_unix_secs = NOW - context().clock_skew_tolerance.as_secs();
5526        let just_expired =
5527            ZakuraNodeRecord::sign(just_expired, &signing_secret).expect("record signs");
5528        just_expired
5529            .verify(&context())
5530            .expect("just expired within skew is accepted");
5531    }
5532
5533    #[test]
5534    fn decode_rejects_trailing_and_unknown_bytes() {
5535        let mut encoded = DiscoveryMessage::Hello {
5536            record: signed_record(),
5537        }
5538        .encode()
5539        .expect("message encodes");
5540        encoded.push(0);
5541        assert!(matches!(
5542            DiscoveryMessage::decode(&encoded),
5543            Err(DiscoveryWireError::TrailingBytes)
5544        ));
5545
5546        assert!(matches!(
5547            DiscoveryMessage::decode(&[99]),
5548            Err(DiscoveryWireError::InvalidMessageType(99))
5549        ));
5550    }
5551
5552    #[test]
5553    fn discovery_book_imports_valid_record() {
5554        let mut book = ZakuraDiscoveryBook::default();
5555        let source = secret_key().public();
5556        let record = signed_record();
5557        let node_id = record.body.node_id;
5558
5559        assert_eq!(
5560            book.import_record(record.clone(), Some(source), NOW, &context())
5561                .expect("valid record imports"),
5562            ImportOutcome::Added
5563        );
5564
5565        let entry = book.get(&node_id).expect("entry was inserted");
5566        assert_eq!(entry.record(), &record);
5567        assert_eq!(entry.source(), Some(source));
5568        assert_eq!(entry.last_seen(), NOW);
5569        assert!(!entry.is_static());
5570    }
5571
5572    #[test]
5573    fn discovery_book_rejects_invalid_signature_expired_wrong_network_and_wrong_chain() {
5574        let mut book = ZakuraDiscoveryBook::default();
5575
5576        let mut invalid_signature = signed_record();
5577        invalid_signature.body.sequence += 1;
5578        assert!(matches!(
5579            book.import_record(invalid_signature, None, NOW, &context()),
5580            Err(DiscoveryBookError::Record(
5581                DiscoveryRecordError::InvalidSignature
5582            ))
5583        ));
5584
5585        let signing_secret = secret_key();
5586        let mut expired = body(&signing_secret);
5587        expired.expires_at_unix_secs = NOW - context().clock_skew_tolerance.as_secs() - 1;
5588        let expired = ZakuraNodeRecord::sign(expired, &signing_secret).expect("record signs");
5589        assert!(matches!(
5590            book.import_record(expired, None, NOW, &context()),
5591            Err(DiscoveryBookError::Record(DiscoveryRecordError::Expired))
5592        ));
5593
5594        let mut wrong_network_context = context();
5595        wrong_network_context.expected_network_id = ZakuraNetworkId::Mainnet;
5596        assert!(matches!(
5597            book.import_record(signed_record(), None, NOW, &wrong_network_context),
5598            Err(DiscoveryBookError::Record(
5599                DiscoveryRecordError::WrongNetwork
5600            ))
5601        ));
5602
5603        let mut wrong_chain_context = context();
5604        wrong_chain_context.expected_chain_id[0] ^= 1;
5605        assert!(matches!(
5606            book.import_record(signed_record(), None, NOW, &wrong_chain_context),
5607            Err(DiscoveryBookError::Record(DiscoveryRecordError::WrongChain))
5608        ));
5609    }
5610
5611    #[test]
5612    fn discovery_book_rejects_self_record_no_usable_address_and_far_future_expiry() {
5613        let local_secret = secret_key();
5614        let local_record = record_with_secret(&local_secret, 1, service(1));
5615        let mut book = ZakuraDiscoveryBook::with_local_node_id(
5616            ZakuraDiscoveryBookLimits::default(),
5617            local_secret.public(),
5618        );
5619
5620        assert!(matches!(
5621            book.import_record(local_record.clone(), None, NOW, &context()),
5622            Err(DiscoveryBookError::SelfRecord)
5623        ));
5624        assert!(book.get(&local_secret.public()).is_none());
5625
5626        let signing_secret = secret_key();
5627        let mut no_addr = body(&signing_secret);
5628        no_addr.direct_addrs = Vec::new();
5629        let no_addr = ZakuraNodeRecord::sign(no_addr, &signing_secret).expect("record signs");
5630        assert!(matches!(
5631            book.import_record(no_addr, None, NOW, &context()),
5632            Err(DiscoveryBookError::NoUsableDirectAddress)
5633        ));
5634
5635        let signing_secret = secret_key();
5636        let mut too_far = body(&signing_secret);
5637        too_far.expires_at_unix_secs =
5638            NOW + context().max_record_ttl.as_secs() + context().clock_skew_tolerance.as_secs() + 1;
5639        let too_far = ZakuraNodeRecord::sign(too_far, &signing_secret).expect("record signs");
5640        assert!(matches!(
5641            book.import_record(too_far, None, NOW, &context()),
5642            Err(DiscoveryBookError::Record(
5643                DiscoveryRecordError::FarFutureExpiry
5644            ))
5645        ));
5646    }
5647
5648    #[test]
5649    fn discovery_book_rejects_non_dialable_direct_addresses() {
5650        let bad_addrs = [
5651            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8233),
5652            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8233),
5653            SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8233),
5654            SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8233),
5655            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)), 8233),
5656            SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)), 8233),
5657            SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), 8233),
5658            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1)), 8233),
5659            SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), 8233),
5660            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 20)), 0),
5661        ];
5662
5663        for (index, bad_addr) in bad_addrs.into_iter().enumerate() {
5664            let mut book = ZakuraDiscoveryBook::default();
5665            let record = signed_record_with_addrs(
5666                u64::try_from(index).expect("small test index fits in u64"),
5667                service(index),
5668                vec![test_addr(20), bad_addr],
5669            );
5670
5671            assert!(matches!(
5672                book.import_record(record, None, NOW, &context()),
5673                Err(DiscoveryBookError::NonDialableDirectAddress { addr }) if addr == bad_addr
5674            ));
5675            assert!(book.is_empty());
5676        }
5677    }
5678
5679    #[test]
5680    fn discovery_book_peer_import_rejects_private_and_internal_direct_addresses() {
5681        // A signed record only proves control of the node key, not ownership of the advertised
5682        // direct addresses. Untrusted gossip must therefore be restricted to globally routable
5683        // targets; otherwise an authenticated discovery peer could seed signed records pointing at
5684        // the honest node's private/internal network and turn the candidate dialer into an internal
5685        // SSRF/scanner. RFC 1918 private, RFC 6598 shared (CGNAT), and RFC 4193 unique-local ranges
5686        // are neither loopback nor link-local, so the original filter let them through.
5687        let bad_addrs = [
5688            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 8233),
5689            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 5, 5)), 8233),
5690            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 8233),
5691            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)), 8233),
5692            SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), 8233),
5693            SocketAddr::new(
5694                IpAddr::V6(Ipv6Addr::new(0xfd12, 0x3456, 0, 0, 0, 0, 0, 1)),
5695                8233,
5696            ),
5697        ];
5698
5699        for (index, bad_addr) in bad_addrs.into_iter().enumerate() {
5700            let mut book = ZakuraDiscoveryBook::default();
5701            // Pair the malicious target with a genuinely routable address to prove the whole
5702            // record is rejected, not just dropped down to its usable addresses.
5703            let record = signed_record_with_addrs(
5704                u64::try_from(index).expect("small test index fits in u64"),
5705                service(index),
5706                vec![test_addr(30), bad_addr],
5707            );
5708
5709            assert!(
5710                matches!(
5711                    book.import_record(record, Some(secret_key().public()), NOW, &context()),
5712                    Err(DiscoveryBookError::NonDialableDirectAddress { addr }) if addr == bad_addr
5713                ),
5714                "untrusted gossip must reject internal direct address {bad_addr}"
5715            );
5716            assert!(book.is_empty());
5717        }
5718
5719        // A genuinely globally routable gossip target still imports.
5720        let mut book = ZakuraDiscoveryBook::default();
5721        let public_record = signed_record_with_addrs(99, service(99), vec![test_addr(40)]);
5722        assert_eq!(
5723            book.import_record(public_record, Some(secret_key().public()), NOW, &context())
5724                .expect("public gossip record imports"),
5725            ImportOutcome::Added
5726        );
5727
5728        // Trusted/static configuration is unchanged: operators may still configure private targets
5729        // for LAN/regtest deployments.
5730        let mut static_book = ZakuraDiscoveryBook::default();
5731        let private_static = signed_record_with_addrs(
5732            1,
5733            service(1),
5734            vec![SocketAddr::new(
5735                IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
5736                8233,
5737            )],
5738        );
5739        assert_eq!(
5740            static_book
5741                .import_static_record(private_static, NOW, &context())
5742                .expect("configured static private record imports"),
5743            ImportOutcome::Added
5744        );
5745    }
5746
5747    #[test]
5748    fn discovery_book_gossiped_public_third_party_record_is_not_dialable() {
5749        let mut book = ZakuraDiscoveryBook::default();
5750        let gossip_source = secret_key().public();
5751        let public_target = signed_record_with_addrs(
5752            1,
5753            service(1),
5754            vec![SocketAddr::new(
5755                IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
5756                8233,
5757            )],
5758        );
5759        let target_id = public_target.body.node_id;
5760
5761        assert_eq!(
5762            book.import_record(public_target.clone(), Some(gossip_source), NOW, &context())
5763                .expect("valid public third-party gossip record still imports"),
5764            ImportOutcome::Added
5765        );
5766        assert_eq!(
5767            book.get(&target_id).expect("entry is stored").record(),
5768            &public_target
5769        );
5770
5771        assert!(
5772            book.dial_candidates(
5773                10,
5774                &[service(1)],
5775                DialCandidateExclusions {
5776                    connected_node_ids: &[],
5777                    in_flight_node_ids: &[],
5778                },
5779                NOW,
5780                (
5781                    DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
5782                    DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
5783                ),
5784                &mut StdRng::seed_from_u64(7),
5785            )
5786            .is_empty(),
5787            "unconfirmed third-party public gossip must not drive native dials"
5788        );
5789
5790        assert_eq!(
5791            book.import_record(public_target.clone(), Some(target_id), NOW + 1, &context())
5792                .expect("first-party self-record confirmation imports"),
5793            ImportOutcome::MetadataUpdated
5794        );
5795        assert_eq!(
5796            book.dial_candidates(
5797                10,
5798                &[service(1)],
5799                DialCandidateExclusions {
5800                    connected_node_ids: &[],
5801                    in_flight_node_ids: &[],
5802                },
5803                NOW + 1,
5804                (
5805                    DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
5806                    DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
5807                ),
5808                &mut StdRng::seed_from_u64(7),
5809            ),
5810            vec![candidate_for(&public_target, false)]
5811        );
5812    }
5813
5814    #[test]
5815    fn discovery_book_static_import_allows_loopback_direct_address() {
5816        let mut book = ZakuraDiscoveryBook::default();
5817        let loopback_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8233);
5818        let record = signed_record_with_addrs(1, service(1), vec![loopback_addr]);
5819        let node_id = record.body.node_id;
5820
5821        assert_eq!(
5822            book.import_static_record(record.clone(), NOW, &context())
5823                .expect("configured static loopback record imports"),
5824            ImportOutcome::Added
5825        );
5826
5827        let entry = book
5828            .get(&node_id)
5829            .expect("static loopback entry was stored");
5830        assert!(entry.is_static());
5831        assert_eq!(entry.record(), &record);
5832    }
5833
5834    #[test]
5835    fn discovery_book_peer_import_still_rejects_loopback_direct_address() {
5836        let mut book = ZakuraDiscoveryBook::default();
5837        let loopback_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8233);
5838        let record = signed_record_with_addrs(1, service(1), vec![loopback_addr]);
5839
5840        assert!(matches!(
5841            book.import_record(record, Some(secret_key().public()), NOW, &context()),
5842            Err(DiscoveryBookError::NonDialableDirectAddress { addr }) if addr == loopback_addr
5843        ));
5844        assert!(book.is_empty());
5845    }
5846
5847    #[test]
5848    fn discovery_book_static_import_rejects_unspecified_and_port_zero_addresses() {
5849        let bad_addrs = [
5850            SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8233),
5851            SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8233),
5852            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)), 8233),
5853            SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)), 8233),
5854            SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), 8233),
5855            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
5856        ];
5857
5858        for (index, bad_addr) in bad_addrs.into_iter().enumerate() {
5859            let mut book = ZakuraDiscoveryBook::default();
5860            let record = signed_record_with_addrs(
5861                u64::try_from(index).expect("small test index fits in u64"),
5862                service(index),
5863                vec![bad_addr],
5864            );
5865
5866            assert!(matches!(
5867                book.import_static_record(record, NOW, &context()),
5868                Err(DiscoveryBookError::NonDialableDirectAddress { addr }) if addr == bad_addr
5869            ));
5870            assert!(book.is_empty());
5871        }
5872    }
5873
5874    #[test]
5875    fn discovery_book_inserts_static_bootstrap_candidate_without_gossiping_it() {
5876        let mut book = ZakuraDiscoveryBook::default();
5877        let node_id = secret_key().public();
5878        let loopback_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8233);
5879        let node_addr = NodeAddr::new(node_id).with_direct_addresses([loopback_addr]);
5880
5881        book.insert_static_candidate(node_addr, NOW)
5882            .expect("configured static loopback candidate imports");
5883
5884        let mut rng = StdRng::seed_from_u64(7);
5885        assert!(book.sample_peers(10, &[], &[], NOW, &mut rng).is_empty());
5886        assert_eq!(
5887            book.dial_candidates(
5888                10,
5889                &[],
5890                DialCandidateExclusions {
5891                    connected_node_ids: &[],
5892                    in_flight_node_ids: &[],
5893                },
5894                NOW,
5895                (
5896                    DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
5897                    DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
5898                ),
5899                &mut rng,
5900            ),
5901            vec![ZakuraDiscoveryDialCandidate {
5902                node_id,
5903                direct_addrs: vec![loopback_addr],
5904                is_static: true,
5905            }]
5906        );
5907    }
5908
5909    #[test]
5910    fn discovery_book_static_bootstrap_candidate_rejects_unusable_addresses() {
5911        let bad_addrs = [
5912            SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8233),
5913            SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8233),
5914            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)), 8233),
5915            SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)), 8233),
5916            SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), 8233),
5917            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
5918        ];
5919
5920        for bad_addr in bad_addrs {
5921            let mut book = ZakuraDiscoveryBook::default();
5922            let node_addr = NodeAddr::new(secret_key().public()).with_direct_addresses([bad_addr]);
5923
5924            assert!(matches!(
5925                book.insert_static_candidate(node_addr, NOW),
5926                Err(DiscoveryBookError::NonDialableDirectAddress { addr }) if addr == bad_addr
5927            ));
5928            assert!(book.is_empty());
5929        }
5930    }
5931
5932    #[test]
5933    fn discovery_book_sequence_rules_keep_newest_record() {
5934        let mut book = ZakuraDiscoveryBook::default();
5935        let secret = secret_key();
5936        let newer = record_with_secret(&secret, 10, service(1));
5937        let older = record_with_secret(&secret, 9, service(2));
5938        let equal = record_with_secret(&secret, 10, service(3));
5939        let node_id = newer.body.node_id;
5940
5941        assert_eq!(
5942            book.import_record(newer.clone(), None, NOW, &context())
5943                .unwrap(),
5944            ImportOutcome::Added
5945        );
5946        assert_eq!(
5947            book.import_record(older, Some(secret_key().public()), NOW + 1, &context())
5948                .unwrap(),
5949            ImportOutcome::IgnoredOlder
5950        );
5951        assert_eq!(
5952            book.get(&node_id).unwrap().record().body.services,
5953            newer.body.services
5954        );
5955
5956        let equal_source = secret_key().public();
5957        assert_eq!(
5958            book.import_record(equal, Some(equal_source), NOW + 2, &context())
5959                .unwrap(),
5960            ImportOutcome::MetadataUpdated
5961        );
5962        let entry = book.get(&node_id).unwrap();
5963        assert_eq!(entry.record().body.services, newer.body.services);
5964        assert_eq!(entry.source(), Some(equal_source));
5965        assert_eq!(entry.last_seen(), NOW + 2);
5966
5967        let newest = record_with_secret(&secret, 11, service(4));
5968        assert_eq!(
5969            book.import_record(newest.clone(), None, NOW + 3, &context())
5970                .unwrap(),
5971            ImportOutcome::Updated
5972        );
5973        assert_eq!(book.get(&node_id).unwrap().record(), &newest);
5974    }
5975
5976    #[test]
5977    fn discovery_book_import_batch_enforces_per_response_cap() {
5978        let mut book = ZakuraDiscoveryBook::new(ZakuraDiscoveryBookLimits {
5979            max_imported_records_per_response: 2,
5980            ..ZakuraDiscoveryBookLimits::default()
5981        });
5982        let records = (1u8..=4)
5983            .map(|index| {
5984                signed_record_with(u64::from(index), service(index as usize), test_addr(index))
5985            })
5986            .collect::<Vec<_>>();
5987
5988        let outcome = book.import_records(records, None, NOW, &context());
5989
5990        assert_eq!(outcome.attempted, 2);
5991        assert_eq!(outcome.added, 2);
5992        assert_eq!(outcome.dropped_for_limit, 2);
5993        assert_eq!(book.discovered_len(), 2);
5994    }
5995
5996    #[test]
5997    fn discovery_book_total_capacity_and_eviction_order_are_enforced() {
5998        let mut book = small_book(2);
5999
6000        let expired_secret = secret_key();
6001        let mut expired_body = body(&expired_secret);
6002        expired_body.sequence = 1;
6003        expired_body.expires_at_unix_secs = NOW - 1;
6004        let expired_context = DiscoveryRecordValidationContext {
6005            current_unix_secs: NOW - context().clock_skew_tolerance.as_secs(),
6006            ..context()
6007        };
6008        let expired = ZakuraNodeRecord::sign(expired_body, &expired_secret).expect("record signs");
6009        let expired_id = expired.body.node_id;
6010        book.import_record(
6011            expired,
6012            None,
6013            NOW - context().clock_skew_tolerance.as_secs(),
6014            &expired_context,
6015        )
6016        .expect("expired-later record imports before eviction time");
6017
6018        let failed = signed_record_with(2, service(2), test_addr(2));
6019        let failed_id = failed.body.node_id;
6020        book.import_record(failed, None, NOW, &context()).unwrap();
6021        for _ in 0..HIGH_DIAL_FAILURE_COUNT {
6022            book.mark_dial_failure(&failed_id, NOW);
6023        }
6024
6025        let survivor = signed_record_with(3, service(3), test_addr(3));
6026        let survivor_id = survivor.body.node_id;
6027        book.import_record(survivor, None, NOW, &context()).unwrap();
6028
6029        assert!(book.get(&expired_id).is_none());
6030        assert!(book.get(&failed_id).is_some());
6031        assert!(book.get(&survivor_id).is_some());
6032
6033        let replacement = signed_record_with(4, service(4), test_addr(4));
6034        let replacement_id = replacement.body.node_id;
6035        book.import_record(replacement, None, NOW, &context())
6036            .unwrap();
6037
6038        assert!(book.get(&failed_id).is_none());
6039        assert!(book.get(&survivor_id).is_some());
6040        assert!(book.get(&replacement_id).is_some());
6041        assert_eq!(book.discovered_len(), 2);
6042    }
6043
6044    #[test]
6045    fn discovery_book_static_entries_survive_eviction_storm() {
6046        let mut book = small_book(1);
6047        let static_record = signed_record_with(1, service(1), test_addr(1));
6048        let static_id = static_record.body.node_id;
6049        book.import_static_record(static_record, NOW, &context())
6050            .expect("static record imports");
6051
6052        for index in 2..=10 {
6053            book.import_record(
6054                signed_record_with(index.into(), service(index as usize), test_addr(index)),
6055                Some(secret_key().public()),
6056                NOW,
6057                &context(),
6058            )
6059            .expect("flood record imports");
6060        }
6061
6062        assert!(book
6063            .get(&static_id)
6064            .expect("static entry remains")
6065            .is_static());
6066        assert_eq!(book.discovered_len(), 1);
6067        assert_eq!(book.len(), 2);
6068    }
6069
6070    #[test]
6071    fn discovery_book_evicts_least_recently_successful_entry() {
6072        let mut book = small_book(2);
6073        let older_success = signed_record_with(1, service(1), test_addr(1));
6074        let recent_success = signed_record_with(2, service(2), test_addr(2));
6075        let replacement = signed_record_with(3, service(3), test_addr(3));
6076        let older_success_id = older_success.body.node_id;
6077        let recent_success_id = recent_success.body.node_id;
6078        let replacement_id = replacement.body.node_id;
6079
6080        let persisted = [older_success, recent_success, replacement]
6081            .into_iter()
6082            .enumerate()
6083            .map(|(index, record)| ZakuraDiscoveryPersistedEntry {
6084                record,
6085                source: None,
6086                is_static: false,
6087                last_seen: NOW,
6088                last_dial_attempt: None,
6089                last_success: Some(
6090                    NOW + u64::try_from(index).expect("small test index fits in u64") + 1,
6091                ),
6092                failure_count: 0,
6093            });
6094
6095        let outcome = book.import_persisted_entries(persisted, NOW, &context());
6096
6097        assert_eq!(outcome.added, 3);
6098        assert!(book.get(&older_success_id).is_none());
6099        assert!(book.get(&recent_success_id).is_some());
6100        assert!(book.get(&replacement_id).is_some());
6101        assert_eq!(book.discovered_len(), 2);
6102    }
6103
6104    #[test]
6105    fn discovery_book_samples_are_bounded_excluded_and_service_filtered() {
6106        let mut book = ZakuraDiscoveryBook::default();
6107        let wanted = service(1);
6108        let other = service(2);
6109        let matching_a = signed_record_with(1, wanted.clone(), test_addr(1));
6110        let matching_b = signed_record_with(2, wanted.clone(), test_addr(2));
6111        let matching_c = signed_record_with(3, wanted.clone(), test_addr(3));
6112        let non_matching = signed_record_with(4, other, test_addr(4));
6113        let excluded = matching_a.body.node_id;
6114        book.import_record(matching_a, None, NOW, &context())
6115            .unwrap();
6116        book.import_record(matching_b, None, NOW, &context())
6117            .unwrap();
6118        book.import_record(matching_c, None, NOW, &context())
6119            .unwrap();
6120        book.import_record(non_matching, None, NOW, &context())
6121            .unwrap();
6122
6123        let mut rng = StdRng::seed_from_u64(7);
6124        let sample =
6125            book.sample_peers(1, std::slice::from_ref(&wanted), &[excluded], NOW, &mut rng);
6126        let sample_ids: HashSet<_> = sample.iter().map(|record| record.body.node_id).collect();
6127
6128        assert_eq!(sample.len(), 1);
6129        assert!(!sample_ids.contains(&excluded));
6130        assert!(sample
6131            .iter()
6132            .all(|record| record.body.services.contains(&wanted)));
6133    }
6134
6135    #[test]
6136    fn discovery_book_samples_skip_expired_records() {
6137        let mut book = ZakuraDiscoveryBook::default();
6138        let record = signed_record_with(1, service(1), test_addr(1));
6139        book.import_record(record, None, NOW, &context()).unwrap();
6140
6141        let mut rng = StdRng::seed_from_u64(7);
6142        let sample = book.sample_peers(10, &[], &[], NOW + 61, &mut rng);
6143
6144        assert!(sample.is_empty());
6145    }
6146
6147    #[test]
6148    fn discovery_book_dial_metadata_updates_candidates_without_blacklisting() {
6149        let mut book = ZakuraDiscoveryBook::default();
6150        let preferred = signed_record_with(1, service(1), test_addr(1));
6151        let failed = signed_record_with(2, service(1), test_addr(2));
6152        let failed_id = failed.body.node_id;
6153        let preferred_id = preferred.body.node_id;
6154
6155        import_confirmed_record(&mut book, failed.clone()).unwrap();
6156        import_confirmed_record(&mut book, preferred.clone()).unwrap();
6157        book.mark_dial_attempt(&failed_id, NOW);
6158        book.mark_dial_failure(&failed_id, NOW);
6159        book.mark_dial_success(&preferred_id, NOW + 1);
6160
6161        let mut rng = StdRng::seed_from_u64(7);
6162        let candidates = book.dial_candidates(
6163            10,
6164            &[service(1)],
6165            DialCandidateExclusions {
6166                connected_node_ids: &[],
6167                in_flight_node_ids: &[],
6168            },
6169            NOW + 1,
6170            (
6171                DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
6172                DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
6173            ),
6174            &mut rng,
6175        );
6176        assert_eq!(candidates, vec![candidate_for(&preferred, false)]);
6177        assert_eq!(book.get(&failed_id).unwrap().failure_count(), 1);
6178
6179        let candidates = book.dial_candidates(
6180            10,
6181            &[service(1)],
6182            DialCandidateExclusions {
6183                connected_node_ids: &[],
6184                in_flight_node_ids: &[],
6185            },
6186            NOW + 60,
6187            (
6188                DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
6189                DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
6190            ),
6191            &mut rng,
6192        );
6193        assert_eq!(
6194            candidates,
6195            vec![
6196                candidate_for(&preferred, false),
6197                candidate_for(&failed, false)
6198            ]
6199        );
6200    }
6201
6202    #[test]
6203    fn discovery_book_randomizes_comparable_non_static_dial_candidates() {
6204        let mut book = ZakuraDiscoveryBook::default();
6205        let records = (1u8..=32)
6206            .map(|index| signed_record_with(index.into(), service(1), test_addr(index)))
6207            .collect::<Vec<_>>();
6208
6209        for record in &records {
6210            import_confirmed_record(&mut book, record.clone()).expect("test record imports");
6211        }
6212
6213        let mut deterministic = records
6214            .iter()
6215            .map(|record| candidate_for(record, false))
6216            .collect::<Vec<_>>();
6217        deterministic.sort_by_key(|candidate| node_id_sort_key(&candidate.node_id));
6218
6219        let mut rng = StdRng::seed_from_u64(11);
6220        let sampled = book.dial_candidates(
6221            records.len(),
6222            &[service(1)],
6223            DialCandidateExclusions {
6224                connected_node_ids: &[],
6225                in_flight_node_ids: &[],
6226            },
6227            NOW,
6228            (
6229                DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
6230                DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
6231            ),
6232            &mut rng,
6233        );
6234        let mut other_rng = StdRng::seed_from_u64(12);
6235        let other_sampled = book.dial_candidates(
6236            records.len(),
6237            &[service(1)],
6238            DialCandidateExclusions {
6239                connected_node_ids: &[],
6240                in_flight_node_ids: &[],
6241            },
6242            NOW,
6243            (
6244                DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
6245                DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
6246            ),
6247            &mut other_rng,
6248        );
6249
6250        let sampled_ids: HashSet<_> = sampled.iter().map(|candidate| candidate.node_id).collect();
6251        assert_eq!(sampled_ids.len(), records.len());
6252        assert_eq!(
6253            sampled_ids,
6254            deterministic
6255                .iter()
6256                .map(|candidate| candidate.node_id)
6257                .collect::<HashSet<_>>()
6258        );
6259        assert_ne!(sampled, deterministic);
6260        assert_ne!(sampled, other_sampled);
6261    }
6262
6263    #[test]
6264    fn discovery_book_dial_backoff_uses_configured_bounds() {
6265        let mut book = ZakuraDiscoveryBook::default();
6266        let failed = signed_record_with(1, service(1), test_addr(1));
6267        let failed_id = failed.body.node_id;
6268
6269        import_confirmed_record(&mut book, failed.clone()).unwrap();
6270        book.mark_dial_attempt(&failed_id, NOW);
6271        book.mark_dial_failure(&failed_id, NOW);
6272
6273        assert!(book
6274            .dial_candidates(
6275                10,
6276                &[service(1)],
6277                DialCandidateExclusions {
6278                    connected_node_ids: &[],
6279                    in_flight_node_ids: &[],
6280                },
6281                NOW + 29,
6282                (Duration::from_secs(30), Duration::from_secs(300)),
6283                &mut StdRng::seed_from_u64(7),
6284            )
6285            .is_empty());
6286        let mut rng = StdRng::seed_from_u64(7);
6287        assert_eq!(
6288            book.dial_candidates(
6289                10,
6290                &[service(1)],
6291                DialCandidateExclusions {
6292                    connected_node_ids: &[],
6293                    in_flight_node_ids: &[],
6294                },
6295                NOW + 30,
6296                (Duration::from_secs(30), Duration::from_secs(300)),
6297                &mut rng,
6298            ),
6299            vec![candidate_for(&failed, false)]
6300        );
6301    }
6302
6303    #[test]
6304    fn discovery_book_short_lived_exchange_backoff_is_non_punitive() {
6305        let mut book = ZakuraDiscoveryBook::default();
6306        let exchanged = signed_record_with(1, service(1), test_addr(1));
6307        let exchanged_id = exchanged.body.node_id;
6308
6309        import_confirmed_record(&mut book, exchanged.clone()).unwrap();
6310        book.mark_dial_success(&exchanged_id, NOW);
6311        book.mark_short_lived_exchange(&exchanged_id, NOW);
6312
6313        let entry = book.get(&exchanged_id).unwrap();
6314        assert_eq!(entry.failure_count(), 0);
6315        assert_eq!(entry.last_short_lived_exchange(), Some(NOW));
6316
6317        assert!(book
6318            .dial_candidates(
6319                10,
6320                &[service(1)],
6321                DialCandidateExclusions {
6322                    connected_node_ids: &[],
6323                    in_flight_node_ids: &[],
6324                },
6325                NOW + 29,
6326                (Duration::from_secs(30), Duration::from_secs(300)),
6327                &mut StdRng::seed_from_u64(7),
6328            )
6329            .is_empty());
6330        assert_eq!(
6331            book.dial_candidates(
6332                10,
6333                &[service(1)],
6334                DialCandidateExclusions {
6335                    connected_node_ids: &[],
6336                    in_flight_node_ids: &[],
6337                },
6338                NOW + 30,
6339                (Duration::from_secs(30), Duration::from_secs(300)),
6340                &mut StdRng::seed_from_u64(7),
6341            ),
6342            vec![candidate_for(&exchanged, false)]
6343        );
6344    }
6345
6346    #[test]
6347    fn discovery_book_services_and_persistence_hooks_revalidate_records() {
6348        let mut book = ZakuraDiscoveryBook::default();
6349        let record = signed_record_with(1, service(9), test_addr(9));
6350        let node_id = record.body.node_id;
6351        import_confirmed_record(&mut book, record.clone()).unwrap();
6352        book.mark_dial_attempt(&node_id, NOW + 1);
6353        book.mark_dial_failure(&node_id, NOW + 1);
6354
6355        assert_eq!(book.services_for(&node_id), Some(vec![service(9)]));
6356
6357        let mut persisted = book.persisted_entries();
6358        persisted.push(ZakuraDiscoveryPersistedEntry {
6359            record: {
6360                let mut invalid = record.clone();
6361                invalid.body.sequence += 1;
6362                invalid
6363            },
6364            source: None,
6365            is_static: false,
6366            last_seen: NOW,
6367            last_dial_attempt: None,
6368            last_success: None,
6369            failure_count: 0,
6370        });
6371
6372        let mut reloaded = ZakuraDiscoveryBook::default();
6373        let outcome = reloaded.import_persisted_entries(persisted, NOW, &context());
6374
6375        assert_eq!(outcome.added, 1);
6376        assert_eq!(outcome.rejected, 1);
6377        let reloaded_entry = reloaded
6378            .get(&node_id)
6379            .expect("valid persisted entry reloads");
6380        assert_eq!(reloaded_entry.record(), &record);
6381        assert_eq!(reloaded_entry.last_dial_attempt(), Some(NOW + 1));
6382        assert_eq!(reloaded_entry.failure_count(), 1);
6383    }
6384
6385    #[test]
6386    fn self_record_includes_local_node_addresses_services_and_verifies() {
6387        let secret = secret_key();
6388        let node_id = secret.public();
6389        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6390        let handle = discovery_handle_at(
6391            local_config_with(
6392                secret,
6393                vec![test_addr(42)],
6394                vec![
6395                    ZakuraServiceId::legacy_requests(),
6396                    ZakuraServiceId::discovery(),
6397                ],
6398            ),
6399            ZakuraDiscoveryConfig::default(),
6400            connected_rx,
6401            NOW,
6402        );
6403
6404        let record = handle.current_self_record();
6405
6406        assert_eq!(record.body.node_id, node_id);
6407        assert_eq!(record.body.direct_addrs, vec![test_addr(42)]);
6408        assert_eq!(
6409            record.body.services,
6410            vec![
6411                ZakuraServiceId::discovery(),
6412                ZakuraServiceId::legacy_requests()
6413            ]
6414        );
6415        record.verify(&context()).expect("self-record verifies");
6416    }
6417
6418    #[tokio::test]
6419    async fn self_record_sequence_increases_when_services_change() {
6420        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6421        let handle = discovery_handle_at(
6422            local_config_with(secret_key(), vec![test_addr(43)], vec![service(1)]),
6423            ZakuraDiscoveryConfig::default(),
6424            connected_rx,
6425            NOW,
6426        );
6427        let initial = handle.current_self_record();
6428
6429        let updated = handle
6430            .update_advertised_services(vec![service(2)])
6431            .await
6432            .expect("updated self-record signs");
6433
6434        assert!(updated.body.sequence > initial.body.sequence);
6435        assert_eq!(updated.body.services, vec![service(2)]);
6436        updated
6437            .verify(&DiscoveryRecordValidationContext {
6438                current_unix_secs: current_unix_secs(),
6439                ..context()
6440            })
6441            .expect("updated record verifies");
6442    }
6443
6444    #[test]
6445    fn derived_sequence_does_not_regress_after_restart() {
6446        let secret = secret_key();
6447        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6448        let first = discovery_handle_at(
6449            local_config_with(secret.clone(), vec![test_addr(44)], vec![service(1)]),
6450            ZakuraDiscoveryConfig::default(),
6451            connected_rx,
6452            NOW,
6453        );
6454        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6455        let restarted = discovery_handle_at(
6456            local_config_with(secret, vec![test_addr(44)], vec![service(1)]),
6457            ZakuraDiscoveryConfig::default(),
6458            connected_rx,
6459            NOW + 1,
6460        );
6461
6462        assert!(
6463            restarted.current_self_record().body.sequence
6464                > first.current_self_record().body.sequence
6465        );
6466        assert_eq!(
6467            restarted.current_self_record().body.node_id,
6468            first.current_self_record().body.node_id
6469        );
6470    }
6471
6472    #[test]
6473    fn persisted_sequence_seed_makes_fast_restart_record_newer() {
6474        let secret = secret_key();
6475        let same_unix_second = NOW;
6476        let same_sequence_tick = 10_000_000_000;
6477        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6478        let first = discovery_handle_at_with_sequence(
6479            local_config_with(secret.clone(), vec![test_addr(44)], vec![service(1)]),
6480            ZakuraDiscoveryConfig::default(),
6481            connected_rx,
6482            same_unix_second,
6483            same_sequence_tick,
6484        );
6485        let first_record = first.current_self_record();
6486
6487        let mut restarted_config = local_config_with(secret, vec![test_addr(45)], vec![service(2)]);
6488        restarted_config.last_authored_sequence = Some(first_record.body.sequence);
6489        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6490        let restarted = discovery_handle_at_with_sequence(
6491            restarted_config,
6492            ZakuraDiscoveryConfig::default(),
6493            connected_rx,
6494            same_unix_second,
6495            same_sequence_tick,
6496        );
6497        let restarted_record = restarted.current_self_record();
6498
6499        assert_eq!(restarted_record.body.node_id, first_record.body.node_id);
6500        assert!(restarted_record.body.sequence > first_record.body.sequence);
6501        assert_eq!(restarted_record.body.direct_addrs, vec![test_addr(45)]);
6502        assert_eq!(restarted_record.body.services, vec![service(2)]);
6503    }
6504
6505    #[test]
6506    fn self_record_filters_non_routable_direct_addresses() {
6507        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6508        let handle = discovery_handle_at(
6509            local_config_with(
6510                secret_key(),
6511                vec![
6512                    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8233),
6513                    SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8233),
6514                    SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8233),
6515                    SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8233),
6516                    test_addr(45),
6517                ],
6518                vec![service(1)],
6519            ),
6520            ZakuraDiscoveryConfig::default(),
6521            connected_rx,
6522            NOW,
6523        );
6524
6525        assert_eq!(
6526            handle.current_self_record().body.direct_addrs,
6527            vec![test_addr(45)]
6528        );
6529    }
6530
6531    #[tokio::test]
6532    async fn active_services_follow_supervisor_watch_connect_and_disconnect() {
6533        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6534        let handle = discovery_handle_with_connected(connected_rx);
6535        let record = runtime_record_with(1, service(9), test_addr(9));
6536        let node_id = record.body.node_id;
6537        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6538        handle
6539            .import_connected_peer_record(record, node_id)
6540            .await
6541            .expect("connected self-record imports");
6542
6543        assert_eq!(
6544            handle.active_services(node_id).await,
6545            Some(vec![service(9)])
6546        );
6547
6548        connected_tx.send_replace(Vec::new());
6549        assert_eq!(handle.active_services(node_id).await, None);
6550    }
6551
6552    #[tokio::test]
6553    async fn active_services_drop_connected_hello_absent_from_supervisor_watch() {
6554        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
6555        let handle = discovery_handle_with_connected(connected_rx);
6556        let record = runtime_record_with(1, service(9), test_addr(9));
6557        let node_id = record.body.node_id;
6558        handle
6559            .import_connected_peer_record(record, node_id)
6560            .await
6561            .expect("connected self-record imports");
6562
6563        assert!(handle
6564            .service_candidates(&service(9), false, &[])
6565            .await
6566            .connected
6567            .is_empty());
6568        assert_eq!(handle.active_services(node_id).await, None);
6569        assert!(handle.inner.lock().await.active_services.is_empty());
6570    }
6571
6572    #[tokio::test]
6573    async fn gossiped_service_record_does_not_create_active_services() {
6574        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6575        let handle = discovery_handle_with_connected(connected_rx);
6576        let record = runtime_record_with(1, service(9), test_addr(9));
6577        let node_id = record.body.node_id;
6578
6579        handle.import_peer_records([record], None).await;
6580        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6581
6582        assert_eq!(handle.active_services(node_id).await, None);
6583    }
6584
6585    #[tokio::test]
6586    async fn connected_services_import_creates_and_updates_first_party_live_summary() {
6587        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6588        let handle = discovery_handle_with_connected(connected_rx);
6589        let node_id = secret_key().public();
6590        let now = current_unix_secs();
6591        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6592
6593        let first_summary = DiscoveryServiceSummary {
6594            peer_exchange_slots_free: 1,
6595            ..discovery_summary()
6596        };
6597        handle
6598            .import_connected_peer_services_at(
6599                first_party_services(
6600                    node_id,
6601                    u64::MAX,
6602                    vec![ServiceSummaryEnvelope::discovery(&first_summary)
6603                        .expect("test discovery summary encodes")],
6604                ),
6605                node_id,
6606                now,
6607            )
6608            .await
6609            .expect("first-party services import");
6610
6611        assert_eq!(
6612            handle.active_services(node_id).await,
6613            Some(vec![ZakuraServiceId::discovery()])
6614        );
6615        let cached = handle
6616            .live_service_summaries_at(node_id, now)
6617            .await
6618            .expect("live summary entry exists");
6619        assert_eq!(cached.len(), 1);
6620        assert_eq!(cached[0].observed_at_unix_secs, now);
6621        assert_eq!(
6622            cached[0].expires_at_unix_secs,
6623            now + DEFAULT_LIVE_SERVICE_SUMMARY_TTL.as_secs()
6624        );
6625        assert_eq!(
6626            cached[0].summary,
6627            ZakuraLiveServiceSummary::Discovery(first_summary)
6628        );
6629
6630        let updated_summary = DiscoveryServiceSummary {
6631            peer_exchange_slots_free: 9,
6632            ..discovery_summary()
6633        };
6634        handle
6635            .import_connected_peer_services_at(
6636                first_party_services(
6637                    node_id,
6638                    now + 10,
6639                    vec![ServiceSummaryEnvelope::discovery(&updated_summary)
6640                        .expect("test discovery summary encodes")],
6641                ),
6642                node_id,
6643                now + 1,
6644            )
6645            .await
6646            .expect("newer first-party services update");
6647
6648        let cached = handle
6649            .live_service_summaries_at(node_id, now + 1)
6650            .await
6651            .expect("updated live summary entry exists");
6652        assert_eq!(cached.len(), 1);
6653        assert_eq!(cached[0].observed_at_unix_secs, now + 1);
6654        assert_eq!(cached[0].expires_at_unix_secs, now + 10);
6655        assert_eq!(
6656            cached[0].summary,
6657            ZakuraLiveServiceSummary::Discovery(updated_summary)
6658        );
6659    }
6660
6661    #[tokio::test]
6662    async fn connected_services_mismatched_node_id_rejected_without_live_summary() {
6663        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6664        let handle = discovery_handle_with_connected(connected_rx);
6665        let peer_node_id = secret_key().public();
6666        let claimed_node_id = secret_key().public();
6667        connected_tx.send_replace(vec![peer_id_for(peer_node_id)]);
6668
6669        let error = handle
6670            .import_connected_peer_services_at(
6671                first_party_services(
6672                    claimed_node_id,
6673                    NOW + 30,
6674                    vec![ServiceSummaryEnvelope::discovery(&discovery_summary())
6675                        .expect("test discovery summary encodes")],
6676                ),
6677                peer_node_id,
6678                NOW,
6679            )
6680            .await
6681            .expect_err("mismatched services are rejected");
6682
6683        assert!(matches!(
6684            error,
6685            DiscoveryWireError::MismatchedNodeId {
6686                field: "services node id"
6687            }
6688        ));
6689        assert_eq!(
6690            handle.live_service_summaries_at(peer_node_id, NOW).await,
6691            None
6692        );
6693        assert_eq!(
6694            handle.live_service_summaries_at(claimed_node_id, NOW).await,
6695            None
6696        );
6697    }
6698
6699    #[tokio::test]
6700    async fn peers_response_and_gossiped_record_do_not_create_live_summary() {
6701        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6702        let handle = discovery_handle_with_connected(connected_rx);
6703        let record = runtime_record_with(1, service(9), test_addr(9));
6704        let node_id = record.body.node_id;
6705        let message = DiscoveryMessage::Peers {
6706            records: vec![record],
6707        };
6708        let encoded = message.encode().expect("test peers response encodes");
6709        let DiscoveryMessage::Peers { records } =
6710            DiscoveryMessage::decode(&encoded).expect("test peers response decodes")
6711        else {
6712            panic!("PEERS response decoded as a different message");
6713        };
6714
6715        handle.import_peer_records(records, None).await;
6716        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6717
6718        assert_eq!(handle.live_service_summaries_at(node_id, NOW).await, None);
6719        assert_eq!(handle.active_services(node_id).await, None);
6720        assert!(handle.record_for(node_id).await.is_some());
6721    }
6722
6723    #[tokio::test]
6724    async fn active_services_prefer_latest_connected_hello_over_book_record() {
6725        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6726        let handle = discovery_handle_with_connected(connected_rx);
6727        let secret = secret_key();
6728        let active_record = runtime_record_with_secret(&secret, 1, service(1), test_addr(1));
6729        let stale_book_record = runtime_record_with_secret(&secret, 2, service(2), test_addr(2));
6730        let node_id = active_record.body.node_id;
6731
6732        handle
6733            .import_connected_peer_record(active_record, node_id)
6734            .await
6735            .expect("connected self-record imports");
6736        handle
6737            .import_peer_record(stale_book_record, Some(secret_key().public()))
6738            .await
6739            .expect("newer third-party record imports into book");
6740        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6741
6742        assert_eq!(
6743            handle.active_services(node_id).await,
6744            Some(vec![service(1)])
6745        );
6746        assert_eq!(
6747            handle
6748                .service_candidates(&service(1), false, &[])
6749                .await
6750                .connected,
6751            vec![node_id]
6752        );
6753    }
6754
6755    #[tokio::test]
6756    async fn active_services_ignore_stale_and_equal_sequence_conflicting_hellos() {
6757        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6758        let handle = discovery_handle_with_connected(connected_rx);
6759        let secret = secret_key();
6760        let newer = runtime_record_with_secret(&secret, 2, service(2), test_addr(2));
6761        let older = runtime_record_with_secret(&secret, 1, service(1), test_addr(1));
6762        let equal_conflicting = runtime_record_with_secret(&secret, 2, service(3), test_addr(3));
6763        let stored_newer = runtime_record_with_secret(&secret, 4, service(4), test_addr(4));
6764        let older_than_stored = runtime_record_with_secret(&secret, 3, service(3), test_addr(3));
6765        let node_id = newer.body.node_id;
6766        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6767
6768        handle
6769            .import_connected_peer_record(newer, node_id)
6770            .await
6771            .expect("newer connected self-record imports");
6772        assert_eq!(
6773            handle
6774                .import_connected_peer_record(older, node_id)
6775                .await
6776                .expect("older connected self-record verifies and is ignored"),
6777            ImportOutcome::IgnoredOlder
6778        );
6779        assert_eq!(
6780            handle
6781                .import_connected_peer_record(equal_conflicting, node_id)
6782                .await
6783                .expect("equal-sequence connected self-record refreshes metadata only"),
6784            ImportOutcome::MetadataUpdated
6785        );
6786        handle
6787            .import_peer_record(stored_newer, Some(secret_key().public()))
6788            .await
6789            .expect("newer third-party record imports into book");
6790        assert_eq!(
6791            handle
6792                .import_connected_peer_record(older_than_stored, node_id)
6793                .await
6794                .expect("connected self-record older than the book is ignored"),
6795            ImportOutcome::IgnoredOlder
6796        );
6797
6798        assert_eq!(
6799            handle.active_services(node_id).await,
6800            Some(vec![service(2)])
6801        );
6802        assert_eq!(
6803            handle
6804                .service_candidates(&service(2), false, &[])
6805                .await
6806                .connected,
6807            vec![node_id]
6808        );
6809        assert!(handle
6810            .service_candidates(&service(3), false, &[])
6811            .await
6812            .connected
6813            .is_empty());
6814    }
6815
6816    #[tokio::test]
6817    async fn addressless_active_services_ignore_stale_and_equal_sequence_conflicting_hellos() {
6818        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6819        let handle = discovery_handle_with_connected(connected_rx);
6820        let secret = secret_key();
6821        let newer = runtime_record_with_secret_and_addrs(&secret, 2, service(2), Vec::new());
6822        let older = runtime_record_with_secret_and_addrs(&secret, 1, service(1), Vec::new());
6823        let equal_conflicting =
6824            runtime_record_with_secret_and_addrs(&secret, 2, service(3), Vec::new());
6825        let node_id = newer.body.node_id;
6826        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6827
6828        assert!(matches!(
6829            handle.import_connected_peer_record(newer, node_id).await,
6830            Err(DiscoveryBookError::NoUsableDirectAddress)
6831        ));
6832        assert!(matches!(
6833            handle.import_connected_peer_record(older, node_id).await,
6834            Err(DiscoveryBookError::NoUsableDirectAddress)
6835        ));
6836        assert!(matches!(
6837            handle
6838                .import_connected_peer_record(equal_conflicting, node_id)
6839                .await,
6840            Err(DiscoveryBookError::NoUsableDirectAddress)
6841        ));
6842
6843        assert_eq!(
6844            handle.active_services(node_id).await,
6845            Some(vec![service(2)])
6846        );
6847        assert_eq!(
6848            handle
6849                .service_candidates(&service(2), false, &[])
6850                .await
6851                .connected,
6852            vec![node_id]
6853        );
6854        assert!(handle
6855            .service_candidates(&service(3), false, &[])
6856            .await
6857            .connected
6858            .is_empty());
6859    }
6860
6861    #[tokio::test]
6862    async fn expired_connected_hello_does_not_create_active_services() {
6863        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6864        let handle = discovery_handle_with_connected(connected_rx);
6865        let secret = secret_key();
6866        let mut body = body(&secret);
6867        body.sequence = 1;
6868        body.direct_addrs = vec![test_addr(1)];
6869        body.services = vec![service(1)];
6870        body.expires_at_unix_secs = current_unix_secs()
6871            .saturating_sub(DEFAULT_DISCOVERY_CLOCK_SKEW_TOLERANCE.as_secs())
6872            .saturating_sub(1);
6873        let expired = ZakuraNodeRecord::sign(body, &secret).expect("expired record signs");
6874        let node_id = expired.body.node_id;
6875        connected_tx.send_replace(vec![peer_id_for(node_id)]);
6876
6877        assert!(matches!(
6878            handle.import_connected_peer_record(expired, node_id).await,
6879            Err(DiscoveryBookError::Record(DiscoveryRecordError::Expired))
6880        ));
6881        assert_eq!(handle.active_services(node_id).await, None);
6882        assert!(handle
6883            .service_candidates(&service(1), false, &[])
6884            .await
6885            .connected
6886            .is_empty());
6887    }
6888
6889    #[tokio::test]
6890    async fn service_candidates_require_explicit_general_fallback() {
6891        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6892        let handle = discovery_handle_at(
6893            local_config_with(secret_key(), vec![test_addr(49)], vec![service(1)]),
6894            ZakuraDiscoveryConfig {
6895                max_zakura_connections: 8,
6896                discovery_connection_headroom: 1,
6897                ..ZakuraDiscoveryConfig::default()
6898            },
6899            connected_rx,
6900            NOW,
6901        );
6902        let active = runtime_record_with(1, service(1), test_addr(1));
6903        let active_id = active.body.node_id;
6904        let discovered = runtime_record_with(2, service(1), test_addr(2));
6905        let general = runtime_record_with(3, service(3), test_addr(3));
6906        handle
6907            .import_connected_peer_record(active, active_id)
6908            .await
6909            .expect("connected self-record imports");
6910        handle
6911            .import_peer_record(discovered.clone(), Some(discovered.body.node_id))
6912            .await
6913            .expect("discovered first-party record imports");
6914        handle
6915            .import_peer_record(general.clone(), Some(general.body.node_id))
6916            .await
6917            .expect("general first-party record imports");
6918        connected_tx.send_replace(vec![peer_id_for(active_id)]);
6919
6920        let matching = handle.service_candidates(&service(1), true, &[]).await;
6921        assert_eq!(matching.connected, vec![active_id]);
6922        assert_eq!(matching.discovered, vec![candidate_for(&discovered, false)]);
6923        assert!(!matching.used_fallback);
6924
6925        assert_eq!(
6926            handle
6927                .service_candidates(&service(2), false, &[])
6928                .await
6929                .discovered,
6930            Vec::<ZakuraDiscoveryDialCandidate>::new()
6931        );
6932
6933        let fallback = handle
6934            .service_candidates(&service(2), true, &[discovered.body.node_id])
6935            .await;
6936        assert!(fallback.connected.is_empty());
6937        assert_eq!(fallback.discovered, vec![candidate_for(&general, false)]);
6938        assert!(fallback.used_fallback);
6939    }
6940
6941    #[tokio::test]
6942    async fn live_first_party_summary_prioritizes_service_candidates_until_stale() {
6943        let (connected_tx, connected_rx) = watch::channel(Vec::new());
6944        let handle = discovery_handle_with_connected(connected_rx);
6945        let now = current_unix_secs();
6946        let first = runtime_record_with(1, ZakuraServiceId::header_sync(), test_addr(1));
6947        let second = runtime_record_with(2, ZakuraServiceId::header_sync(), test_addr(2));
6948        let first_id = first.body.node_id;
6949        let second_id = second.body.node_id;
6950        connected_tx.send_replace(vec![peer_id_for(first_id), peer_id_for(second_id)]);
6951
6952        handle
6953            .import_connected_peer_record(first, first_id)
6954            .await
6955            .expect("first connected self-record imports");
6956        handle
6957            .import_connected_peer_record(second, second_id)
6958            .await
6959            .expect("second connected self-record imports");
6960
6961        let initial = handle
6962            .service_candidates(&ZakuraServiceId::header_sync(), false, &[])
6963            .await
6964            .connected;
6965        assert_eq!(initial.len(), 2);
6966        let preferred_id = initial[1];
6967        let mut preferred_summary = header_summary();
6968        preferred_summary.inbound_slots_free = 8;
6969        preferred_summary.outbound_slots_free = 8;
6970
6971        handle
6972            .import_connected_peer_services_at(
6973                first_party_services(
6974                    preferred_id,
6975                    now + 30,
6976                    vec![ServiceSummaryEnvelope::header_sync(&preferred_summary)
6977                        .expect("test header summary encodes")],
6978                ),
6979                preferred_id,
6980                now,
6981            )
6982            .await
6983            .expect("first-party header summary imports");
6984
6985        let with_live_summary = handle
6986            .service_candidates(&ZakuraServiceId::header_sync(), false, &[])
6987            .await
6988            .connected;
6989        assert_eq!(with_live_summary[0], preferred_id);
6990
6991        handle
6992            .import_connected_peer_services_at(
6993                first_party_services(
6994                    preferred_id,
6995                    now,
6996                    vec![ServiceSummaryEnvelope::header_sync(&preferred_summary)
6997                        .expect("test header summary encodes")],
6998                ),
6999                preferred_id,
7000                now,
7001            )
7002            .await
7003            .expect("expired first-party header summary is dropped");
7004
7005        assert_eq!(
7006            handle
7007                .service_candidates(&ZakuraServiceId::header_sync(), false, &[])
7008                .await
7009                .connected,
7010            initial
7011        );
7012    }
7013
7014    #[tokio::test]
7015    async fn header_sync_candidates_prefer_useful_first_party_height() {
7016        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7017        let handle = discovery_handle_with_connected(connected_rx);
7018        let now = current_unix_secs();
7019        let without_summary = runtime_record_with(1, ZakuraServiceId::header_sync(), test_addr(1));
7020        let with_summary = runtime_record_with(2, ZakuraServiceId::header_sync(), test_addr(2));
7021        let without_summary_id = without_summary.body.node_id;
7022        let with_summary_id = with_summary.body.node_id;
7023        connected_tx.send_replace(vec![
7024            peer_id_for(without_summary_id),
7025            peer_id_for(with_summary_id),
7026        ]);
7027
7028        handle
7029            .import_connected_peer_record(without_summary, without_summary_id)
7030            .await
7031            .expect("connected record imports");
7032        handle
7033            .import_connected_peer_record(with_summary, with_summary_id)
7034            .await
7035            .expect("connected record imports");
7036
7037        let mut summary = header_summary();
7038        summary.best_height = block::Height(20);
7039        summary.inbound_slots_free = 1;
7040        handle
7041            .import_connected_peer_services_at(
7042                first_party_services(
7043                    with_summary_id,
7044                    now + 30,
7045                    vec![ServiceSummaryEnvelope::header_sync(&summary)
7046                        .expect("test header summary encodes")],
7047                ),
7048                with_summary_id,
7049                now,
7050            )
7051            .await
7052            .expect("first-party header summary imports");
7053
7054        let candidates = handle
7055            .header_sync_candidates(
7056                &ZakuraHeaderSyncCandidateState {
7057                    target_height: block::Height(10),
7058                    ..ZakuraHeaderSyncCandidateState::default()
7059                },
7060                false,
7061                &[],
7062            )
7063            .await;
7064
7065        assert_eq!(candidates.connected[0], with_summary_id);
7066        assert!(candidates.connected.contains(&without_summary_id));
7067    }
7068
7069    #[tokio::test]
7070    async fn retired_header_summary_scores_only_dual_stack_header_sync_candidates() {
7071        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7072        let handle = discovery_handle_with_connected(connected_rx);
7073        let now = current_unix_secs();
7074        let v1_only = runtime_record_with(1, ZakuraServiceId::header_sync_retired(), test_addr(1));
7075        let dual_stack = runtime_record_with_services(
7076            2,
7077            vec![
7078                ZakuraServiceId::header_sync_retired(),
7079                ZakuraServiceId::header_sync(),
7080            ],
7081            test_addr(2),
7082        );
7083        let current_only = runtime_record_with(3, ZakuraServiceId::header_sync(), test_addr(3));
7084        let v1_only_id = v1_only.body.node_id;
7085        let dual_stack_id = dual_stack.body.node_id;
7086        let current_only_id = current_only.body.node_id;
7087        connected_tx.send_replace(vec![
7088            peer_id_for(v1_only_id),
7089            peer_id_for(dual_stack_id),
7090            peer_id_for(current_only_id),
7091        ]);
7092
7093        for record in [v1_only, dual_stack, current_only] {
7094            let node_id = record.body.node_id;
7095            handle
7096                .import_connected_peer_record(record, node_id)
7097                .await
7098                .expect("connected record imports");
7099        }
7100
7101        let mut summary = header_summary();
7102        summary.best_height = block::Height(20);
7103        summary.inbound_slots_free = 8;
7104        for node_id in [v1_only_id, dual_stack_id] {
7105            handle
7106                .import_connected_peer_services_at(
7107                    first_party_services(
7108                        node_id,
7109                        now + 30,
7110                        vec![retired_header_sync_envelope(&summary)],
7111                    ),
7112                    node_id,
7113                    now,
7114                )
7115                .await
7116                .expect("retired first-party header summary imports");
7117        }
7118
7119        let generic = handle
7120            .service_candidates(&ZakuraServiceId::header_sync(), false, &[])
7121            .await;
7122        assert_eq!(generic.connected[0], dual_stack_id);
7123        assert!(generic.connected.contains(&current_only_id));
7124        assert!(!generic.connected.contains(&v1_only_id));
7125
7126        let candidates = handle
7127            .header_sync_candidates(
7128                &ZakuraHeaderSyncCandidateState {
7129                    target_height: block::Height(10),
7130                    ..ZakuraHeaderSyncCandidateState::default()
7131                },
7132                false,
7133                &[],
7134            )
7135            .await;
7136
7137        assert_eq!(candidates.connected[0], dual_stack_id);
7138        assert!(candidates.connected.contains(&current_only_id));
7139        assert!(!candidates.connected.contains(&v1_only_id));
7140    }
7141
7142    #[tokio::test]
7143    async fn header_sync_candidates_skip_full_inbound_unless_already_admitted() {
7144        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7145        let handle = discovery_handle_with_connected(connected_rx);
7146        let now = current_unix_secs();
7147        let full = runtime_record_with(1, ZakuraServiceId::header_sync(), test_addr(1));
7148        let open = runtime_record_with(2, ZakuraServiceId::header_sync(), test_addr(2));
7149        let full_id = full.body.node_id;
7150        let open_id = open.body.node_id;
7151        connected_tx.send_replace(vec![peer_id_for(full_id), peer_id_for(open_id)]);
7152
7153        handle
7154            .import_connected_peer_record(full, full_id)
7155            .await
7156            .expect("connected record imports");
7157        handle
7158            .import_connected_peer_record(open, open_id)
7159            .await
7160            .expect("connected record imports");
7161
7162        let mut full_summary = header_summary();
7163        full_summary.best_height = block::Height(20);
7164        full_summary.inbound_slots_free = 0;
7165        let mut open_summary = full_summary;
7166        open_summary.inbound_slots_free = 2;
7167        for (node_id, summary) in [(full_id, full_summary), (open_id, open_summary)] {
7168            handle
7169                .import_connected_peer_services_at(
7170                    first_party_services(
7171                        node_id,
7172                        now + 30,
7173                        vec![ServiceSummaryEnvelope::header_sync(&summary)
7174                            .expect("test header summary encodes")],
7175                    ),
7176                    node_id,
7177                    now,
7178                )
7179                .await
7180                .expect("first-party header summary imports");
7181        }
7182
7183        let candidates = handle
7184            .header_sync_candidates(
7185                &ZakuraHeaderSyncCandidateState {
7186                    target_height: block::Height(10),
7187                    ..ZakuraHeaderSyncCandidateState::default()
7188                },
7189                false,
7190                &[],
7191            )
7192            .await;
7193        assert_eq!(candidates.connected, vec![open_id]);
7194
7195        let candidates = handle
7196            .header_sync_candidates(
7197                &ZakuraHeaderSyncCandidateState {
7198                    target_height: block::Height(10),
7199                    admitted_node_ids: vec![full_id],
7200                    ..ZakuraHeaderSyncCandidateState::default()
7201                },
7202                false,
7203                &[],
7204            )
7205            .await;
7206        assert!(candidates.connected.contains(&full_id));
7207        assert!(candidates.connected.contains(&open_id));
7208    }
7209
7210    #[tokio::test]
7211    async fn block_sync_service_candidates_prefer_covering_free_slot_summary() {
7212        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7213        let handle = discovery_handle_with_connected(connected_rx);
7214        let now = current_unix_secs();
7215        let no_summary = runtime_record_with(1, ZakuraServiceId::block_sync(), test_addr(1));
7216        let covering = runtime_record_with(2, ZakuraServiceId::block_sync(), test_addr(2));
7217        let prefix = runtime_record_with(3, ZakuraServiceId::block_sync(), test_addr(3));
7218        let full = runtime_record_with(4, ZakuraServiceId::block_sync(), test_addr(4));
7219        let not_block_sync = runtime_record_with(5, ZakuraServiceId::header_sync(), test_addr(5));
7220        let no_summary_id = no_summary.body.node_id;
7221        let covering_id = covering.body.node_id;
7222        let prefix_id = prefix.body.node_id;
7223        let full_id = full.body.node_id;
7224        let not_block_sync_id = not_block_sync.body.node_id;
7225        connected_tx.send_replace(vec![
7226            peer_id_for(no_summary_id),
7227            peer_id_for(covering_id),
7228            peer_id_for(prefix_id),
7229            peer_id_for(full_id),
7230            peer_id_for(not_block_sync_id),
7231        ]);
7232
7233        for record in [no_summary, covering, prefix, full, not_block_sync] {
7234            let node_id = record.body.node_id;
7235            handle
7236                .import_connected_peer_record(record, node_id)
7237                .await
7238                .expect("connected record imports");
7239        }
7240
7241        let mut covering_summary = block_sync_summary();
7242        covering_summary.servable_low = block::Height(10);
7243        covering_summary.servable_high = block::Height(20);
7244        covering_summary.free_slots = 2;
7245        let mut prefix_summary = covering_summary;
7246        prefix_summary.servable_high = block::Height(12);
7247        prefix_summary.free_slots = 4;
7248        let mut full_summary = covering_summary;
7249        full_summary.free_slots = 0;
7250        for (node_id, summary) in [
7251            (covering_id, covering_summary),
7252            (prefix_id, prefix_summary),
7253            (full_id, full_summary),
7254        ] {
7255            handle
7256                .import_connected_peer_services_at(
7257                    first_party_services(
7258                        node_id,
7259                        now + 30,
7260                        vec![ServiceSummaryEnvelope::block_sync(&summary)
7261                            .expect("test block summary encodes")],
7262                    ),
7263                    node_id,
7264                    now,
7265                )
7266                .await
7267                .expect("first-party block summary imports");
7268        }
7269
7270        let generic = handle
7271            .service_candidates(&ZakuraServiceId::block_sync(), false, &[])
7272            .await;
7273        assert!(generic.connected.contains(&covering_id));
7274        assert!(generic.connected.contains(&prefix_id));
7275        assert!(generic.connected.contains(&full_id));
7276        assert!(generic.connected.contains(&no_summary_id));
7277        assert!(!generic.connected.contains(&not_block_sync_id));
7278
7279        let candidates = handle
7280            .block_sync_candidates(
7281                &ZakuraBlockSyncCandidateState {
7282                    missing_block_bodies: vec![block::Height(10), block::Height(20)],
7283                    ..ZakuraBlockSyncCandidateState::default()
7284                },
7285                false,
7286                &[],
7287            )
7288            .await;
7289
7290        assert_eq!(candidates.connected[0], covering_id);
7291        let prefix_position = candidates
7292            .connected
7293            .iter()
7294            .position(|node_id| node_id == &prefix_id)
7295            .expect("prefix-covering peer remains a candidate");
7296        let no_summary_position = candidates
7297            .connected
7298            .iter()
7299            .position(|node_id| node_id == &no_summary_id)
7300            .expect("no-summary peer remains a candidate");
7301        assert!(prefix_position < no_summary_position);
7302        assert!(candidates.connected.contains(&no_summary_id));
7303        assert!(!candidates.connected.contains(&full_id));
7304        assert!(!candidates.connected.contains(&not_block_sync_id));
7305
7306        let candidates = handle
7307            .block_sync_candidates(
7308                &ZakuraBlockSyncCandidateState {
7309                    missing_block_bodies: vec![block::Height(10), block::Height(20)],
7310                    admitted_node_ids: vec![full_id],
7311                },
7312                false,
7313                &[],
7314            )
7315            .await;
7316
7317        assert!(candidates.connected.contains(&full_id));
7318    }
7319
7320    #[tokio::test]
7321    async fn first_party_block_sync_summary_only_imports_from_authenticated_peer() {
7322        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7323        let handle = discovery_handle_with_connected(connected_rx);
7324        let peer_node_id = secret_key().public();
7325        let claimed_node_id = secret_key().public();
7326        let now = current_unix_secs();
7327        connected_tx.send_replace(vec![peer_id_for(peer_node_id)]);
7328
7329        let error = handle
7330            .import_connected_peer_services_at(
7331                first_party_services(
7332                    claimed_node_id,
7333                    now + 30,
7334                    vec![ServiceSummaryEnvelope::block_sync(&block_sync_summary())
7335                        .expect("test block summary encodes")],
7336                ),
7337                peer_node_id,
7338                now,
7339            )
7340            .await
7341            .expect_err("mismatched first-party block summary is rejected");
7342
7343        assert!(matches!(
7344            error,
7345            DiscoveryWireError::MismatchedNodeId {
7346                field: "services node id"
7347            }
7348        ));
7349        assert_eq!(
7350            handle.live_service_summaries_at(peer_node_id, now).await,
7351            None
7352        );
7353        assert_eq!(
7354            handle.live_service_summaries_at(claimed_node_id, now).await,
7355            None
7356        );
7357
7358        let record = runtime_record_with(10, ZakuraServiceId::block_sync(), test_addr(10));
7359        let node_id = record.body.node_id;
7360        let message = DiscoveryMessage::Peers {
7361            records: vec![record],
7362        };
7363        let encoded = message.encode().expect("test peers response encodes");
7364        let DiscoveryMessage::Peers { records } =
7365            DiscoveryMessage::decode(&encoded).expect("test peers response decodes")
7366        else {
7367            panic!("PEERS response decoded as a different message");
7368        };
7369
7370        handle.import_peer_records(records, None).await;
7371        connected_tx.send_replace(vec![peer_id_for(peer_node_id), peer_id_for(node_id)]);
7372
7373        assert_eq!(handle.live_service_summaries_at(node_id, now).await, None);
7374        assert_eq!(handle.active_services(node_id).await, None);
7375    }
7376
7377    #[tokio::test]
7378    async fn block_sync_summary_mismatch_changes_selection_without_punitive_state() {
7379        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7380        let handle = discovery_handle_with_connected(connected_rx);
7381        let now = current_unix_secs();
7382        let peer = runtime_record_with(1, ZakuraServiceId::block_sync(), test_addr(1));
7383        let covering_peer = runtime_record_with(2, ZakuraServiceId::block_sync(), test_addr(2));
7384        let peer_id = peer.body.node_id;
7385        let covering_peer_id = covering_peer.body.node_id;
7386        connected_tx.send_replace(vec![peer_id_for(peer_id), peer_id_for(covering_peer_id)]);
7387        for record in [peer, covering_peer] {
7388            let node_id = record.body.node_id;
7389            handle
7390                .import_connected_peer_record(record, node_id)
7391                .await
7392                .expect("connected record imports");
7393        }
7394
7395        let mut summary = block_sync_summary();
7396        summary.servable_low = block::Height(10);
7397        summary.servable_high = block::Height(20);
7398        summary.free_slots = 3;
7399        let mut covering_summary = summary;
7400        covering_summary.free_slots = 1;
7401        for (node_id, summary) in [(peer_id, summary), (covering_peer_id, covering_summary)] {
7402            handle
7403                .import_connected_peer_services_at(
7404                    first_party_services(
7405                        node_id,
7406                        now + 30,
7407                        vec![ServiceSummaryEnvelope::block_sync(&summary)
7408                            .expect("test block summary encodes")],
7409                    ),
7410                    node_id,
7411                    now,
7412                )
7413                .await
7414                .expect("first-party block summary imports");
7415        }
7416
7417        let state = ZakuraBlockSyncCandidateState {
7418            missing_block_bodies: vec![block::Height(10), block::Height(20)],
7419            ..ZakuraBlockSyncCandidateState::default()
7420        };
7421        assert_eq!(
7422            handle
7423                .block_sync_candidates(&state, false, &[])
7424                .await
7425                .connected
7426                .first(),
7427            Some(&peer_id)
7428        );
7429
7430        summary.servable_high = block::Height(11);
7431        handle
7432            .import_connected_peer_services_at(
7433                first_party_services(
7434                    peer_id,
7435                    now + 31,
7436                    vec![ServiceSummaryEnvelope::block_sync(&summary)
7437                        .expect("test block summary encodes")],
7438                ),
7439                peer_id,
7440                now + 1,
7441            )
7442            .await
7443            .expect("corrected first-party block summary imports");
7444
7445        let candidates = handle.block_sync_candidates(&state, false, &[]).await;
7446        assert_eq!(candidates.connected.first(), Some(&covering_peer_id));
7447        assert!(candidates.connected.contains(&peer_id));
7448        let cached = handle
7449            .live_service_summaries_at(peer_id, now + 1)
7450            .await
7451            .expect("live summary remains cached");
7452        assert_eq!(
7453            cached[0].summary,
7454            ZakuraLiveServiceSummary::BlockSync(summary)
7455        );
7456    }
7457
7458    #[tokio::test]
7459    async fn live_summaries_expire_independently_of_signed_records() {
7460        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7461        let handle = discovery_handle_with_connected(connected_rx);
7462        let record = runtime_record_with(1, ZakuraServiceId::discovery(), test_addr(1));
7463        let node_id = record.body.node_id;
7464        connected_tx.send_replace(vec![peer_id_for(node_id)]);
7465
7466        handle
7467            .import_connected_peer_record(record.clone(), node_id)
7468            .await
7469            .expect("connected self-record imports");
7470        handle
7471            .import_connected_peer_services_at(
7472                first_party_services(
7473                    node_id,
7474                    NOW + 5,
7475                    vec![ServiceSummaryEnvelope::discovery(&discovery_summary())
7476                        .expect("test discovery summary encodes")],
7477                ),
7478                node_id,
7479                NOW,
7480            )
7481            .await
7482            .expect("first-party discovery summary imports");
7483
7484        assert_eq!(
7485            handle
7486                .live_service_summaries_at(node_id, NOW)
7487                .await
7488                .expect("fresh summary is cached")
7489                .len(),
7490            1
7491        );
7492        assert_eq!(
7493            handle.live_service_summaries_at(node_id, NOW + 6).await,
7494            Some(Vec::new())
7495        );
7496        assert_eq!(handle.record_for(node_id).await, Some(record));
7497        assert_eq!(
7498            handle.active_services(node_id).await,
7499            Some(vec![ZakuraServiceId::discovery()])
7500        );
7501    }
7502
7503    #[tokio::test]
7504    async fn expired_live_only_service_membership_is_pruned() {
7505        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7506        let handle = discovery_handle_with_connected(connected_rx);
7507        let service = service(42);
7508        let live_only_node_id = secret_key().public();
7509        let record_backed = runtime_record_with(1, service.clone(), test_addr(42));
7510        let record_backed_node_id = record_backed.body.node_id;
7511        let now = current_unix_secs();
7512        connected_tx.send_replace(vec![
7513            peer_id_for(live_only_node_id),
7514            peer_id_for(record_backed_node_id),
7515        ]);
7516
7517        handle
7518            .import_connected_peer_record(record_backed, record_backed_node_id)
7519            .await
7520            .expect("connected record imports");
7521        for node_id in [live_only_node_id, record_backed_node_id] {
7522            handle
7523                .import_connected_peer_services_at(
7524                    first_party_services(
7525                        node_id,
7526                        now + 30,
7527                        vec![ServiceSummaryEnvelope {
7528                            service_id: service.clone(),
7529                            summary_tag: 999,
7530                            summary_bytes: vec![1, 2, 3],
7531                        }],
7532                    ),
7533                    node_id,
7534                    now,
7535                )
7536                .await
7537                .expect("first-party live summary imports");
7538        }
7539
7540        assert_eq!(
7541            handle.active_services(live_only_node_id).await,
7542            Some(vec![service.clone()])
7543        );
7544        assert_eq!(
7545            handle.active_services(record_backed_node_id).await,
7546            Some(vec![service.clone()])
7547        );
7548        let fresh_candidates = handle
7549            .service_candidates(&service, false, &[])
7550            .await
7551            .connected;
7552        assert!(fresh_candidates.contains(&live_only_node_id));
7553        assert!(fresh_candidates.contains(&record_backed_node_id));
7554
7555        for node_id in [live_only_node_id, record_backed_node_id] {
7556            handle
7557                .import_connected_peer_services_at(
7558                    first_party_services(
7559                        node_id,
7560                        now,
7561                        vec![ServiceSummaryEnvelope {
7562                            service_id: service.clone(),
7563                            summary_tag: 999,
7564                            summary_bytes: vec![1, 2, 3],
7565                        }],
7566                    ),
7567                    node_id,
7568                    now,
7569                )
7570                .await
7571                .expect("expired first-party live summary is dropped");
7572        }
7573
7574        assert_eq!(handle.active_services(live_only_node_id).await, None);
7575        assert_eq!(
7576            handle.active_services(record_backed_node_id).await,
7577            Some(vec![service.clone()])
7578        );
7579        let stale_candidates = handle
7580            .service_candidates(&service, false, &[])
7581            .await
7582            .connected;
7583        assert!(!stale_candidates.contains(&live_only_node_id));
7584        assert!(stale_candidates.contains(&record_backed_node_id));
7585    }
7586
7587    #[tokio::test]
7588    async fn handle_imports_records_through_storage_validation() {
7589        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7590        let handle = discovery_handle_with_connected(connected_rx);
7591        let valid = runtime_record_with(1, service(1), test_addr(1));
7592        let signing_secret = secret_key();
7593        let mut no_addr = body(&signing_secret);
7594        no_addr.direct_addrs = Vec::new();
7595        no_addr.expires_at_unix_secs = current_unix_secs() + DEFAULT_DISCOVERY_RECORD_TTL.as_secs();
7596        let invalid = ZakuraNodeRecord::sign(no_addr, &signing_secret).expect("record signs");
7597
7598        let outcome = handle.import_peer_records([valid, invalid], None).await;
7599
7600        assert_eq!(outcome.attempted, 2);
7601        assert_eq!(outcome.added, 1);
7602        assert_eq!(outcome.rejected, 1);
7603    }
7604
7605    /// Regression test for `claude-discovery-expensive-work-under-global-mutex`.
7606    ///
7607    /// An authenticated discovery peer can send a full `Peers` batch whose records carry
7608    /// invalid signatures. Each record still triggers a full Ed25519 verification — the
7609    /// expensive, attacker-driven work. Previously that verification ran while holding the
7610    /// global discovery mutex, so an attacker could repeatedly stall every other discovery
7611    /// operation (admission, sampling, dial selection). Signatures are now verified outside
7612    /// the lock, and an all-invalid batch never needs the lock at all, so the import makes
7613    /// progress even while another task holds the mutex.
7614    #[tokio::test]
7615    async fn import_peer_records_verifies_signatures_without_holding_global_mutex() {
7616        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7617        let handle = discovery_handle_with_connected(connected_rx);
7618
7619        // A full Peers batch of records with valid bodies but tampered signatures: bumping
7620        // the sequence after signing leaves body validation passing while the Ed25519 check
7621        // (re-encoding the body and verifying) runs in full and fails.
7622        let mut batch = Vec::with_capacity(MAX_DISCOVERY_RECORDS_PER_RESPONSE);
7623        for index in 0..MAX_DISCOVERY_RECORDS_PER_RESPONSE {
7624            let octet = u8::try_from(index + 1).expect("test batch index fits in u8");
7625            let sequence = u64::try_from(index + 1).expect("test batch index fits in u64");
7626            let mut record = runtime_record_with(sequence, service(1), test_addr(octet));
7627            record.body.sequence += 1;
7628            batch.push(record);
7629        }
7630
7631        // Hold the global discovery mutex for the entire import call.
7632        let guard = handle.inner.lock().await;
7633
7634        // Verification and rejection of the whole batch must make progress without the lock;
7635        // if it were still performed under the mutex this would deadlock until the timeout.
7636        let outcome = tokio::time::timeout(
7637            Duration::from_secs(5),
7638            handle.import_peer_records(batch, None),
7639        )
7640        .await
7641        .expect("import_peer_records must verify signatures without holding the global mutex");
7642
7643        drop(guard);
7644
7645        assert_eq!(outcome.attempted, MAX_DISCOVERY_RECORDS_PER_RESPONSE);
7646        assert_eq!(outcome.rejected, MAX_DISCOVERY_RECORDS_PER_RESPONSE);
7647        assert_eq!(outcome.added, 0);
7648    }
7649
7650    /// Regression test for `claude-discovery-expensive-work-under-global-mutex` (GetPeers sampling
7651    /// facet).
7652    ///
7653    /// `sample_peers` runs under the global discovery mutex and is driven by attacker-paced GetPeers
7654    /// requests. It previously cloned *every* record that passed the filter — up to the whole book
7655    /// (`max_records`, default 10_000) — even though at most `max_imported_records_per_response`
7656    /// records are ever returned. The clone is now bounded to the chosen sample.
7657    ///
7658    /// The bound is proven machine-independently by comparing the production `sample_peers` against
7659    /// an in-test reference that reproduces the pre-fix clone-the-whole-filtered-set behavior, over
7660    /// the same large book in the same run. With the fix, production clones only the returned sample
7661    /// and is markedly cheaper than the clone-everything reference; before the fix the two are the
7662    /// same computation, so the production-is-cheaper bound fails.
7663    #[test]
7664    fn sample_peers_clone_cost_is_bounded_by_returned_sample() {
7665        const BOOK: usize = 1024;
7666        const ITERS: usize = 400;
7667
7668        let wanted = service(1);
7669        // Heavy records (maximum direct-address fan-out plus many services, with the wanted service
7670        // first so the filter match itself stays cheap) make each *record clone* far more expensive
7671        // than the allocation-free per-entry filter scan, so the clone count is the dominant cost
7672        // and the bounded-vs-unbounded clone gap is large.
7673        let addrs: Vec<SocketAddr> = (1u8..=MAX_DIRECT_ADDRS_PER_RECORD as u8)
7674            .map(test_addr)
7675            .collect();
7676        let mut services = vec![wanted.clone()];
7677        services.extend((100..100 + (MAX_SERVICES_PER_RECORD - 1)).map(service));
7678
7679        let mut book = ZakuraDiscoveryBook::new(ZakuraDiscoveryBookLimits {
7680            max_records: BOOK,
7681            ..ZakuraDiscoveryBookLimits::default()
7682        });
7683        for seq in 0..BOOK {
7684            let secret = secret_key();
7685            let mut record_body = body(&secret);
7686            record_body.sequence = seq as u64 + 1;
7687            record_body.direct_addrs = addrs.clone();
7688            record_body.services = services.clone();
7689            let record = ZakuraNodeRecord::sign(record_body, &secret).expect("test record signs");
7690            book.import_record(record, None, NOW, &context())
7691                .expect("test record imports");
7692        }
7693
7694        let cap = book.limits.max_imported_records_per_response;
7695
7696        // Reference implementation: the pre-fix behavior of cloning every record that passes the
7697        // filter, then reservoir-sampling the clones. Mirrors `sample_peers`'s filter exactly.
7698        let naive_sample =
7699            |book: &ZakuraDiscoveryBook, rng: &mut StdRng| -> Vec<ZakuraNodeRecord> {
7700                book.entries
7701                    .iter()
7702                    .filter(|(node_id, entry)| {
7703                        book.local_node_id != Some(**node_id)
7704                            && !entry_is_expired(entry, NOW)
7705                            && has_wanted_services(&entry.record, std::slice::from_ref(&wanted))
7706                            && has_discovery_dialable_direct_addrs(&entry.record)
7707                    })
7708                    .map(|(_, entry)| entry.record.clone())
7709                    .choose_multiple(rng, cap)
7710            };
7711
7712        let mut rng = StdRng::seed_from_u64(5);
7713
7714        // Warm up the allocator and instruction caches before timing.
7715        let _ = naive_sample(&book, &mut rng);
7716        let _ = book.sample_peers(cap, std::slice::from_ref(&wanted), &[], NOW, &mut rng);
7717
7718        let naive_start = std::time::Instant::now();
7719        for _ in 0..ITERS {
7720            assert_eq!(naive_sample(&book, &mut rng).len(), cap);
7721        }
7722        let naive_time = naive_start.elapsed();
7723
7724        let production_start = std::time::Instant::now();
7725        for _ in 0..ITERS {
7726            assert_eq!(
7727                book.sample_peers(cap, std::slice::from_ref(&wanted), &[], NOW, &mut rng)
7728                    .len(),
7729                cap
7730            );
7731        }
7732        let production_time = production_start.elapsed();
7733
7734        // Both run the same O(book) filter scan and reservoir sampling; the only difference is how
7735        // many records are cloned (production: the returned `cap`; reference: every match in the
7736        // book). With the bound in place production must be clearly cheaper than cloning the whole
7737        // filtered set. Before the fix they are the identical computation, so production is not
7738        // meaningfully cheaper and this fails. The 0.8 factor is a ratio on the same machine, so it
7739        // is machine-independent.
7740        assert!(
7741            production_time.as_nanos() * 5 < naive_time.as_nanos() * 4,
7742            "sample_peers did not bound its clone work: production={production_time:?} \
7743             clone-everything reference={naive_time:?}; production should be clearly cheaper than \
7744             cloning every filtered record"
7745        );
7746    }
7747
7748    /// Guards the bounded top-k dial-candidate selection added for
7749    /// `claude-discovery-expensive-work-under-global-mutex`.
7750    ///
7751    /// `dial_candidates` now selects the best `limit` candidates with a partial select instead of
7752    /// sorting the whole book before `take(limit)`. With far more matching candidates than `limit`,
7753    /// the result must still be exactly the highest dial-priority candidates (most recent
7754    /// successful dial first), proving the partial selection keeps the same ordering as a full
7755    /// sort.
7756    #[test]
7757    fn dial_candidates_selects_top_priority_subset_under_limit() {
7758        let mut book = ZakuraDiscoveryBook::default();
7759        let records = (1u8..=10)
7760            .map(|index| signed_record_with(index.into(), service(1), test_addr(index)))
7761            .collect::<Vec<_>>();
7762        for record in &records {
7763            import_confirmed_record(&mut book, record.clone()).expect("test record imports");
7764        }
7765
7766        // Three candidates get strictly increasing last-success times, so dial priority is
7767        // deterministic (most recent success first); the remaining seven have no successful dial.
7768        book.mark_dial_success(&records[5].body.node_id, NOW + 1);
7769        book.mark_dial_success(&records[3].body.node_id, NOW + 2);
7770        book.mark_dial_success(&records[7].body.node_id, NOW + 3);
7771        let expected_top = vec![
7772            records[7].body.node_id,
7773            records[3].body.node_id,
7774            records[5].body.node_id,
7775        ];
7776
7777        let mut rng = StdRng::seed_from_u64(99);
7778        let selected = book.dial_candidates(
7779            3,
7780            &[service(1)],
7781            DialCandidateExclusions {
7782                connected_node_ids: &[],
7783                in_flight_node_ids: &[],
7784            },
7785            NOW,
7786            (
7787                DEFAULT_DISCOVERY_DIAL_BACKOFF_BASE,
7788                DEFAULT_DISCOVERY_DIAL_BACKOFF_MAX,
7789            ),
7790            &mut rng,
7791        );
7792
7793        assert_eq!(
7794            selected
7795                .iter()
7796                .map(|candidate| candidate.node_id)
7797                .collect::<Vec<_>>(),
7798            expected_top,
7799        );
7800    }
7801
7802    #[tokio::test]
7803    async fn handle_samples_records_through_storage_path() {
7804        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7805        let handle = discovery_handle_with_connected(connected_rx);
7806        let wanted = service(1);
7807        let matching = runtime_record_with(1, wanted.clone(), test_addr(1));
7808        let excluded = runtime_record_with(2, wanted.clone(), test_addr(2));
7809        let excluded_id = excluded.body.node_id;
7810        let other = runtime_record_with(3, service(2), test_addr(3));
7811        handle
7812            .import_peer_records([matching.clone(), excluded, other], None)
7813            .await;
7814
7815        let sample = handle
7816            .sample_peers(10, std::slice::from_ref(&wanted), &[excluded_id])
7817            .await;
7818
7819        assert_eq!(sample, vec![matching]);
7820    }
7821
7822    #[tokio::test]
7823    async fn stale_discovery_admit_cannot_overwrite_newer_session() {
7824        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7825        let handle = discovery_handle_with_connected(connected_rx);
7826        let peer = peer_id_for(secret_key().public());
7827
7828        assert_eq!(
7829            handle
7830                .admit_peer(2, peer.clone(), ServicePeerDirection::Inbound)
7831                .await,
7832            ServiceAdmissionDecision::Admit
7833        );
7834        assert_eq!(handle.peer_snapshot().inbound_peers, 1);
7835
7836        assert_eq!(
7837            handle
7838                .admit_peer(1, peer.clone(), ServicePeerDirection::Inbound)
7839                .await,
7840            ServiceAdmissionDecision::RejectNotUseful
7841        );
7842        assert_eq!(handle.peer_snapshot().inbound_peers, 1);
7843
7844        handle.remove_peer(&peer, 1).await;
7845        assert_eq!(
7846            handle.peer_snapshot().inbound_peers,
7847            1,
7848            "stale discovery cleanup must not remove the newer admission",
7849        );
7850
7851        handle.remove_peer(&peer, 2).await;
7852        assert_eq!(handle.peer_snapshot().inbound_peers, 0);
7853    }
7854
7855    #[tokio::test]
7856    async fn discovery_state_construction_works_with_default_config() {
7857        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7858        let handle = ZakuraDiscoveryHandle::new(
7859            local_config_with(secret_key(), vec![test_addr(46)], vec![service(1)]),
7860            ZakuraDiscoveryConfig::default(),
7861            connected_rx,
7862        )
7863        .expect("default discovery state constructs");
7864
7865        assert_eq!(
7866            handle.current_self_record().body.direct_addrs,
7867            vec![test_addr(46)]
7868        );
7869    }
7870
7871    #[tokio::test]
7872    async fn dial_candidates_exclude_connected_peers_and_respect_soft_cap() {
7873        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7874        let handle = discovery_handle_at(
7875            local_config_with(secret_key(), vec![test_addr(47)], vec![service(1)]),
7876            ZakuraDiscoveryConfig {
7877                max_zakura_connections: 2,
7878                discovery_connection_headroom: 1,
7879                ..ZakuraDiscoveryConfig::default()
7880            },
7881            connected_rx,
7882            NOW,
7883        );
7884        let candidate = runtime_record_with(1, service(1), test_addr(1));
7885        let connected = runtime_record_with(2, service(1), test_addr(2));
7886        let connected_id = connected.body.node_id;
7887        let candidate_id = candidate.body.node_id;
7888        handle
7889            .import_peer_record(candidate.clone(), Some(candidate_id))
7890            .await
7891            .expect("candidate first-party record imports");
7892        handle
7893            .import_connected_peer_record(connected, connected_id)
7894            .await
7895            .expect("connected self-record imports");
7896
7897        connected_tx.send_replace(vec![peer_id_for(connected_id)]);
7898        assert!(handle.dial_candidates(&[service(1)], &[]).await.is_empty());
7899
7900        let (connected_tx, connected_rx) = watch::channel(Vec::new());
7901        let handle = discovery_handle_at(
7902            local_config_with(secret_key(), vec![test_addr(48)], vec![service(1)]),
7903            ZakuraDiscoveryConfig {
7904                max_zakura_connections: 4,
7905                discovery_connection_headroom: 1,
7906                ..ZakuraDiscoveryConfig::default()
7907            },
7908            connected_rx,
7909            NOW,
7910        );
7911        let connected = runtime_record_with(2, service(1), test_addr(2));
7912        let connected_id = connected.body.node_id;
7913        let candidate_id = candidate.body.node_id;
7914        handle
7915            .import_peer_record(candidate.clone(), Some(candidate_id))
7916            .await
7917            .expect("candidate first-party record imports");
7918        handle
7919            .import_connected_peer_record(connected, connected_id)
7920            .await
7921            .expect("connected self-record imports");
7922
7923        connected_tx.send_replace(vec![peer_id_for(connected_id)]);
7924        assert_eq!(
7925            handle.dial_candidates(&[service(1)], &[]).await,
7926            vec![candidate_for(&candidate, false)]
7927        );
7928    }
7929
7930    #[tokio::test]
7931    async fn dial_candidates_exclude_in_flight_peers() {
7932        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7933        let handle = discovery_handle_with_connected(connected_rx);
7934        let candidate = runtime_record_with(1, service(1), test_addr(1));
7935        let candidate_id = candidate.body.node_id;
7936        handle
7937            .import_peer_record(candidate, Some(secret_key().public()))
7938            .await
7939            .expect("candidate imports");
7940
7941        assert!(handle
7942            .dial_candidates(&[service(1)], &[candidate_id])
7943            .await
7944            .is_empty());
7945    }
7946
7947    #[test]
7948    fn discovery_dial_slot_limit_reserves_headroom_and_concurrent_cap() {
7949        assert_eq!(discovery_dial_slot_limit(0, 0, 8, 2, 4), 4);
7950        assert_eq!(discovery_dial_slot_limit(5, 0, 8, 2, 4), 1);
7951        assert_eq!(discovery_dial_slot_limit(5, 1, 8, 2, 4), 1);
7952        assert_eq!(discovery_dial_slot_limit(2, 4, 8, 2, 4), 0);
7953        assert_eq!(discovery_dial_slot_limit(2, 0, 2, 4, 4), 0);
7954    }
7955
7956    #[tokio::test]
7957    async fn handle_records_are_owned_after_storage_changes() {
7958        let (_connected_tx, connected_rx) = watch::channel(Vec::new());
7959        let handle = discovery_handle_with_connected(connected_rx);
7960        let secret = secret_key();
7961        let old = {
7962            let mut record_body = body(&secret);
7963            record_body.sequence = 1;
7964            record_body.direct_addrs = vec![test_addr(10)];
7965            record_body.services = vec![service(1)];
7966            record_body.expires_at_unix_secs =
7967                current_unix_secs() + DEFAULT_DISCOVERY_RECORD_TTL.as_secs();
7968            ZakuraNodeRecord::sign(record_body, &secret).expect("old record signs")
7969        };
7970        let new = {
7971            let mut record_body = body(&secret);
7972            record_body.sequence = 2;
7973            record_body.direct_addrs = vec![test_addr(11)];
7974            record_body.services = vec![service(1)];
7975            record_body.expires_at_unix_secs =
7976                current_unix_secs() + DEFAULT_DISCOVERY_RECORD_TTL.as_secs();
7977            ZakuraNodeRecord::sign(record_body, &secret).expect("new record signs")
7978        };
7979
7980        handle.import_peer_records([old.clone()], None).await;
7981        let sample = handle.sample_peers(1, &[service(1)], &[]).await;
7982        handle.import_peer_records([new], None).await;
7983
7984        assert_eq!(sample, vec![old]);
7985    }
7986}