nostr: resolve a contact's @name via authority reverse lookup first

The requester still saw a bare npub for the payer because resolving a name
depended on fetching the peer's kind-0 off a relay (a nostr REQ with tight
timeouts over the private transport) before it could verify the NIP-05. When
that fetch doesn't land, resolution never starts — even though the name server
knows the name perfectly well.

resolve_contact_identity now asks the home authority directly:
GET /api/v1/by-pubkey/{hex} → the active @name for that key, in one HTTP
round-trip, with no profile fetch. That answer is authoritative, so the name is
set verified immediately. The kind-0 + verify path stays as a fallback for
FOREIGN authorities (which the home server can't speak for) and is still what
CLEARS a released/reassigned name. New nip05::name_by_pubkey helper.

Pairs with the goblin-nip05d by-pubkey endpoint. Verified by me only as far as
compile + unit/i18n tests; the live two-party resolution is the owner's call.
This commit is contained in:
2ro
2026-06-16 20:01:20 -04:00
parent d60e71d1e0
commit 919cfcb71e
2 changed files with 47 additions and 8 deletions
+25 -8
View File
@@ -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) {
+22
View File
@@ -106,6 +106,28 @@ pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
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<String> {
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 {