Skip to main content

offline_protocol/protocol/
nostr_discovery.rs

1//! Username discovery: publishing this install's claim, and resolving a name
2//! to the set of devices claiming it.
3//!
4//! The publication half mirrors [`super::nostr_publication`] closely enough
5//! that the differences are the interesting part:
6//!
7//! - **One claim, not a set of slots.** A key package is single-use, so several
8//!   must stand at once. A claim is a statement, so exactly one stands and
9//!   republishing *replaces* it.
10//! - **A deterministic `d` tag.** That is what makes replacement work, and it
11//!   is what makes retraction possible at all.
12//! - **Retraction exists.** A key-package slot is abandoned by letting it
13//!   expire; a claim must be actively withdrawn, because it names a human and
14//!   points at an address that stays live.
15//!
16//! The resolution half has one rule that outranks its mechanics: **a username
17//! resolves to a set, and the set is the only thing ever emitted.** See
18//! [`PendingResolution`].
19
20use std::collections::{HashMap, HashSet};
21use std::time::{Duration, Instant};
22
23use base64::Engine as _;
24use offline_protocol_core::Username;
25use offline_protocol_mls::discovery::{
26    parse_discovery_body, verify_discovery_record, DiscoveryBody, DiscoveryRecordV1,
27    DiscoveryTombstoneV1,
28};
29use offline_protocol_transport::nostr::ResolveRequest;
30use offline_protocol_transport::nostr_crypto::discovery_tag_for_username;
31use serde::{Deserialize, Serialize};
32use tracing::{debug, warn};
33
34use super::{lock_shared_state, storage_keys, OfflineProtocol, ProtocolState};
35use crate::events::{Event, UsernameClaim};
36use crate::{Error, Result};
37
38/// How often the discovery claim is re-examined, matching the slot refresh.
39const DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
40
41/// Ceiling on the republish backoff, and the quiet period after which a
42/// failure streak resets. Matches the key-package publication ladder.
43const DISCOVERY_MAX_BACKOFF: Duration = Duration::from_secs(1800);
44
45/// How long a resolution may accumulate before it is flushed regardless.
46///
47/// End-of-stored-events is the natural completion signal, and it is the one a
48/// relay is free not to send. Without this sweep a relay that goes quiet after
49/// answering would leave the resolution accumulating forever and the app
50/// waiting on an event that never comes — a hang rather than an empty result.
51/// Sized well above a relay round-trip and well below any human's patience.
52const RESOLUTION_TIMEOUT: Duration = Duration::from_secs(30);
53
54/// Maximum concurrent username resolutions.
55///
56/// Bounded like every other query-keyed map: a caller can start resolutions
57/// faster than relays answer them, and an app looping over a contact list
58/// would otherwise grow this without limit. At capacity the *oldest* is
59/// flushed with whatever it has rather than dropped silently, so its caller
60/// still receives an answer.
61///
62/// Deliberately *not* the transport's queue ceiling, and named differently to
63/// keep the two from being read as one number: that one bounds lookups waiting
64/// to be minted into queries, this one bounds accumulators waiting for answers.
65/// A lookup passes through both, so the smaller of the two is what a caller
66/// actually gets, and equalizing them would only hide which limit was reached.
67const MAX_CONCURRENT_RESOLUTIONS: usize = 32;
68
69/// Maximum names for which a tombstone may be owed at once.
70///
71/// One rename owes one retraction, and each is retired as soon as a relay
72/// acknowledges it, so reaching this means retractions have been failing across
73/// several renames. The oldest is dropped, because an unbounded list persisted
74/// across restarts is worse than an old claim this install can no longer
75/// withdraw: hop 2 still arbitrates the stale claim, and nothing arbitrates a
76/// record that grows forever.
77const MAX_PENDING_RETRACTIONS: usize = 8;
78
79/// Maximum claims accumulated for one username.
80///
81/// The tag is public, so a squatter can publish many records at it. This caps
82/// what one resolution can cost in memory and what an app is asked to render.
83/// Reaching it means crowding, which the design accepts: the honest claimants
84/// may be among the ones displaced, which is why the invite path exists and
85/// why a user confirms out of band.
86const MAX_CLAIMS_PER_RESOLUTION: usize = 32;
87
88/// What this install claims, and what it still owes a tombstone for.
89///
90/// # Why the owed retractions are persisted beside the claim
91///
92/// A retraction is the one operation here that cannot be reconstructed from
93/// anything else. The claim can: it is `config.profile`, so a lost record costs
94/// one redundant publish. The *old* name is gone the moment the profile
95/// changes, so a record that forgets it before the tombstone lands leaves a
96/// human-readable name standing in a public directory, pointing at an address
97/// this install is still publishing key packages for. Nothing expires it,
98/// because the second hop it dead-ends at is exactly the hop that still works.
99///
100/// So a name moves from [`Self::claim`] to [`Self::retracting`] and stays there
101/// until a relay acknowledges the tombstone. That survives a failed send, a
102/// process restart, and the case that has no failure to report at all: no Nostr
103/// transport installed, where queueing a retraction is a silent no-op.
104#[derive(Debug, Default, Clone, Serialize, Deserialize)]
105pub(crate) struct DiscoveryClaimState {
106    /// The name currently claimed, if any.
107    #[serde(default)]
108    pub(crate) claim: Option<Username>,
109    /// Names whose tombstone has not yet reached a relay, oldest first.
110    #[serde(default)]
111    pub(crate) retracting: Vec<Username>,
112}
113
114/// A username resolution in flight.
115///
116/// # Why claims accumulate here instead of being emitted as they arrive
117///
118/// Emitting one event per claim would make the *first* claim the easiest thing
119/// for an app to consume, and the first claim is meaningless — it is whichever
120/// relay answered fastest. An app written the obvious way against a stream of
121/// per-claim events silently becomes an app that picks a winner, which is
122/// precisely the failure this layer must not enable.
123///
124/// So the set accumulates here and leaves as one event. The shape makes the
125/// correct behaviour the path of least resistance rather than a documented
126/// obligation, which is the only kind of API rule that survives contact with a
127/// deadline.
128pub(crate) struct PendingResolution {
129    /// The username being resolved.
130    username: Username,
131    /// Verified claims, keyed by the publishing Nostr author key.
132    ///
133    /// Keyed by author rather than by address because that is what a device
134    /// *is* here: one install publishes under one Nostr key. Two records from
135    /// the same author are the same device republishing, and the newer one
136    /// wins. Two records from different authors naming the same address would
137    /// be unusual (it needs the same identity key on two installs) and are
138    /// kept separate, since collapsing them would hide a real anomaly.
139    claims: HashMap<String, DiscoveryRecordV1>,
140    /// Authors that have retracted, so a retraction survives whatever order the
141    /// relays answer in.
142    ///
143    /// A tombstone and the record it replaces both live on the relays for a
144    /// while: the tombstone occupies the addressable slot, and a relay that
145    /// missed the replacement still serves the old record. Removing the claim
146    /// on arrival is therefore not enough — a stale copy landing afterwards
147    /// would verify (it is genuinely signed) and stand the retracted claim back
148    /// up, which is precisely the outcome retraction exists to prevent.
149    ///
150    /// A suppressed record is **not** counted in [`Self::rejected`]. It is not
151    /// junk, and counting it would make the same two events report differently
152    /// depending on which arrived first, reintroducing the order-dependence in
153    /// the counter after removing it from the set.
154    ///
155    /// Bounded by the transport's per-query delivery ceiling, like `claims`.
156    tombstoned: HashSet<String>,
157    /// Records seen and refused. Reported so an app can tell "nobody claims
158    /// this name" apart from "everything claiming it was junk".
159    rejected: u32,
160    /// Verified claims dropped at [`MAX_CLAIMS_PER_RESOLUTION`].
161    ///
162    /// Counted separately from [`Self::rejected`] because it is the opposite
163    /// statement: these records passed every check and are missing anyway.
164    /// Folding them into the refusal count would report a squatted name as a
165    /// junk-filled one, and reporting neither would render it as a clean set.
166    truncated: u32,
167    /// When the resolution began, driving the timeout sweep.
168    started: Instant,
169}
170
171impl OfflineProtocol {
172    /// Publishes, republishes or retracts this install's username claim.
173    ///
174    /// Runs on the process tick beside the key-package slot refresh. Four
175    /// states, and the transitions between them are the whole of this
176    /// function:
177    ///
178    /// 1. discovery is off and nothing stands: do nothing;
179    /// 2. discovery is off and a claim stands: retract it;
180    /// 3. discovery is on and the standing claim names a different username
181    ///    than the current profile: retract the old one, then publish the new;
182    /// 4. discovery is on and the claim is current: republish once per process;
183    /// 5. a tombstone is owed, from this run or a previous one: queue it again,
184    ///    until a relay acknowledges it.
185    ///
186    /// State 3 is the one that is easy to omit and expensive to omit. A user
187    /// who renames leaves their old name standing in a public directory,
188    /// pointing at an address that is still live, with nothing to ever remove
189    /// it — and the *new* owner of that name looks like a squatter next to it.
190    ///
191    /// State 5 is why states 2 and 3 record the debt rather than discharging
192    /// it. Queueing a tombstone is not the same as landing one: the send can
193    /// fail, the process can exit first, and with no Nostr transport installed
194    /// the queue call is a silent no-op. Each of those leaves the same stale
195    /// claim as omitting state 3 entirely, and the name is unrecoverable by
196    /// then because the profile has already moved on.
197    pub(crate) fn refresh_nostr_discovery_claim(&mut self) {
198        if !self.nostr_discovery_refresh_due() {
199            return;
200        }
201
202        let now = Instant::now();
203
204        // Drained after the throttle check, like the slot reports: draining is
205        // destructive and a throttled tick that dropped them would lose them.
206        //
207        // Confirmations before failures, so a tag reported both ways inside one
208        // interval settles as landed. The other order would leave a retraction
209        // that reached a relay owed forever, re-queued on every tick.
210        let confirmed = self
211            .transport_manager
212            .take_confirmed_nostr_discovery_publications();
213        self.retire_confirmed_retractions(&confirmed);
214
215        for tag in self
216            .transport_manager
217            .take_failed_nostr_discovery_publications()
218        {
219            // Scoped to the standing claim's own tag. A failed *retraction* of
220            // a name this install no longer claims says nothing about whether
221            // the current claim is on the relays, and clearing the flag for it
222            // republishes a healthy claim on every such failure while the
223            // backoff ladder, which is keyed by the failing tag, does not
224            // apply.
225            if self.discovery_tag_is_claimed(&tag) {
226                self.nostr_discovery_published = false;
227            }
228            self.note_discovery_failure(&tag, now);
229        }
230
231        let enabled = self.transport_manager.nostr_discovery_active();
232        let desired = if enabled {
233            match self.config.profile.parse::<Username>() {
234                Ok(username) => Some(username),
235                Err(e) => {
236                    // Not a warning event: an app whose profile is not a
237                    // claimable username has simply not opted into a feature
238                    // that needs one, and saying so once per minute would be
239                    // noise. The claim is not published, which is the correct
240                    // and safe outcome.
241                    debug!(
242                        error = %e,
243                        "Profile is not a claimable username; discovery claim not published"
244                    );
245                    None
246                }
247            }
248        } else {
249            None
250        };
251
252        // Retract whatever stands that should not. This only *records* the
253        // debt; queueing it is the next step, and is retried from the record
254        // until a relay acknowledges it.
255        if let Some(standing) = self.nostr_discovery_claim.clone() {
256            if desired.as_ref() != Some(&standing) {
257                self.owe_retraction(standing);
258            }
259        }
260
261        self.queue_owed_retractions(desired.as_ref(), now);
262        self.prune_discovery_backoff(desired.as_ref());
263
264        let Some(username) = desired else {
265            return;
266        };
267
268        if self.nostr_discovery_claim.as_ref() == Some(&username) && self.nostr_discovery_published
269        {
270            return;
271        }
272
273        if self.discovery_backoff_active(&username, now) {
274            return;
275        }
276
277        // Identity readiness is a *timing* state, not a fault: this tick runs
278        // from process start, and MLS init plus the Nostr signing key land
279        // some ticks later. Warning about it would emit once a minute during
280        // every normal startup and train a reader to ignore the one message
281        // that means something. Checked before the attempt rather than
282        // classified after it, so the two cannot drift.
283        if self.mls_manager.is_none()
284            || self.local_address().is_none()
285            || self.transport_manager.nostr_public_key().is_none()
286        {
287            debug!("Identity not ready yet; discovery claim deferred to a later tick");
288            return;
289        }
290
291        if let Err(e) = self.publish_discovery_claim(username) {
292            warn!(error = %e, "Failed to publish the Nostr username discovery claim");
293        }
294    }
295
296    /// Builds, signs and queues the claim record.
297    fn publish_discovery_claim(&mut self, username: Username) -> Result<()> {
298        let address = self
299            .local_address()
300            .ok_or_else(|| Error::Other("No local address yet".to_string()))?
301            .parse()
302            .map_err(|e| Error::Other(format!("Local address is not canonical: {}", e)))?;
303
304        let nostr_author = self
305            .transport_manager
306            .nostr_public_key()
307            .ok_or_else(|| Error::Other("Nostr transport has no signing key yet".to_string()))?;
308        let author_bytes = hex::decode(&nostr_author)
309            .map_err(|e| Error::Other(format!("Nostr public key is not hex: {}", e)))?;
310
311        let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
312        let manager = mls
313            .read()
314            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
315        let public_key = manager
316            .get_identity_public_key()
317            .map_err(|e| Error::Other(format!("Failed to get identity public key: {}", e)))?;
318
319        let record = DiscoveryRecordV1::unsigned(
320            username.clone(),
321            address,
322            public_key,
323            author_bytes,
324            chrono::Utc::now().timestamp_millis(),
325        )
326        .sign_with(|payload| manager.sign_data(payload))
327        .map_err(|e| Error::Other(format!("Failed to sign the discovery record: {}", e)))?;
328        drop(manager);
329
330        let payload =
331            serde_json::to_vec(&record).map_err(|e| Error::Serialization(e.to_string()))?;
332
333        self.transport_manager
334            .publish_nostr_discovery_record(username.clone(), payload);
335
336        self.nostr_discovery_published = true;
337
338        // Claiming a name cancels any tombstone still owed for it. The two are
339        // contradictory statements about one addressable slot and the claim is
340        // the newer one, which is also why the transport collapses a queued
341        // tombstone for this name into this publication rather than sending
342        // both.
343        let claim_changed = self.nostr_discovery_claim.as_ref() != Some(&username);
344        let debt_cleared = self.remove_owed_retraction(&username);
345        if claim_changed {
346            self.nostr_discovery_claim = Some(username);
347        }
348        if claim_changed || debt_cleared {
349            self.persist_nostr_discovery_claim();
350        }
351        Ok(())
352    }
353
354    /// Records that a name is owed a tombstone, and stops claiming it.
355    ///
356    /// Recording is separate from queueing because the two can fail
357    /// independently, and only one of them is recoverable from anything else.
358    /// The debt is written down first, so every later attempt has a name to
359    /// retract: without it the sole record of the old name is gone the moment
360    /// the profile changes.
361    fn owe_retraction(&mut self, username: Username) {
362        if !self.nostr_discovery_retracting.contains(&username) {
363            if self.nostr_discovery_retracting.len() >= MAX_PENDING_RETRACTIONS {
364                self.nostr_discovery_retracting.remove(0);
365                // The name itself is not logged. It is the most personal
366                // identifier this module handles, and a device log is not the
367                // place to write one down; the count is what a reader needs.
368                warn!(
369                    cap = MAX_PENDING_RETRACTIONS,
370                    "Too many un-landed username retractions; giving up on the \
371                     oldest. Its claim may stand until its key packages expire"
372                );
373            }
374            self.nostr_discovery_retracting.push(username);
375        }
376        self.nostr_discovery_claim = None;
377        self.nostr_discovery_published = false;
378        self.persist_nostr_discovery_claim();
379    }
380
381    /// Drops a name from the owed set, reporting whether it was there.
382    fn remove_owed_retraction(&mut self, username: &Username) -> bool {
383        let before = self.nostr_discovery_retracting.len();
384        self.nostr_discovery_retracting
385            .retain(|owed| owed != username);
386        self.nostr_discovery_retracting.len() != before
387    }
388
389    /// Retires the retractions whose tombstones a relay acknowledged.
390    fn retire_confirmed_retractions(&mut self, tags: &[String]) {
391        if tags.is_empty() || self.nostr_discovery_retracting.is_empty() {
392            return;
393        }
394        let before = self.nostr_discovery_retracting.len();
395        self.nostr_discovery_retracting.retain(|username| {
396            // A name whose tag will not derive can never be confirmed either,
397            // so keeping it would owe a tombstone forever. It cannot arise from
398            // a parsed `Username`, and dropping it is the terminating choice.
399            match discovery_tag_for_username(username) {
400                Ok(tag) => !tags.contains(&tag),
401                Err(_) => false,
402            }
403        });
404        if self.nostr_discovery_retracting.len() != before {
405            debug!("A username retraction reached a relay; no longer owed");
406            self.persist_nostr_discovery_claim();
407        }
408    }
409
410    /// Queues a tombstone, and a best-effort deletion, for every name still
411    /// owed one.
412    ///
413    /// Re-queued on every refresh until a relay acknowledges it, which is what
414    /// carries a retraction across a failed send, a restart, and a run with no
415    /// Nostr transport installed. Idempotent at the relay: a tombstone
416    /// republished into its own addressable slot replaces itself.
417    fn queue_owed_retractions(&mut self, desired: Option<&Username>, now: Instant) {
418        if self.nostr_discovery_retracting.is_empty() {
419            return;
420        }
421        // Nothing here can carry a tombstone, and `retract_nostr_discovery_record`
422        // would accept it silently. Keeping the debt is the entire point: it is
423        // queued once a transport exists.
424        if !self.transport_manager.has_nostr_transport() {
425            debug!("No Nostr transport; username retractions stay owed");
426            return;
427        }
428
429        let owed: Vec<Username> = self
430            .nostr_discovery_retracting
431            .iter()
432            .filter(|username| Some(*username) != desired)
433            .filter(|username| !self.discovery_backoff_active(username, now))
434            .cloned()
435            .collect();
436
437        for username in owed {
438            match serde_json::to_vec(&DiscoveryTombstoneV1::new()) {
439                Ok(payload) => {
440                    self.transport_manager
441                        .retract_nostr_discovery_record(username, payload);
442                }
443                Err(e) => warn!(error = %e, "Failed to build a discovery tombstone"),
444            }
445        }
446    }
447
448    /// Whether `tag` is the tag of the name this install currently claims.
449    fn discovery_tag_is_claimed(&self, tag: &str) -> bool {
450        self.nostr_discovery_claim
451            .as_ref()
452            .and_then(|username| discovery_tag_for_username(username).ok())
453            .is_some_and(|claimed| claimed == tag)
454    }
455
456    /// Drops backoff entries for tags nothing will consult again.
457    ///
458    /// The ladder is keyed by tag, and a tag becomes unreachable as soon as its
459    /// name is neither claimed nor owed a retraction: a rename leaves one
460    /// behind on every failure. Unlike the key-package ladder, whose keys are a
461    /// fixed slot set, this one would otherwise grow for the life of the
462    /// process.
463    fn prune_discovery_backoff(&mut self, desired: Option<&Username>) {
464        if self.nostr_discovery_backoff.is_empty() {
465            return;
466        }
467        let mut live: HashSet<String> = self
468            .nostr_discovery_retracting
469            .iter()
470            .filter_map(|username| discovery_tag_for_username(username).ok())
471            .collect();
472        for username in desired.into_iter().chain(self.nostr_discovery_claim.iter()) {
473            if let Ok(tag) = discovery_tag_for_username(username) {
474                live.insert(tag);
475            }
476        }
477        self.nostr_discovery_backoff
478            .retain(|tag, _| live.contains(tag));
479    }
480
481    /// Whether enough time has passed to re-examine the claim.
482    fn nostr_discovery_refresh_due(&mut self) -> bool {
483        let now = Instant::now();
484        if let Some(last) = self.last_nostr_discovery_refresh {
485            if now.duration_since(last) < DISCOVERY_REFRESH_INTERVAL {
486                return false;
487            }
488        }
489        self.last_nostr_discovery_refresh = Some(now);
490        true
491    }
492
493    /// Records a failed publication and pushes the next attempt out.
494    ///
495    /// Identical ladder to the key-package slots: the first failure retries on
496    /// the next refresh, and only a claim that keeps failing climbs.
497    fn note_discovery_failure(&mut self, tag: &str, now: Instant) {
498        let entry = self
499            .nostr_discovery_backoff
500            .entry(tag.to_string())
501            .or_insert(super::nostr_publication::PublicationBackoff {
502                failures: 0,
503                last_failure: now,
504                retry_at: now,
505            });
506
507        if now.duration_since(entry.last_failure) > DISCOVERY_MAX_BACKOFF {
508            entry.failures = 1;
509        } else {
510            entry.failures = entry.failures.saturating_add(1);
511        }
512        entry.last_failure = now;
513
514        let delay = if entry.failures <= 1 {
515            Duration::ZERO
516        } else {
517            let shift = (entry.failures - 2).min(16);
518            DISCOVERY_REFRESH_INTERVAL
519                .saturating_mul(1u32 << shift)
520                .min(DISCOVERY_MAX_BACKOFF)
521        };
522        entry.retry_at = now + delay;
523    }
524
525    /// Whether the claim is waiting out a publication backoff.
526    fn discovery_backoff_active(&self, username: &Username, now: Instant) -> bool {
527        let Ok(tag) =
528            offline_protocol_transport::nostr_crypto::discovery_tag_for_username(username)
529        else {
530            return false;
531        };
532        self.nostr_discovery_backoff
533            .get(&tag)
534            .is_some_and(|backoff| now < backoff.retry_at)
535    }
536
537    /// Starts a username resolution.
538    ///
539    /// # The return value means exactly one thing: an answer is coming
540    ///
541    /// `Ok(true)` started a lookup and `Ok(false)` joined one already in
542    /// flight, and **both** are followed by exactly one
543    /// [`Event::UsernameResolved`] for the name. Every case where no event will
544    /// ever arrive is an error instead.
545    ///
546    /// That split is the whole point. A caller awaits the event, so folding
547    /// "discovery is off" into `false` alongside "already in flight" leaves an
548    /// app unable to tell waiting from hanging — and the failure mode is a
549    /// spinner that never stops, on the path a user reaches by typing a name
550    /// and pressing search.
551    ///
552    /// # Errors
553    ///
554    /// - [`Error::InvalidArgument`] if the string is not a claimable username.
555    /// - [`Error::InvalidConfiguration`] if username discovery is off. It also
556    ///   requires cold contact, so this covers a claim that could only
557    ///   dead-end one hop later.
558    /// - [`Error::InvalidState`] if too many lookups are already in flight.
559    ///   Transient: retry once earlier ones drain.
560    /// - [`Error::NotStarted`] if the protocol is not running. Nothing pumps
561    ///   relay traffic and nothing sweeps deadlines outside `Running`, so a
562    ///   lookup accepted here would be a promise with no machinery behind it.
563    ///   Transient: retry after `start()`.
564    pub fn resolve_username(&mut self, username: &str) -> Result<bool> {
565        let username: Username = username
566            .parse()
567            .map_err(|e| Error::InvalidArgument(format!("Not a resolvable username: {}", e)))?;
568
569        if !self.transport_manager.nostr_discovery_active() {
570            return Err(Error::InvalidConfiguration(
571                "Username discovery is disabled; enable nostr_username_discovery_enabled \
572                 (which also requires nostr_cold_contact_enabled)"
573                    .to_string(),
574            ));
575        }
576
577        // The event this returns a promise of is emitted from `process()`,
578        // either on a relay's answer or on the deadline sweep, and `process()`
579        // is inert unless the protocol is running. Accepting the lookup while
580        // stopped or paused would return `Ok(true)` for an event that cannot
581        // arrive, and would then answer every later call for the same name
582        // with `Ok(false)` off the registration left behind — the caller told
583        // an answer is coming, twice, with nothing able to deliver one.
584        {
585            let state = lock_shared_state(&self.shared_state)?;
586            if state.state != ProtocolState::Running {
587                return Err(Error::NotStarted);
588            }
589        }
590
591        // Deduplicated against both halves of "in flight", because the transport
592        // can only see the first. A lookup lives in its queue until the platform
593        // mints a query, and from then on it exists solely as a resolution here.
594        // Checking only the transport would let the second request for a name
595        // mint a duplicate REQ and emit a second event for one lookup, breaking
596        // the exactly-one-event contract the whole API shape rests on.
597        if self.nostr_resolution_requests.contains_key(&username)
598            || self
599                .nostr_resolutions
600                .values()
601                .any(|resolution| resolution.username == username)
602        {
603            return Ok(false);
604        }
605
606        match self
607            .transport_manager
608            .resolve_nostr_username(username.clone())
609        {
610            ResolveRequest::Queued => {}
611            // Reachable despite the check above only if the two fell out of
612            // step; the in-flight lookup still answers this caller.
613            ResolveRequest::AlreadyQueued => return Ok(false),
614            ResolveRequest::Disabled => {
615                return Err(Error::InvalidConfiguration(
616                    "Username discovery is disabled".to_string(),
617                ))
618            }
619            ResolveRequest::QueueFull => {
620                return Err(Error::InvalidState(
621                    "Too many username lookups in flight; retry shortly".to_string(),
622                ))
623            }
624        }
625
626        // The deadline starts here, not at query mint, because minting is what
627        // may never happen: the platform pumps queries only while the relay
628        // socket is up, so a lookup requested offline sits in the transport
629        // queue with no resolution behind it and no clock running. Timing from
630        // the request is what makes "a lookup was started" a promise this
631        // engine can keep.
632        self.nostr_resolution_requests
633            .entry(username)
634            .or_insert_with(Instant::now);
635        Ok(true)
636    }
637
638    /// Registers a query the transport just minted, so its answers accumulate.
639    ///
640    /// A mint whose request has already been answered by the timeout sweep is
641    /// dropped rather than accumulated. That is what keeps the one-event
642    /// contract when a relay reconnects after the sweep gave up: the query
643    /// still goes out, its answers land on an unknown resolution and are
644    /// discarded, and no second, contradictory set is emitted for a lookup the
645    /// app has already been told the answer to.
646    pub fn begin_username_resolution(&mut self, query_id: String, username: Username) {
647        if self.nostr_resolution_requests.remove(&username).is_none() {
648            debug!("Discovery query minted for an already-answered request; not accumulating");
649            return;
650        }
651
652        if self.nostr_resolutions.len() >= MAX_CONCURRENT_RESOLUTIONS {
653            // Flush the oldest rather than refusing the newest: its caller is
654            // still owed an answer, and an answer with fewer claims beats a
655            // silence that never resolves.
656            if let Some(stale) = self
657                .nostr_resolutions
658                .iter()
659                .min_by_key(|(_, resolution)| resolution.started)
660                .map(|(id, _)| id.clone())
661            {
662                self.flush_username_resolution(&stale);
663            }
664        }
665
666        // Two live resolutions share an id only when the transport's RNG failed
667        // and every query fell back to one fixed id. Flushing the incumbent
668        // keeps the promise made to *its* caller: overwriting it silently would
669        // leave that lookup waiting forever, because the request record it
670        // would have been swept from is already consumed.
671        if self.nostr_resolutions.contains_key(&query_id) {
672            warn!(
673                query_id = %query_id,
674                "Discovery query id collision; flushing the resolution it displaces"
675            );
676            self.flush_username_resolution(&query_id);
677        }
678
679        self.nostr_resolutions.insert(
680            query_id,
681            PendingResolution {
682                username,
683                claims: HashMap::new(),
684                tombstoned: HashSet::new(),
685                rejected: 0,
686                truncated: 0,
687                started: Instant::now(),
688            },
689        );
690    }
691
692    /// Accumulates one discovery record returned by a resolution query.
693    ///
694    /// Verification happens here and every failure is *ordinary*: the tag is
695    /// public, anyone may publish to it, and a query returns whatever the relay
696    /// holds. A refused record is counted and dropped, never surfaced as an
697    /// error — a resolver that reported junk as a failure would make an
698    /// unremarkable directory look broken.
699    /// The name a record is checked against is the *resolution's* own, not one
700    /// passed in beside the query id. The two agree today because the transport
701    /// hands back the subject it stored, but taking both would let a caller
702    /// seed one query's claim set with records verified for another name, and
703    /// the username check is what catches a genuine record copied onto a
704    /// foreign tag.
705    pub fn handle_resolved_discovery_record(&mut self, query_id: &str, author: &str, data: &[u8]) {
706        let Some(resolution) = self.nostr_resolutions.get_mut(query_id) else {
707            debug!(query_id = %query_id, "Discovery record for an unknown resolution");
708            return;
709        };
710
711        let body = match parse_discovery_body(data) {
712            Ok(body) => body,
713            Err(e) => {
714                debug!(error = %e, "Undecodable discovery record");
715                resolution.rejected = resolution.rejected.saturating_add(1);
716                return;
717            }
718        };
719
720        let record = match body {
721            DiscoveryBody::Record(record) => *record,
722            DiscoveryBody::Tombstone => {
723                // A retraction. Drop any claim this author had made and refuse
724                // any that arrives later: the tombstone replaced their record
725                // at the relay, so seeing both means we read a stale copy from
726                // one relay and the retraction from another. The retraction is
727                // the newer statement whichever order they land in.
728                resolution.claims.remove(author);
729                resolution.tombstoned.insert(author.to_string());
730                return;
731            }
732        };
733
734        // Checked before the signature verify, both because it is cheaper and
735        // because a retracted author's record is refused on the strength of the
736        // retraction rather than on anything about the record.
737        if resolution.tombstoned.contains(author) {
738            debug!("Discovery record from an author that has retracted; ignoring");
739            return;
740        }
741
742        let author_bytes = match hex::decode(author) {
743            Ok(bytes) => bytes,
744            Err(_) => {
745                resolution.rejected = resolution.rejected.saturating_add(1);
746                return;
747            }
748        };
749
750        if let Err(rejection) =
751            verify_discovery_record(&record, &resolution.username, &author_bytes)
752        {
753            debug!(reason = %rejection, "Discovery record refused");
754            resolution.rejected = resolution.rejected.saturating_add(1);
755            return;
756        }
757
758        if resolution.claims.len() >= MAX_CLAIMS_PER_RESOLUTION
759            && !resolution.claims.contains_key(author)
760        {
761            debug!(
762                cap = MAX_CLAIMS_PER_RESOLUTION,
763                "Username resolution at its claim ceiling; ignoring the rest"
764            );
765            resolution.truncated = resolution.truncated.saturating_add(1);
766            return;
767        }
768
769        // One device, one claim: a repeat from the same author is that device
770        // republishing, so the newer statement wins.
771        match resolution.claims.get(author) {
772            Some(existing) if existing.issued_at_ms >= record.issued_at_ms => {}
773            _ => {
774                resolution.claims.insert(author.to_string(), record);
775            }
776        }
777    }
778
779    /// Emits the accumulated set for a finished resolution.
780    ///
781    /// Idempotent: a resolution already flushed (by the timeout sweep, say) is
782    /// simply absent, so a late end-of-stored-events emits nothing rather than
783    /// a second, contradictory set.
784    pub fn flush_username_resolution(&mut self, query_id: &str) {
785        let Some(resolution) = self.nostr_resolutions.remove(query_id) else {
786            return;
787        };
788
789        let claims: Vec<UsernameClaim> = resolution
790            .claims
791            .into_values()
792            .map(|record| UsernameClaim {
793                address: record.address.to_string(),
794                public_key: base64::engine::general_purpose::STANDARD.encode(&record.pubkey),
795                issued_at_ms: record.issued_at_ms,
796            })
797            .collect();
798
799        self.emit_event(Event::UsernameResolved {
800            username: resolution.username.into_string(),
801            claims,
802            rejected: resolution.rejected,
803            truncated: resolution.truncated,
804        });
805    }
806
807    /// Flushes resolutions whose relays never sent end-of-stored-events, and
808    /// answers lookups whose query was never minted at all.
809    ///
810    /// Runs on the process tick. Two distinct hangs, one deadline:
811    ///
812    /// - a relay that answers and then goes quiet leaves a resolution
813    ///   accumulating with no completion signal, since end-of-stored-events is
814    ///   the only one a Nostr query has;
815    /// - a lookup requested while the relay socket is down never reaches
816    ///   [`Self::begin_username_resolution`] at all, because the platform pumps
817    ///   queries only while connected. Nothing is accumulating, so there is
818    ///   nothing for the first sweep to find, and the app waits forever on an
819    ///   event with no trigger.
820    ///
821    /// The second case also **cancels** the queued lookup. Leaving it in the
822    /// transport queue would make every later `resolve_username` for that name
823    /// return `false` ("already queued") without ever emitting, so a name that
824    /// timed out once could never be looked up again for the life of the
825    /// process.
826    pub(crate) fn sweep_username_resolutions(&mut self) {
827        let now = Instant::now();
828
829        if !self.nostr_resolutions.is_empty() {
830            let expired: Vec<String> = self
831                .nostr_resolutions
832                .iter()
833                .filter(|(_, resolution)| {
834                    now.duration_since(resolution.started) > RESOLUTION_TIMEOUT
835                })
836                .map(|(id, _)| id.clone())
837                .collect();
838
839            for query_id in expired {
840                debug!(query_id = %query_id, "Username resolution timed out; emitting what it has");
841                self.flush_username_resolution(&query_id);
842            }
843        }
844
845        if self.nostr_resolution_requests.is_empty() {
846            return;
847        }
848
849        let unminted: Vec<Username> = self
850            .nostr_resolution_requests
851            .iter()
852            .filter(|(_, requested_at)| now.duration_since(**requested_at) > RESOLUTION_TIMEOUT)
853            .map(|(username, _)| username.clone())
854            .collect();
855
856        for username in unminted {
857            debug!("Username lookup never reached a relay; emitting an empty answer");
858            self.nostr_resolution_requests.remove(&username);
859            self.transport_manager
860                .cancel_nostr_username_resolution(&username);
861            // An empty set, on the same terms as a query that reached a relay
862            // and found nothing. That is already what this engine emits when
863            // the platform releases a query with no relay connected, so the
864            // two unreachable paths report identically rather than one of them
865            // being silent.
866            self.emit_event(Event::UsernameResolved {
867                username: username.into_string(),
868                claims: Vec::new(),
869                rejected: 0,
870                truncated: 0,
871            });
872        }
873    }
874
875    /// Answers every lookup still in flight, because after this nothing can.
876    ///
877    /// `stop()` takes down both halves of the promise [`Self::resolve_username`]
878    /// makes: relays stop being pumped, so no answer arrives, and `process()`
879    /// goes inert, so the deadline sweep that exists to turn silence into an
880    /// empty answer never runs either. A caller holding `Ok(true)` would wait
881    /// forever on an event with nothing left to emit it.
882    ///
883    /// Flushed rather than discarded, on the same terms the sweep uses:
884    /// discarding would leave the promise broken and would additionally leave
885    /// the registrations behind, so every later lookup for those names would
886    /// answer `Ok(false)` ("an answer is coming") for an answer that already
887    /// will not. A resolution that heard from some relays emits what it has; a
888    /// lookup that never reached one emits an empty set.
889    pub(crate) fn flush_username_resolutions_for_stop(&mut self) {
890        let in_flight: Vec<String> = self.nostr_resolutions.keys().cloned().collect();
891        for query_id in in_flight {
892            debug!(query_id = %query_id, "Protocol stopping; emitting what this resolution has");
893            self.flush_username_resolution(&query_id);
894        }
895
896        let unminted: Vec<Username> = self.nostr_resolution_requests.keys().cloned().collect();
897        for username in unminted {
898            debug!("Protocol stopping; answering a lookup that never reached a relay");
899            self.nostr_resolution_requests.remove(&username);
900            self.transport_manager
901                .cancel_nostr_username_resolution(&username);
902            self.emit_event(Event::UsernameResolved {
903                username: username.into_string(),
904                claims: Vec::new(),
905                rejected: 0,
906                truncated: 0,
907            });
908        }
909    }
910
911    /// Persists which username this install claims, and which it owes a
912    /// tombstone for.
913    pub(crate) fn persist_nostr_discovery_claim(&mut self) {
914        let Some(storage) = self.protocol_state_storage.clone() else {
915            return;
916        };
917        let state = DiscoveryClaimState {
918            claim: self.nostr_discovery_claim.clone(),
919            retracting: self.nostr_discovery_retracting.clone(),
920        };
921        let bytes = match serde_json::to_vec(&state) {
922            Ok(bytes) => bytes,
923            Err(e) => {
924                warn!(error = %e, "Failed to serialize the Nostr discovery claim");
925                return;
926            }
927        };
928        if let Err(e) = self.write_state_record(
929            storage.as_ref(),
930            storage_keys::NOSTR_DISCOVERY_CLAIM,
931            storage_keys::NOSTR_DISCOVERY_CLAIM_ID,
932            &bytes,
933        ) {
934            warn!(error = %e, "Failed to persist the Nostr discovery claim");
935        }
936    }
937
938    /// Restores the standing claim and any retraction still owed.
939    ///
940    /// Every failure lands on an empty state, which means the next tick
941    /// publishes the current profile's claim and *does not* retract whatever
942    /// was standing. That is the benign direction: guessing at a name to
943    /// retract would publish a tombstone at a tag this install may never have
944    /// claimed.
945    ///
946    /// Reads the pre-retraction shape too, which was a bare `Option<Username>`.
947    /// The two are distinguishable on sight (an object against a string or
948    /// `null`) and an install that upgrades mid-life would otherwise lose its
949    /// standing claim and republish it under a second addressable slot.
950    pub(crate) fn restore_nostr_discovery_claim(&mut self) {
951        let Some(storage) = self.protocol_state_storage.clone() else {
952            return;
953        };
954
955        let data = match self.read_state_record(
956            storage.as_ref(),
957            storage_keys::NOSTR_DISCOVERY_CLAIM,
958            storage_keys::NOSTR_DISCOVERY_CLAIM_ID,
959        ) {
960            Ok(Some(data)) => data,
961            Ok(None) => return,
962            Err(e) => {
963                warn!(error = %e, "Failed to read the Nostr discovery claim; starting fresh");
964                return;
965            }
966        };
967
968        let restored = serde_json::from_slice::<DiscoveryClaimState>(&data).or_else(|_| {
969            serde_json::from_slice::<Option<Username>>(&data).map(|claim| DiscoveryClaimState {
970                claim,
971                retracting: Vec::new(),
972            })
973        });
974
975        match restored {
976            Ok(state) => {
977                self.nostr_discovery_claim = state.claim;
978                self.nostr_discovery_retracting = state.retracting;
979                // A restored claim has not been published *this* process, and
980                // an addressable record lives on the relays rather than here.
981                self.nostr_discovery_published = false;
982            }
983            Err(e) => warn!(error = %e, "Corrupted Nostr discovery claim; starting fresh"),
984        }
985    }
986}