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}