diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 7abe4b30..8dcbe63f 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -660,8 +660,31 @@ impl NostrService { let Ok(pk) = PublicKey::from_hex(&hex) else { return; }; - // Check the handle they currently advertise; if the kind-0 can't be - // fetched, fall back to the cached handle so a release is still caught. + let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + // Primary: ask the home authority directly what @name this key holds. + // One HTTP round-trip, authoritative, and independent of whether we can + // fetch their kind-0 off a relay (the fragile leg) — this is what + // makes a contact's name show on the FIRST interaction. + let home = crate::nostr::nip05::home_domain(); + if let Some(name) = rt.block_on(crate::nostr::nip05::name_by_pubkey(&home, &hex)) { + let nip05 = format!("{}@{}", name, home); + if let Some(mut c) = svc.store.contact(&hex) { + if apply_nip05_check(&mut c, &nip05, crate::nostr::nip05::Nip05Check::Verified) + { + svc.store.save_contact(&c); + } + } + return; + } + // Fallback: the handle they advertise in their kind-0 (covers FOREIGN + // authorities the home reverse-lookup can't speak for); if the kind-0 can't + // be fetched, fall back to the cached handle so a release is still caught. + // This path can also CLEAR a released/reassigned name. let advertised = svc .fetch_profile_blocking(&hex, &hints) .and_then(|p| p.nip05); @@ -671,12 +694,6 @@ impl NostrService { let Some((name, domain)) = nip05.split_once('@') else { return; }; - let Ok(rt) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; let check = rt.block_on(crate::nostr::nip05::check(&pk, name, domain)); if let Some(mut c) = svc.store.contact(&hex) { if apply_nip05_check(&mut c, &nip05, check) { diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index acbd4b51..d8e17318 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -106,6 +106,28 @@ pub async fn resolve(name: &str, domain: &str) -> Option { parse_well_known(&body, name) } +/// Reverse lookup against an authority: the active `@name` a pubkey holds, if +/// any. Authoritative and single-request — unlike fetching the peer's kind-0 off +/// a relay and verifying the NIP-05 it advertises, this needs no profile fetch, +/// so a contact's name resolves even when their profile can't be retrieved. +/// `Some(name)` = server-confirmed; `None` = the key has no name on this +/// authority OR the server was unreachable (the two are indistinguishable here, +/// so callers must NOT treat `None` as "released" — fall back to the kind-0 + +/// verify path, which can tell a definitive miss from a network blip). +pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option { + let url = format!( + "https://{}/api/v1/by-pubkey/{}", + domain, + urlencode(pubkey_hex) + ); + let body = nym::http_request("GET", url, None, vec![]).await?; + let doc: Value = serde_json::from_str(&body).ok()?; + doc.get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + /// Verify that a pubkey matches its claimed NIP-05 identifier. pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool { match resolve(name, domain).await {