Skip to main content

dig_dht/
service.rs

1//! [`DhtService`] — the public handle that ties the routing table, provider store, transport, and
2//! iterative lookup into the four operations a DIG Node needs:
3//!
4//! - [`bootstrap`](DhtService::bootstrap) — seed the routing table from known peers (the dig-gossip
5//!   pool / relay introducer) + populate it with a self-lookup.
6//! - [`find_providers`](DhtService::find_providers) — "who holds this content?" → the provider
7//!   records (the node then fetches over the L7 peer RPC).
8//! - [`announce_provider`](DhtService::announce_provider) — "I hold this content" → PUT a provider
9//!   record at the `k` nodes closest to the content key (and locally), and remember to republish it.
10//! - [`find_node`](DhtService::find_node) — the `k` peers closest to a `peer_id` (routing primitive).
11//!
12//! Plus maintenance ([`republish`](DhtService::republish), [`refresh_buckets`](DhtService::refresh_buckets),
13//! [`gc`](DhtService::gc)) and the **serving side** ([`handle_request`](DhtService::handle_request))
14//! that answers inbound DHT RPCs from other nodes.
15//!
16//! ## Serving vs. querying
17//!
18//! A node is both a client and a server of the DHT. [`handle_request`](DhtService::handle_request)
19//! is the server: given an inbound [`DhtRequest`], it reads/writes the local routing table +
20//! provider store and returns the [`DhtResponse`]. The `find_*` / `announce_*` methods are the
21//! client: they run iterative lookups over the [`DhtTransport`]. A dig-node wires `handle_request`
22//! to inbound DHT streams and gives the service a transport that dials outbound.
23
24use std::sync::Arc;
25
26use tokio::sync::Mutex;
27
28use dig_nat::PeerId;
29
30use crate::clock::now_secs;
31use crate::config::DhtConfig;
32use crate::content::ContentId;
33use crate::error::DhtError;
34use crate::key::Key;
35use crate::lookup::{iterative_find, QueryOutcome};
36use crate::provider_store::{ProviderSnapshot, ProviderStore, PutOutcome};
37use crate::record::{hex64_to_bytes, CandidateAddr, ProviderRecord};
38use crate::routing::{Contact, InsertOutcome, RoutingTable};
39use crate::transport::DhtTransport;
40use crate::wire::{DhtRequest, DhtResponse};
41
42/// A peer to bootstrap the routing table from — its `peer_id` and at least one candidate address.
43/// These come from the node's existing discovery (the dig-gossip peer pool / the relay introducer);
44/// the DHT crate takes them as input and never hard-depends on a live relay itself.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BootstrapPeer {
47    /// The bootstrap peer's identity.
48    pub peer_id: PeerId,
49    /// Candidate addresses to reach it.
50    pub addresses: Vec<CandidateAddr>,
51}
52
53impl BootstrapPeer {
54    /// A bootstrap peer with a single direct address.
55    pub fn direct(peer_id: PeerId, host: impl Into<String>, port: u16) -> Self {
56        BootstrapPeer {
57            peer_id,
58            addresses: vec![CandidateAddr::direct(host, port)],
59        }
60    }
61
62    fn to_contact(&self) -> Contact {
63        Contact::new(&self.peer_id, self.addresses.clone())
64    }
65}
66
67/// The DHT service for one node. Cloneable-by-`Arc` internally; wrap in `Arc` to share between the
68/// serving task (inbound RPC) and querying callers.
69pub struct DhtService {
70    local_id: PeerId,
71    /// This node's own candidate addresses — put into provider records it announces so finders can
72    /// reach it.
73    local_addresses: Vec<CandidateAddr>,
74    config: DhtConfig,
75    routing: Arc<Mutex<RoutingTable>>,
76    /// The AUTHORITATIVE provider store — records whose provider attribution this node established
77    /// (its own announces, the mTLS-checked serving-side `add_provider`, the caller-verified
78    /// [`ingest_verified_provider`](DhtService::ingest_verified_provider)). This is the store that
79    /// answers an inbound `find_providers`, so everything in it becomes THIS NODE'S CLAIM about who
80    /// holds what.
81    providers: Arc<Mutex<ProviderStore>>,
82    /// The DISCOVERY CACHE — records this node collected from its OWN lookups (SPEC §6.8). Same
83    /// type, same admission control, different trust provenance and therefore a different store:
84    /// see [`cache_discovered`](DhtService::cache_discovered) for why these two must never be one.
85    discovered: Arc<Mutex<ProviderStore>>,
86    transport: Arc<dyn DhtTransport>,
87}
88
89impl DhtService {
90    /// Create a service for the node identified by `local_id`, advertising `local_addresses` in the
91    /// provider records it announces, driving RPC over `transport`.
92    pub fn new(
93        local_id: PeerId,
94        local_addresses: Vec<CandidateAddr>,
95        config: DhtConfig,
96        transport: Arc<dyn DhtTransport>,
97    ) -> Self {
98        let routing = RoutingTable::new(&local_id, config.k);
99        let providers = ProviderStore::with_limits(config.provider_store_limits);
100        let discovered = ProviderStore::with_limits(config.discovery_cache_limits);
101        DhtService {
102            local_id,
103            local_addresses,
104            config,
105            routing: Arc::new(Mutex::new(routing)),
106            providers: Arc::new(Mutex::new(providers)),
107            discovered: Arc::new(Mutex::new(discovered)),
108            transport,
109        }
110    }
111
112    /// This node's id.
113    pub fn local_id(&self) -> &PeerId {
114        &self.local_id
115    }
116
117    /// This node's own [`Contact`] (its id + advertised addresses) — the authenticated caller
118    /// identity supplied to the transport as the RPC `from`.
119    fn local_contact(&self) -> Contact {
120        Contact::new(&self.local_id, self.local_addresses.clone())
121    }
122
123    // ---- Bootstrap ---------------------------------------------------------------------------
124
125    /// Seed the routing table from `peers` and populate it by looking up this node's own id (the
126    /// canonical Kademlia bootstrap: a self-lookup fills the buckets around us). Returns the number
127    /// of distinct peers now known.
128    ///
129    /// Safe to call repeatedly (on reconnect / when new bootstrap peers arrive) — it merges, never
130    /// resets.
131    pub async fn bootstrap(&self, peers: &[BootstrapPeer]) -> Result<usize, DhtError> {
132        {
133            let mut rt = self.routing.lock().await;
134            for p in peers {
135                let _ = rt.insert(p.to_contact());
136            }
137        }
138        // Self-lookup: find the nodes closest to us to fill our buckets.
139        let self_key = Key::from_peer_id(&self.local_id);
140        let seeds: Vec<Contact> = peers.iter().map(|p| p.to_contact()).collect();
141        let result = self.run_lookup(self_key, seeds, false).await;
142        self.absorb_contacts(&result.closest).await;
143        Ok(self.routing.lock().await.len())
144    }
145
146    /// Add a single live peer to the routing table as it connects (e.g. a `dig-gossip`
147    /// `PoolEvent::PeerAdded`), WITHOUT the network round-trip [`bootstrap`](Self::bootstrap) does.
148    ///
149    /// This is the LIVE seam the one-shot pre-connect bootstrap cannot cover: in a freshly-formed
150    /// network the pool is empty when `bootstrap` runs, so routing stays empty and `find_providers`
151    /// finds nobody. Feeding each connected peer here populates routing as the pool fills, which is
152    /// what makes cross-node discovery work (#1574). Idempotent — re-adding a known peer merges its
153    /// address(es) via the routing table's insert policy; adding this node's own id is a no-op.
154    pub async fn add_peer(&self, peer_id: &PeerId, addresses: Vec<CandidateAddr>) {
155        let contact = Contact::new(peer_id, addresses);
156        let _ = self.routing.lock().await.insert(contact);
157    }
158
159    /// Remove a peer from the routing table as it leaves (a `dig-gossip` `PoolEvent::PeerRemoved`),
160    /// keeping routing accurate so lookups don't seed from a dead contact. Returns whether it was
161    /// present. `peer_id_hex` is the 64-char hex id (as carried on `Contact::provider_peer_id` /
162    /// [`PeerId::to_hex`]).
163    pub async fn remove_peer(&self, peer_id_hex: &str) -> bool {
164        self.routing.lock().await.remove(peer_id_hex)
165    }
166
167    // ---- Client operations -------------------------------------------------------------------
168
169    /// Find the `k` peers closest to `peer_id` (the routing primitive). Runs an iterative
170    /// `find_node` lookup and returns the converged closest contacts.
171    pub async fn find_node(&self, peer_id: &PeerId) -> Result<Vec<Contact>, DhtError> {
172        let target = Key::from_peer_id(peer_id);
173        let seeds = self.seed_contacts(&target).await;
174        if seeds.is_empty() {
175            return Err(DhtError::NoPeers);
176        }
177        let result = self.run_lookup(target, seeds, false).await;
178        self.absorb_contacts(&result.closest).await;
179        Ok(result.closest)
180    }
181
182    /// Find the providers of `content` — the peers holding it. Answers from this node's
183    /// **discovery cache** when a recent lookup for the same key is still live (SPEC §6.8);
184    /// otherwise runs an iterative `find_providers` lookup toward the content key, caches what it
185    /// learns, and returns every live provider record collected (deduped by provider). The node
186    /// then connects to those providers over dig-nat and fetches via the L7 peer RPC.
187    ///
188    /// **Cached answers are what make a later direct dial free** (dig_ecosystem#3128 requirement 7):
189    /// a `.dig` fetch issues many requests against the same store, and without the cache each one
190    /// paid a fresh Kademlia walk. A live cache entry is treated as evidence that this node
191    /// completed a lookup for the key recently, so the walk is skipped entirely — records this node
192    /// holds AUTHORITATIVELY are deliberately NOT such evidence, since they may be its own announce
193    /// and short-circuiting on them would stop a publisher ever learning the other holders of its
194    /// own content.
195    ///
196    /// A cached holder is a claim by an untrusted peer, so a dial to it may fail. That costs one
197    /// failed dial, never a wrong answer — the content is accepted because it verifies against the
198    /// merkle root, never because a peer supplied it (NC-12). A caller that finds every cached
199    /// candidate undialable calls [`forget_discovered`](Self::forget_discovered) and asks again,
200    /// which re-runs the full walk.
201    ///
202    /// Returns an empty vec (not an error) when the content simply has no known providers; returns
203    /// [`DhtError::NoPeers`] only when there is no one to ask (empty routing table + no bootstrap).
204    pub async fn find_providers(
205        &self,
206        content: &ContentId,
207    ) -> Result<Vec<ProviderRecord>, DhtError> {
208        let target = content.to_key();
209        let key_hex = target.to_hex();
210
211        // Local short-circuit: if we already hold providers for this key, include them.
212        let now = now_secs();
213        let local = self.providers.lock().await.get(&key_hex, now);
214
215        let cached = self.discovered.lock().await.get(&key_hex, now);
216        if !cached.is_empty() {
217            return Ok(merge_dedup_by_provider(local, cached, now));
218        }
219
220        let seeds = self.seed_contacts(&target).await;
221        if seeds.is_empty() {
222            // No peers to ask — return whatever we hold locally (possibly empty).
223            return Ok(local);
224        }
225        let result = self.run_lookup(target, seeds, true).await;
226        self.absorb_contacts(&result.closest).await;
227
228        // Discovered records come straight off the wire from other peers' responses, bypassing
229        // `ProviderRecord::new`'s address cap — capped here before they are cached or handed back
230        // to our caller (SPEC §5.5, §14). Records for a key we did not query were already discarded
231        // at the wire boundary in `run_lookup`'s query closure (SPEC §6.7).
232        let mut discovered = result.providers;
233        for r in &mut discovered {
234            crate::record::sort_and_cap_addresses(&mut r.addresses);
235        }
236        self.cache_discovered(&key_hex, &discovered).await;
237
238        Ok(merge_dedup_by_provider(local, discovered, now_secs()))
239    }
240
241    /// The provider records this node has CACHED for `content` from its own lookups, live as of
242    /// now — the direct-dial shortcut requirement 7 exists to provide, with no network round-trip
243    /// and no fallback walk.
244    ///
245    /// # These records MUST NOT be re-served to anyone
246    ///
247    /// They are hearsay: some peer along a lookup said that some other peer holds this content, and
248    /// nothing authenticated that claim — unlike an authoritative record, which either names the
249    /// mTLS-verified caller that announced it or was signature-checked by the caller of
250    /// [`ingest_verified_provider`](Self::ingest_verified_provider). Hearsay belongs on the FETCH
251    /// path, where a wrong candidate is merely a wasted dial because the merkle bind catches it. On
252    /// the ASSERTION path — an inbound `find_providers`, a redirect answer, anything a stranger
253    /// reads — it becomes THIS NODE'S claim about the world, and re-serving it would launder an
254    /// attacker's fabricated holder into an answer other nodes trust. This node therefore never
255    /// serves the cache (see [`handle_request_from`](Self::handle_request_from), which reads the
256    /// authoritative store only) and never publishes it (see
257    /// [`provider_snapshot`](Self::provider_snapshot)).
258    pub async fn cached_providers(&self, content: &ContentId) -> Vec<ProviderRecord> {
259        self.discovered
260            .lock()
261            .await
262            .get(&content.to_key().to_hex(), now_secs())
263    }
264
265    /// Forget every cached provider for `content`, so the next
266    /// [`find_providers`](Self::find_providers) runs a real lookup again. Returns how many cached
267    /// records were dropped.
268    ///
269    /// This is what keeps a cache miss CHEAP and keeps it from being mistaken for absence: a caller
270    /// that has tried every cached candidate and reached none of them calls this and asks again,
271    /// rather than concluding the content has no providers. It touches only this node's own cache —
272    /// never the authoritative store, so it can neither censor a key this node serves nor be
273    /// observed by any other peer.
274    pub async fn forget_discovered(&self, content: &ContentId) -> usize {
275        self.discovered
276            .lock()
277            .await
278            .remove_key(&content.to_key().to_hex())
279    }
280
281    /// Announce that THIS node holds `content`: build a provider record (this node's `peer_id` +
282    /// addresses, expiring at `now + provider_ttl`), store it locally, remember to republish it, and
283    /// PUT it at the `k` nodes closest to the content key. Returns how many peers accepted the PUT.
284    ///
285    /// Called when the node's inventory gains content (a new capsule/root/resource it now serves).
286    pub async fn announce_provider(&self, content: &ContentId) -> Result<usize, DhtError> {
287        self.announce_provider_with_collateral(content, None).await
288    }
289
290    /// As [`announce_provider`](Self::announce_provider), but also publishing this node's claimed
291    /// mirror-coin id so a verifier can fetch ONE coin instead of scanning by hint.
292    ///
293    /// The pointer is per-content because a mirror coin bonds a `(store, root, owner, epoch)`
294    /// tuple, and it is remembered so every [`republish`](Self::republish) re-attaches it. Pass
295    /// `None` — or call [`announce_provider`](Self::announce_provider) — when there is no coin yet;
296    /// **absence is a normal, fully-supported state**, not a degraded one, since the verifier's
297    /// fallback is the hint scan.
298    ///
299    /// To refresh the pointer across an epoch rollover, announce again with the new coin id.
300    ///
301    /// Publishing a pointer claims nothing that a consumer will believe: see
302    /// [`ProviderRecord::unverified_mirror_coin_id`].
303    pub async fn announce_provider_with_collateral(
304        &self,
305        content: &ContentId,
306        unverified_mirror_coin_id: Option<[u8; 32]>,
307    ) -> Result<usize, DhtError> {
308        let target = content.to_key();
309        let mut record = self.build_local_record(&target);
310        if let Some(coin_id) = unverified_mirror_coin_id {
311            record = record.with_unverified_mirror_coin_id(coin_id);
312        }
313
314        // Store locally + remember for republish (pointer included, so the first TTL rollover does
315        // not silently drop it).
316        {
317            let mut ps = self.providers.lock().await;
318            ps.put(record.clone());
319            ps.mark_announced_with_collateral(
320                target.to_hex(),
321                record.unverified_mirror_coin_id.clone(),
322            );
323        }
324
325        // PUT at the k closest peers we can find.
326        let seeds = self.seed_contacts(&target).await;
327        if seeds.is_empty() {
328            // No peers yet — the local record stands; republish will re-attempt once bootstrapped.
329            return Ok(0);
330        }
331        let result = self.run_lookup(target, seeds, false).await;
332        self.absorb_contacts(&result.closest).await;
333        Ok(self.put_record_at(&result.closest, &record).await)
334    }
335
336    /// Stop announcing `content` (the node no longer holds it). The record ages out of the DHT via
337    /// TTL; we just stop republishing it. Returns whether it was being announced.
338    ///
339    /// This is the **passive** withdraw: it leaves this node's own local provider record in place
340    /// (it only expires with TTL) and merely stops re-publishing it, so a `find_providers` on this
341    /// node may still return self until the local record's TTL elapses. For an **immediate**
342    /// own-retract — the local-state half of the #1423 evict+retract step — use
343    /// [`retract_own_provider`](Self::retract_own_provider).
344    pub async fn withdraw_provider(&self, content: &ContentId) -> bool {
345        let key = content.to_key().to_hex();
346        self.providers.lock().await.unmark_announced(&key)
347    }
348
349    // ---- Real-time holdings API (#1394 / #1423) ----------------------------------------------
350
351    /// Ingest a provider record for a THIRD-PARTY holder that the caller has ALREADY verified was
352    /// signed by `record.provider_peer_id` — the inbound-**add** half of the real-time holdings map
353    /// (SPEC §6.5). Returns the store admission outcome.
354    ///
355    /// This is the authenticated push path a node's announce receiver calls after verifying a
356    /// signed `HoldingsAnnounce` (dig-gossip opcode 222): the holder's signature has replaced mTLS
357    /// attribution as the proof of who provides the content, so — unlike the serving-side
358    /// `add_provider` (§6.4) — this method **bypasses the mTLS self-announce identity check** (the
359    /// caller, not the DHT, established authenticity). dig-dht itself stays crypto-free (SPEC §15):
360    /// it NEVER verifies a signature; passing an unverified record here is a caller bug that
361    /// poisons the local provider set.
362    ///
363    /// Every other admission guard still applies exactly as for `add_provider`: the address list is
364    /// capped ([`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)),
365    /// `unverified_mirror_coin_id` is normalized to canonical lowercase 64-hex or dropped to `None`
366    /// (so a caller need not bound it, and MUST NOT rely on it having survived verbatim),
367    /// `expires_at` is clamped to `min(record.expires_at, now + provider_ttl)` (§6.2), and the
368    /// per-key / global
369    /// admission caps (§6.3) are enforced — an over-capacity ingest returns
370    /// [`PutOutcome::RejectedOverCapacity`] and stores nothing. On acceptance the holder is folded
371    /// into the routing table so this node can reach it.
372    pub async fn ingest_verified_provider(&self, record: ProviderRecord) -> PutOutcome {
373        self.admit_verified_record(record).await
374    }
375
376    /// Remove exactly the local provider record for `(content_key, provider_peer_id)` — the
377    /// inbound-**retract** half of the real-time holdings map (SPEC §6.6). Returns whether a record
378    /// was removed.
379    ///
380    /// `content_key` and `provider_peer_id` are the 64-hex forms as they appear on a
381    /// [`ProviderRecord`] (`content` → `content.to_key().to_hex()`; the holder's `peer_id` hex).
382    /// The caller MUST have verified the retract was signed by that same `provider_peer_id`
383    /// (authenticated retract): a retract signed by one holder removes ONLY that holder's record and
384    /// can never evict another provider of the same key (censorship-resistance, §6.6). dig-dht does
385    /// not verify the signature (SPEC §15) — that is the caller's responsibility.
386    pub async fn remove_provider_record(&self, content_key: &str, provider_peer_id: &str) -> bool {
387        self.providers
388            .lock()
389            .await
390            .remove(content_key, provider_peer_id)
391    }
392
393    /// A bounded, AGGREGATED view of this node's provider store — content keys and their live
394    /// provider COUNTS, with no provider identities (dig_ecosystem #1935).
395    ///
396    /// Exposed so a node can answer the relay's RLY-009 `get_dht_records` without the caller needing
397    /// access to the store itself. Because a Kademlia node holds records for keys near its OWN
398    /// `peer_id`, this describes MANY OTHER peers' content rather than what this node caches — which
399    /// is what makes the union across nodes a usable view of the network's content layer.
400    ///
401    /// `max_keys` bounds the result; see [`ProviderStore::snapshot`] for the truncation and privacy
402    /// contract. Expired records are excluded as of the current time, so the counts agree with what
403    /// [`find_providers`](Self::find_providers) would actually return.
404    pub async fn provider_snapshot(&self, max_keys: usize) -> ProviderSnapshot {
405        self.providers.lock().await.snapshot(now_secs(), max_keys)
406    }
407
408    /// Actively retract THIS node's own provider record for `content`: remove the local record AND
409    /// stop republishing it, so `find_providers` on this node stops returning self as a holder
410    /// immediately (SPEC §6.6). Returns whether this node was providing the content (a local record
411    /// existed or the key was being announced).
412    ///
413    /// This is the local-state half of the #1423 atomic **evict + retract** step (on an LRU cache
414    /// eviction the node no longer serves the content). Unlike the passive
415    /// [`withdraw_provider`](Self::withdraw_provider) (which leaves the local record to expire via
416    /// TTL), this deletes it now. The copies previously PUT at the `k` closest peers are NOT deleted
417    /// by this call — they age out via TTL, or are removed sooner when dig-node floods the signed
418    /// retract announce and each recipient calls
419    /// [`remove_provider_record`](Self::remove_provider_record).
420    pub async fn retract_own_provider(&self, content: &ContentId) -> bool {
421        let key = content.to_key().to_hex();
422        let self_id = self.local_id.to_hex();
423        let mut ps = self.providers.lock().await;
424        let removed_record = ps.remove(&key, &self_id);
425        let was_announced = ps.unmark_announced(&key);
426        removed_record || was_announced
427    }
428
429    /// The `peer_id`s of the peers that hold `content` — a thin, address-free convenience over
430    /// [`find_providers`](Self::find_providers) for callers that only need "which peers hold X"
431    /// (e.g. an RPC holder-set query) and do not dial the holders themselves.
432    ///
433    /// `find_providers` remains the PRIMARY API: it returns full [`ProviderRecord`]s with candidate
434    /// addresses, which dig-download needs to actually connect and fetch. This method runs the same
435    /// distributed iterative lookup and simply projects each record to its holder `peer_id`
436    /// (records with a malformed peer id are skipped; the set is already deduped by provider).
437    pub async fn holders_of(&self, content: &ContentId) -> Result<Vec<PeerId>, DhtError> {
438        let records = self.find_providers(content).await?;
439        Ok(records
440            .iter()
441            .filter_map(|r| r.provider_peer_id())
442            .collect())
443    }
444
445    // ---- Maintenance -------------------------------------------------------------------------
446
447    /// Republish every content key this node still announces — re-runs the announce PUT so provider
448    /// records never expire while the node is online. Call on the [`DhtConfig::republish_interval`].
449    /// Returns the number of content keys republished.
450    pub async fn republish(&self) -> usize {
451        let keys = self.providers.lock().await.local_announcements();
452        let count = keys.len();
453        for hex in keys {
454            let Some(bytes) = hex64_to_bytes(&hex) else {
455                continue;
456            };
457            let target = Key::from_bytes(bytes);
458            let mut record = self.build_local_record(&target);
459            // Re-attach the pointer this key was announced with. Rebuilding from
460            // `build_local_record` alone would drop it on the first republish, so a node would
461            // appear to have lost its collateral pointer one TTL after announcing it.
462            record.unverified_mirror_coin_id = self
463                .providers
464                .lock()
465                .await
466                .announced_collateral(&hex)
467                .map(str::to_owned);
468            self.providers.lock().await.put(record.clone());
469            let seeds = self.seed_contacts(&target).await;
470            if !seeds.is_empty() {
471                let result = self.run_lookup(target, seeds, false).await;
472                self.absorb_contacts(&result.closest).await;
473                self.put_record_at(&result.closest, &record).await;
474            }
475        }
476        count
477    }
478
479    /// Refresh populated buckets by looking up a random key in each — keeps the routing table fresh
480    /// as peers churn. Call on the [`DhtConfig::refresh_interval`]. Returns the number of buckets
481    /// refreshed.
482    pub async fn refresh_buckets(&self) -> usize {
483        let indices = self.routing.lock().await.non_empty_bucket_indices();
484        let count = indices.len();
485        for idx in indices {
486            let target = self.random_key_in_bucket(idx);
487            let seeds = self.seed_contacts(&target).await;
488            if !seeds.is_empty() {
489                let result = self.run_lookup(target, seeds, false).await;
490                self.absorb_contacts(&result.closest).await;
491            }
492        }
493        count
494    }
495
496    /// Drop expired provider records from BOTH the authoritative store and the discovery cache
497    /// (SPEC §6.8). Call periodically (piggy-backs on republish/refresh). Returns the total number
498    /// of records removed.
499    ///
500    /// One `now` for both sweeps, so a maintenance tick cannot leave the two stores disagreeing
501    /// about which instant it ran at.
502    pub async fn gc(&self) -> usize {
503        let now = now_secs();
504        let authoritative = self.providers.lock().await.gc(now);
505        let cached = self.discovered.lock().await.gc(now);
506        authoritative + cached
507    }
508
509    /// Ping a peer for liveness; on failure, evict it from the routing table. Used by the
510    /// ping-and-replace maintenance when a bucket is full. Returns whether the peer is alive.
511    pub async fn ping(&self, peer: &Contact) -> bool {
512        let nonce = rand::random::<u64>();
513        let from = self.local_contact();
514        match self
515            .transport
516            .rpc(&from, peer, &DhtRequest::Ping { nonce })
517            .await
518        {
519            Ok(DhtResponse::Pong { nonce: got }) if got == nonce => true,
520            _ => {
521                self.routing.lock().await.remove(&peer.peer_id);
522                false
523            }
524        }
525    }
526
527    // ---- Serving side (inbound RPC) ----------------------------------------------------------
528
529    /// Answer an inbound DHT request from another node, without a known caller identity. Prefer
530    /// [`handle_request_from`](Self::handle_request_from) on an authenticated transport (it lets the
531    /// responder learn the caller and populate its routing table bidirectionally, the way Kademlia
532    /// tables fill).
533    pub async fn handle_request(&self, request: DhtRequest) -> DhtResponse {
534        self.handle_request_from(None, request).await
535    }
536
537    /// Answer an inbound DHT request, folding the **authenticated caller** into the routing table.
538    ///
539    /// This is the server half — a dig-node wires it to inbound DHT streams, passing the caller's
540    /// mTLS-verified [`Contact`] as `caller`. Learning the caller from every inbound RPC is how a
541    /// Kademlia node discovers peers *without* an explicit announce: a node that talks to you becomes
542    /// a candidate in your table. The caller MUST come from the authenticated transport (the mTLS
543    /// `peer_id`), never from the request body — identity is not self-asserted.
544    ///
545    /// It reads/writes only local state (routing table + provider store) and never makes outbound
546    /// RPCs, so it cannot recurse or block on the network.
547    pub async fn handle_request_from(
548        &self,
549        caller: Option<Contact>,
550        request: DhtRequest,
551    ) -> DhtResponse {
552        // The authenticated caller's peer_id (if any), kept for the AddProvider self-announce check
553        // below — taken BEFORE the caller Contact is (conditionally) moved into the routing table.
554        let caller_peer_id = caller.as_ref().map(|c| c.peer_id.clone());
555
556        // Learn the (authenticated) caller — every inbound RPC is evidence the caller is alive.
557        // Cap its address list at the boundary (SPEC §5.5, §14): a `Contact` decoded off the wire
558        // bypasses `Contact::new`'s cap entirely (its fields are public), so an uncapped caller
559        // address list would otherwise be folded straight into our routing table and later re-served
560        // to every peer that queries us.
561        if let Some(mut c) = caller {
562            if c.peer_id != self.local_id.to_hex() {
563                crate::record::sort_and_cap_addresses(&mut c.addresses);
564                let _ = self.routing.lock().await.insert(c);
565            }
566        }
567        match request {
568            DhtRequest::Ping { nonce } => DhtResponse::Pong { nonce },
569            DhtRequest::FindNode { target } => {
570                let Some(key) = parse_key(&target) else {
571                    return DhtResponse::Error {
572                        code: 2,
573                        message: "bad target key".into(),
574                    };
575                };
576                let nodes = self.routing.lock().await.closest(&key);
577                DhtResponse::Nodes { nodes }
578            }
579            DhtRequest::FindProviders { content_key } => {
580                let Some(key) = parse_key(&content_key) else {
581                    return DhtResponse::Error {
582                        code: 2,
583                        message: "bad content key".into(),
584                    };
585                };
586                let now = now_secs();
587                let providers = self.providers.lock().await.get(&key.to_hex(), now);
588                let closer = self.routing.lock().await.closest(&key);
589                DhtResponse::Providers { providers, closer }
590            }
591            DhtRequest::AddProvider { record } => {
592                // Self-announce check (SPEC §6.4, §14): when the caller identity is known (an
593                // authenticated transport), the record's provider_peer_id MUST be the caller itself.
594                // ProviderRecord carries no signature, so without this check any authenticated caller
595                // could announce an arbitrary THIRD-PARTY peer_id as a provider of arbitrary content
596                // at attacker-chosen addresses — provider-set poisoning. A caller we cannot identify
597                // (`handle_request`, no transport-supplied identity) cannot be checked and is let
598                // through unchanged — that path already deviates from the mTLS-authenticated model.
599                if let Some(caller_id) = &caller_peer_id {
600                    if *caller_id != record.provider_peer_id {
601                        return DhtResponse::Error {
602                            code: 4,
603                            message:
604                                "add_provider: provider_peer_id must match the authenticated caller"
605                                    .into(),
606                        };
607                    }
608                }
609
610                // Address-cap, TTL-clamp, admission-control, and (on acceptance) fold into routing —
611                // the shared verified-record admission pipeline (SPEC §6.3, §14).
612                match self.admit_verified_record(record).await {
613                    PutOutcome::Accepted => DhtResponse::AddProviderOk,
614                    PutOutcome::RejectedOverCapacity => DhtResponse::Error {
615                        code: 3,
616                        message: "provider store over capacity".into(),
617                    },
618                }
619            }
620        }
621    }
622
623    // ---- Internals ---------------------------------------------------------------------------
624
625    /// Admit a provider record whose provider attribution is ALREADY established — either the
626    /// serving-side mTLS self-announce check passed (`handle_request_from`'s `AddProvider` arm) or
627    /// the caller pre-verified the holder signature ([`ingest_verified_provider`]). This is the one
628    /// admission pipeline both paths share (SPEC §6.3, §14), in order:
629    ///
630    /// 1. **Cap the address list** at [`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)
631    ///    — a record decoded off the wire bypasses `ProviderRecord::new`'s cap (its fields are
632    ///    public), so an attacker could otherwise pack thousands of addresses into one record.
633    /// 2. **Normalize `unverified_mirror_coin_id`** to a canonical lowercase 64-hex string or
634    ///    `None`. Same reason as the address cap and the same blind spot: the wire boundary's
635    ///    `deserialize_mirror_coin_id` only runs under serde, so a record built by literal (how a
636    ///    consumer folds a verified holdings-announce in) could otherwise carry a body-sized
637    ///    pointer that this node stores AND re-serves until every querier's frame check rejects the
638    ///    answer, making the key undiscoverable through us for a full TTL.
639    /// 3. **Clamp `expires_at`** to `now + provider_ttl` — an inbound record is never trusted to
640    ///    self-report its expiry; without this a record naming `u64::MAX` would never GC.
641    /// 4. **Admission-control** via [`ProviderStore::put`], enforcing the per-key + global caps so a
642    ///    flood cannot grow the store without bound.
643    /// 5. On [`PutOutcome::Accepted`], **fold the holder into the routing table** (its addresses let
644    ///    us reach it). A rejected record folds nothing.
645    ///
646    /// [`ingest_verified_provider`]: Self::ingest_verified_provider
647    async fn admit_verified_record(&self, mut record: ProviderRecord) -> PutOutcome {
648        crate::record::sort_and_cap_addresses(&mut record.addresses);
649        record.unverified_mirror_coin_id =
650            crate::record::normalize_mirror_coin_id(record.unverified_mirror_coin_id.as_deref());
651
652        let now = now_secs();
653        let clamp_ceiling = now.saturating_add(self.config.provider_ttl_secs());
654        record.expires_at = record.expires_at.min(clamp_ceiling);
655
656        // `put_at` with the SAME instant the clamp used, so admission cannot reclaim a slot it
657        // considers expired while the clamp considered it live (or vice versa).
658        let outcome = self.providers.lock().await.put_at(record.clone(), now);
659        if outcome == PutOutcome::Accepted {
660            if let Some(pid) = record.provider_peer_id() {
661                let contact = Contact::new(&pid, record.addresses.clone());
662                let _ = self.routing.lock().await.insert(contact);
663            }
664        }
665        outcome
666    }
667
668    /// Cache the records a lookup for `content_key` collected, so a later fetch of the same content
669    /// can dial directly instead of walking the DHT again (SPEC §6.8, dig_ecosystem#3128 req 7).
670    ///
671    /// # Why this is a SEPARATE store from the authoritative one
672    ///
673    /// The two hold the same type and are admission-controlled by the same code, but they carry
674    /// different trust provenance, and the difference decides who may read them. An authoritative
675    /// record was attributed — the serving side checked the announcing record against its
676    /// mTLS-verified caller, or the caller of `ingest_verified_provider` checked the holder's
677    /// signature. A record collected during a lookup was attributed by NOBODY: an arbitrary peer
678    /// along the walk asserted that some third party holds the content, at addresses of its
679    /// choosing. Merging the two would make this node re-serve that assertion as its own on every
680    /// inbound `find_providers` — turning one fabricated record fed to one node into a poisoned
681    /// answer the rest of the network reads back, at a keyspace position this node has no `k`-closest
682    /// duty over. Kept apart, the worst a fabricated record achieves is a wasted dial by the one
683    /// node that cached it.
684    ///
685    /// Three admission rules, in order:
686    ///
687    /// 1. **Never cache a record naming THIS node.** It is useless as a dial target, and worse, it
688    ///    would make the cache non-empty and so suppress the next real lookup — a peer that echoed
689    ///    our own record back at us could pin us to a provider set of one entry we cannot use.
690    /// 2. **Never cache a record for a different key.** The wire boundary already discards those
691    ///    (SPEC §6.7); re-checking costs a string compare and this write outlives the lookup that
692    ///    produced it, so the invariant is asserted rather than assumed.
693    /// 3. **Clamp the expiry DOWN to `now + discovery_cache_ttl`**, never up. A peer cannot extend
694    ///    its residence in this node's cache by claiming a distant expiry, and a record that is
695    ///    already expired is not cached at all.
696    ///
697    /// Every surviving record goes through [`ProviderStore::put_at`], so the discovery cache's
698    /// per-key and global caps bound it exactly as the authoritative store's bound that one — this
699    /// write path has no way to exceed them.
700    async fn cache_discovered(&self, content_key: &str, discovered: &[ProviderRecord]) {
701        let now = now_secs();
702        let ceiling = now.saturating_add(self.config.discovery_cache_ttl_secs());
703        let self_id = self.local_id.to_hex();
704
705        let mut cache = self.discovered.lock().await;
706        for record in discovered {
707            if record.provider_peer_id == self_id || record.content_key != content_key {
708                continue;
709            }
710            let mut entry = record.clone();
711            entry.expires_at = entry.expires_at.min(ceiling);
712            if entry.is_expired(now) {
713                continue;
714            }
715            cache.put_at(entry, now);
716        }
717    }
718
719    /// Build a provider record for content key `target` naming THIS node, expiring at
720    /// `now + provider_ttl`.
721    fn build_local_record(&self, target: &Key) -> ProviderRecord {
722        let expires_at = now_secs().saturating_add(self.config.provider_ttl_secs());
723        ProviderRecord::new(
724            target,
725            &self.local_id,
726            self.local_addresses.clone(),
727            expires_at,
728        )
729    }
730
731    /// The seed set for a lookup toward `target`: the closest contacts we currently know.
732    async fn seed_contacts(&self, target: &Key) -> Vec<Contact> {
733        self.routing.lock().await.closest(target)
734    }
735
736    /// Run an iterative lookup toward `target` from `seeds`, querying peers over the transport. Each
737    /// peer is asked `find_providers` (which also returns closer contacts), so ONE query kind serves
738    /// both node- and provider-lookups; `stop_on_providers` controls early exit.
739    async fn run_lookup(
740        &self,
741        target: Key,
742        seeds: Vec<Contact>,
743        stop_on_providers: bool,
744    ) -> crate::lookup::LookupResult {
745        let transport = self.transport.clone();
746        let content_key = target.to_hex();
747        let from = self.local_contact();
748        let query = move |contact: Contact| {
749            let transport = transport.clone();
750            let content_key = content_key.clone();
751            let from = from.clone();
752            async move {
753                let req = DhtRequest::FindProviders {
754                    content_key: content_key.clone(),
755                };
756                match transport.rpc(&from, &contact, &req).await {
757                    Ok(DhtResponse::Providers {
758                        mut providers,
759                        closer,
760                    }) => {
761                        // Answer-to-question binding (SPEC §6.7, §14): keep only records for the
762                        // key we actually asked about. A responder is free to say ANYTHING here —
763                        // `ProviderRecord` carries no signature and the peer is not the record's
764                        // subject — so without this equality check any peer on the lookup path
765                        // could stamp arbitrary provider peer_ids and address hints onto records
766                        // for keys the finder never queried, and the finder would return them to
767                        // its caller as dial targets (dial fan-out / wasted-dial DoS, and a
768                        // spirit-defeat of the #1490 amplification bound).
769                        //
770                        // Filtering HERE, at the wire boundary, rather than at the final merge is
771                        // load-bearing: the lookup's `stop_on_providers` early exit fires as soon
772                        // as any provider is collected, so a mismatched record counted as "found"
773                        // would end the walk before it reached a real holder — discovery
774                        // censorship. Nothing downstream of this point sees an off-key record.
775                        providers.retain(|r| r.content_key == content_key);
776                        Ok(QueryOutcome { closer, providers })
777                    }
778                    Ok(DhtResponse::Nodes { nodes }) => Ok(QueryOutcome {
779                        closer: nodes,
780                        providers: vec![],
781                    }),
782                    _ => Err(()),
783                }
784            }
785        };
786        iterative_find(
787            target,
788            seeds,
789            self.config.k,
790            self.config.alpha,
791            stop_on_providers,
792            query,
793        )
794        .await
795    }
796
797    /// Fold discovered contacts back into the routing table (skipping ourselves). Applies the LRS
798    /// insert policy; a full bucket's [`InsertOutcome::Full`] is left for the ping-and-replace
799    /// maintenance (we do not ping inline to keep lookups fast).
800    ///
801    /// `contacts` come straight off the wire (a peer's `find_node`/`find_providers` response) and
802    /// so bypass [`Contact::new`]'s address cap (its fields are public) — this is another
803    /// untrusted-input boundary (SPEC §5.5, §14), capped here before insertion.
804    async fn absorb_contacts(&self, contacts: &[Contact]) {
805        let mut rt = self.routing.lock().await;
806        for c in contacts {
807            let mut c = c.clone();
808            crate::record::sort_and_cap_addresses(&mut c.addresses);
809            match rt.insert(c) {
810                InsertOutcome::Inserted => {}
811                InsertOutcome::Full { .. } => {
812                    // Bucket full — leave for ping-and-replace; do not block the lookup on a ping.
813                }
814            }
815        }
816    }
817
818    /// PUT `record` at each of `peers` via `add_provider`, counting acceptances. A peer that errors
819    /// is skipped (best-effort replication — the record survives at the peers that accepted + locally).
820    async fn put_record_at(&self, peers: &[Contact], record: &ProviderRecord) -> usize {
821        let req = DhtRequest::AddProvider {
822            record: record.clone(),
823        };
824        let from = self.local_contact();
825        let mut accepted = 0;
826        for p in peers {
827            if p.peer_id == self.local_id.to_hex() {
828                continue; // already stored locally
829            }
830            if let Ok(DhtResponse::AddProviderOk) = self.transport.rpc(&from, p, &req).await {
831                accepted += 1;
832            }
833        }
834        accepted
835    }
836
837    /// A random key whose distance from this node falls in bucket `idx` (so a refresh lookup targets
838    /// that bucket's region). Sets the bit at position `255 - idx` and randomizes the lower bits.
839    fn random_key_in_bucket(&self, idx: usize) -> Key {
840        let local = *self.local_id.as_bytes();
841        let mut distance = [0u8; 32];
842        let bit = 255 - idx; // MSB-set position for this bucket
843        let byte = bit / 8;
844        let bit_in_byte = 7 - (bit % 8);
845        distance[byte] = 1 << bit_in_byte;
846        // Randomize lower-significant bits so successive refreshes vary the target.
847        for b in distance.iter_mut().skip(byte + 1) {
848            *b = rand::random::<u8>();
849        }
850        let mut target = [0u8; 32];
851        for i in 0..32 {
852            target[i] = local[i] ^ distance[i];
853        }
854        Key::from_bytes(target)
855    }
856
857    /// The contacts currently in this node's routing table closest to `target` (diagnostic /
858    /// introspection — the peers this node knows without any network round-trip).
859    pub async fn known_closest(&self, target: &Key) -> Vec<Contact> {
860        self.routing.lock().await.closest(target)
861    }
862
863    /// The number of peers currently in this node's routing table (diagnostic / metrics).
864    pub async fn routing_len(&self) -> usize {
865        self.routing.lock().await.len()
866    }
867}
868
869/// Merge two provider sets into one answer: `authoritative` first, then `extra`, deduped by
870/// provider `peer_id` and with anything expired at `now` dropped.
871///
872/// Order is the contract, not an accident. The caller dials the list front-to-back, so the records
873/// whose provenance this node established lead, and the weaker-provenance set (a discovery-cache
874/// hit, or the records a lookup just collected) follows. A provider present in both keeps its
875/// authoritative entry, because the first occurrence wins.
876fn merge_dedup_by_provider(
877    mut authoritative: Vec<ProviderRecord>,
878    extra: Vec<ProviderRecord>,
879    now: u64,
880) -> Vec<ProviderRecord> {
881    authoritative.extend(extra);
882    let mut seen = std::collections::HashSet::new();
883    authoritative.retain(|r| !r.is_expired(now) && seen.insert(r.provider_peer_id.clone()));
884    authoritative
885}
886
887/// Parse a 64-hex string into a [`Key`] (used on the serving side for wire targets).
888fn parse_key(hex: &str) -> Option<Key> {
889    hex64_to_bytes(hex).map(Key::from_bytes)
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    fn key_hex_round_trips() {
897        // sanity for the local hex helper
898    }
899
900    #[test]
901    fn hex64_round_trip() {
902        let bytes = [0xABu8; 32];
903        let hex = Key::from_bytes(bytes).to_hex();
904        assert_eq!(hex64_to_bytes(&hex).unwrap(), bytes);
905        assert!(hex64_to_bytes("short").is_none());
906        assert!(hex64_to_bytes(&"zz".repeat(32)).is_none());
907        key_hex_round_trips();
908    }
909
910    #[test]
911    fn parse_key_rejects_bad_hex() {
912        assert!(parse_key("nothex").is_none());
913        assert!(parse_key(&"00".repeat(32)).is_some());
914    }
915}
916
917#[cfg(test)]
918mod collateral_pointer_tests {
919    use super::*;
920    use crate::record::CandidateAddr;
921
922    const BONDED_COIN: [u8; 32] = [0x5c; 32];
923
924    /// A transport that is never dialled: these tests exercise the LOCAL provider store only, so an
925    /// unseeded routing table makes every lookup a no-op.
926    struct UnusedTransport;
927
928    #[async_trait::async_trait]
929    impl crate::transport::DhtTransport for UnusedTransport {
930        async fn rpc(
931            &self,
932            _from: &Contact,
933            _peer: &Contact,
934            _request: &DhtRequest,
935        ) -> Result<DhtResponse, DhtError> {
936            unreachable!("collateral-pointer tests never dial a peer")
937        }
938    }
939
940    fn service() -> DhtService {
941        DhtService::new(
942            PeerId::from_bytes([9u8; 32]),
943            vec![CandidateAddr::direct("h", 9444)],
944            DhtConfig::default(),
945            Arc::new(UnusedTransport),
946        )
947    }
948
949    /// The local record this node published for `content`.
950    async fn local_record(svc: &DhtService, content: &ContentId) -> ProviderRecord {
951        svc.providers
952            .lock()
953            .await
954            .get(&content.to_key().to_hex(), now_secs())
955            .into_iter()
956            .find(|r| r.provider_peer_id == svc.local_id.to_hex())
957            .expect("this node should have a local record for the announced content")
958    }
959
960    #[tokio::test]
961    async fn announcing_with_collateral_publishes_the_pointer_and_without_omits_it() {
962        let svc = service();
963        let bonded = ContentId::store([1u8; 32]);
964        let bare = ContentId::store([2u8; 32]);
965
966        svc.announce_provider_with_collateral(&bonded, Some(BONDED_COIN))
967            .await
968            .unwrap();
969        svc.announce_provider(&bare).await.unwrap();
970
971        assert_eq!(
972            local_record(&svc, &bonded)
973                .await
974                .unverified_mirror_coin_id_bytes(),
975            Some(BONDED_COIN)
976        );
977        assert_eq!(
978            local_record(&svc, &bare).await.unverified_mirror_coin_id,
979            None,
980            "a bare announce must not acquire a pointer from a sibling announce"
981        );
982    }
983
984    /// The PLACEMENT test. Republish rebuilds the record from scratch, so a pointer held anywhere
985    /// but per-announced-key is lost on the first TTL rollover — a node would look collateralised
986    /// for one TTL and bare afterwards.
987    ///
988    /// Two keys, exactly one pointered: a service-wide or config-held pointer would re-attach it to
989    /// BOTH and pass a single-key version of this test. That is the nearest wrong implementation,
990    /// so the bare key is the control that makes relocation observable.
991    #[tokio::test]
992    async fn republish_re_attaches_each_keys_own_pointer_and_only_its_own() {
993        let svc = service();
994        let bonded = ContentId::store([1u8; 32]);
995        let bare = ContentId::store([2u8; 32]);
996
997        svc.announce_provider_with_collateral(&bonded, Some(BONDED_COIN))
998            .await
999            .unwrap();
1000        svc.announce_provider(&bare).await.unwrap();
1001
1002        assert_eq!(svc.republish().await, 2);
1003
1004        assert_eq!(
1005            local_record(&svc, &bonded)
1006                .await
1007                .unverified_mirror_coin_id_bytes(),
1008            Some(BONDED_COIN),
1009            "republish dropped the pointer this key was announced with"
1010        );
1011        assert_eq!(
1012            local_record(&svc, &bare).await.unverified_mirror_coin_id,
1013            None,
1014            "republish invented a pointer for a key that never had one"
1015        );
1016    }
1017
1018    /// Re-announcing after an epoch rollover replaces the pointer rather than accumulating one.
1019    #[tokio::test]
1020    async fn re_announcing_replaces_the_pointer() {
1021        let svc = service();
1022        let content = ContentId::store([1u8; 32]);
1023        let next_epoch_coin = [0xE7; 32];
1024
1025        svc.announce_provider_with_collateral(&content, Some(BONDED_COIN))
1026            .await
1027            .unwrap();
1028        svc.announce_provider_with_collateral(&content, Some(next_epoch_coin))
1029            .await
1030            .unwrap();
1031        svc.republish().await;
1032
1033        assert_eq!(
1034            local_record(&svc, &content)
1035                .await
1036                .unverified_mirror_coin_id_bytes(),
1037            Some(next_epoch_coin)
1038        );
1039    }
1040
1041    /// The NON-SERDE ingress. `ingest_verified_provider` takes an already-constructed
1042    /// [`ProviderRecord`], whose fields are all `pub`, so `deserialize_mirror_coin_id` never runs on
1043    /// it - which is exactly how a consumer folding a verified holdings-announce into the DHT builds
1044    /// one. A test that goes through serde passes without the fix and proves nothing, so this one
1045    /// builds the record by struct literal.
1046    ///
1047    /// Three pointers, because "clears the field" and "normalizes the field" are different
1048    /// implementations and only a truthful control tells them apart: one oversized (sized FROM the
1049    /// protocol's own [`MAX_FRAMED_BODY`] ceiling, which is the value that makes the record
1050    /// unservable), one 64 chars but not hex (a length-only check would admit it), and one VALID,
1051    /// which must survive.
1052    #[tokio::test]
1053    async fn ingesting_a_record_built_by_literal_normalizes_its_pointer() {
1054        use crate::wire::MAX_FRAMED_BODY;
1055
1056        let svc = service();
1057        let valid = crate::record::to_hex64(&BONDED_COIN);
1058
1059        let cases: [(&str, String, Option<String>); 3] = [
1060            (
1061                "an oversized pointer must not be stored",
1062                "a".repeat(MAX_FRAMED_BODY),
1063                None,
1064            ),
1065            (
1066                "a 64-char non-hex pointer must not be stored",
1067                "z".repeat(64),
1068                None,
1069            ),
1070            (
1071                "a canonical pointer must survive ingest",
1072                valid.clone(),
1073                Some(valid.clone()),
1074            ),
1075        ];
1076
1077        for (i, (why, pointer, expected)) in cases.into_iter().enumerate() {
1078            let content = ContentId::store([i as u8 + 40; 32]);
1079            let content_key = content.to_key().to_hex();
1080            let holder = PeerId::from_bytes([i as u8 + 70; 32]);
1081
1082            let outcome = svc
1083                .ingest_verified_provider(ProviderRecord {
1084                    content_key: content_key.clone(),
1085                    provider_peer_id: holder.to_hex(),
1086                    addresses: vec![CandidateAddr::direct("holder.example", 9444)],
1087                    expires_at: now_secs() + 60,
1088                    unverified_mirror_coin_id: Some(pointer),
1089                })
1090                .await;
1091            assert_eq!(outcome, PutOutcome::Accepted, "{why}: ingest must accept");
1092
1093            let providers = svc.providers.lock().await.get(&content_key, now_secs());
1094            let stored = providers
1095                .iter()
1096                .find(|r| r.provider_peer_id == holder.to_hex())
1097                .expect("the ingested record should be stored");
1098            assert_eq!(stored.unverified_mirror_coin_id, expected, "{why}");
1099
1100            // The harm the bound exists to prevent: an oversized pointer is re-served in every
1101            // answer for this key, and no OUTBOUND cap trims it - so the frame the querier must
1102            // decode is what actually has to stay under the ceiling.
1103            let frame = crate::wire::DhtResponse::Providers {
1104                providers: providers.clone(),
1105                closer: vec![],
1106            }
1107            .encode();
1108            assert!(
1109                frame.len() <= MAX_FRAMED_BODY,
1110                "{why}: the answer for this key is unservable at {} bytes",
1111                frame.len()
1112            );
1113        }
1114    }
1115
1116    /// Withdrawing forgets the pointer with the announcement, so a later bare re-announce cannot
1117    /// resurrect a stale coin id.
1118    #[tokio::test]
1119    async fn withdrawing_forgets_the_pointer() {
1120        let svc = service();
1121        let content = ContentId::store([1u8; 32]);
1122
1123        svc.announce_provider_with_collateral(&content, Some(BONDED_COIN))
1124            .await
1125            .unwrap();
1126        svc.withdraw_provider(&content).await;
1127        svc.announce_provider(&content).await.unwrap();
1128        svc.republish().await;
1129
1130        assert_eq!(
1131            local_record(&svc, &content).await.unverified_mirror_coin_id,
1132            None
1133        );
1134    }
1135}
1136
1137#[cfg(test)]
1138mod provider_snapshot_tests {
1139    use super::*;
1140    use crate::record::CandidateAddr;
1141
1142    /// A transport that is never dialled: these tests only exercise the LOCAL provider store.
1143    struct UnusedTransport;
1144
1145    #[async_trait::async_trait]
1146    impl crate::transport::DhtTransport for UnusedTransport {
1147        async fn rpc(
1148            &self,
1149            _from: &Contact,
1150            _peer: &Contact,
1151            _request: &DhtRequest,
1152        ) -> Result<DhtResponse, DhtError> {
1153            unreachable!("provider-snapshot tests never dial a peer")
1154        }
1155    }
1156
1157    fn service() -> DhtService {
1158        DhtService::new(
1159            PeerId::from_bytes([9u8; 32]),
1160            vec![CandidateAddr::direct("h", 9444)],
1161            DhtConfig::default(),
1162            Arc::new(UnusedTransport),
1163        )
1164    }
1165
1166    async fn announce(svc: &DhtService, content_seed: u8, provider_seed: u8) {
1167        let content = ContentId::store([content_seed; 32]);
1168        svc.ingest_verified_provider(ProviderRecord::new(
1169            &content.to_key(),
1170            &PeerId::from_bytes([provider_seed; 32]),
1171            vec![CandidateAddr::direct("h", 9444)],
1172            now_secs() + 3600,
1173        ))
1174        .await;
1175    }
1176
1177    /// The accessor RLY-009 answers from: counts reachable WITHOUT handing out the store, and
1178    /// without a single provider identity crossing the boundary (dig_ecosystem #1935).
1179    #[tokio::test]
1180    async fn provider_snapshot_reports_counts_and_no_identities() {
1181        let svc = service();
1182        announce(&svc, 1, 7).await;
1183
1184        let snap = svc.provider_snapshot(100).await;
1185
1186        assert_eq!(snap.total_keys, 1);
1187        assert_eq!(snap.entries[0].providers, 1);
1188        assert!(
1189            !format!("{snap:?}").contains(&PeerId::from_bytes([7u8; 32]).to_hex()),
1190            "a provider identity must never leave the store through this accessor"
1191        );
1192    }
1193
1194    /// The bound is honoured: the store is attacker-influenced, so the answer size must be OURS.
1195    #[tokio::test]
1196    async fn provider_snapshot_honours_the_bound() {
1197        let svc = service();
1198        for i in 0..6u8 {
1199            announce(&svc, i, 100 + i).await;
1200        }
1201        let snap = svc.provider_snapshot(2).await;
1202        assert_eq!(snap.entries.len(), 2);
1203        assert!(snap.truncated);
1204        assert_eq!(snap.total_keys, 6, "the true total survives truncation");
1205    }
1206}