1
0
forked from GRIN/grim

nostr: keep contact @usernames fresh; clear released/reassigned names

Cached names were verified once and never re-checked, so a contact who
released or changed their username kept showing the stale name forever.
Re-validate names on a 78s sweep (capped per tick to bound mixnet lookups):

- nip05::check — tri-state Verified/Mismatch/Unreachable, so we only clear on
  a definitive server answer (released, or reassigned to a different key),
  never on a network blip.
- resolve_contact_identity now re-checks names older than the freshness window
  and clears nip05 + nip05_verified_at on Mismatch (a user petname is kept);
  display falls back to the npub automatically.
- A periodic sweep in run_service re-verifies the stalest due contacts.

Tests for the tri-state parsing and the clear-keeps-petname logic.
This commit is contained in:
2ro
2026-06-16 01:39:54 -04:00
parent 7eb0683646
commit dfbd85c7b3
2 changed files with 228 additions and 29 deletions
+150 -29
View File
@@ -57,6 +57,13 @@ const RATE_CONTACT_PER_HOUR: usize = 30;
const RATE_UNKNOWN_PER_HOUR: usize = 10;
/// Auto-resend window for pending outgoing messages (days).
const RESEND_WINDOW_SECS: i64 = 7 * 86_400;
/// How often a cached @username is re-validated against the identity server, so
/// a released or reassigned name stops being shown. Doubles as the freshness
/// gate in `resolve_contact_identity`.
const NAME_REVERIFY_INTERVAL_SECS: i64 = 78;
/// Cap on contacts re-verified per sweep, so a large contact list rolls through
/// instead of bursting dozens of simultaneous mixnet lookups at once.
const NAME_REVERIFY_MAX_PER_TICK: usize = 8;
/// Per-wallet nostr service.
pub struct NostrService {
@@ -621,59 +628,87 @@ impl NostrService {
}
}
/// Best-effort: resolve a contact's published `@username`. Incoming messages
/// only carry the sender's key, so a fresh contact shows as a bare npub. This
/// fetches their kind-0 profile and, if it advertises a NIP-05 handle that
/// verifies back to their key, records it so the UI can show `@username`.
/// Spawns a worker; fail-open (any miss just leaves the npub). Skips contacts
/// that already carry a verified handle.
/// Best-effort: resolve and KEEP FRESH a contact's published `@username`.
/// Incoming messages only carry the sender's key, so a fresh contact shows as
/// a bare npub; this fetches their kind-0, and if it advertises a NIP-05 that
/// maps back to their key, records it so the UI shows `@username`. It also
/// re-validates an already-known name (older than the freshness window): if
/// the server says the name was released or reassigned, it CLEARS it so the
/// stale name stops showing; a transient network miss leaves it untouched.
/// Spawns a worker; fail-open. A user-set petname is never touched.
pub fn resolve_contact_identity(self: &Arc<Self>, sender_hex: &str) {
if let Some(c) = self.store.contact(sender_hex) {
if c.nip05.is_some() && c.nip05_verified_at.is_some() {
return;
let existing = self.store.contact(sender_hex);
// Freshness gate: skip only if a name was verified recently. Older (or
// never-verified) contacts are (re-)checked so releases get caught.
if let Some(c) = &existing {
if let (Some(_), Some(at)) = (&c.nip05, c.nip05_verified_at) {
if unix_time() - at < NAME_REVERIFY_INTERVAL_SECS {
return;
}
}
}
// Any DM relays we've already learned for them are the best hint for where
// their profile lives (their messages came from there).
let hints = self
.store
.contact(sender_hex)
.map(|c| c.relays)
let hints = existing
.as_ref()
.map(|c| c.relays.clone())
.unwrap_or_default();
let cached_nip05 = existing.and_then(|c| c.nip05);
let svc = self.clone();
let hex = sender_hex.to_string();
thread::spawn(move || {
let Some(profile) = svc.fetch_profile_blocking(&hex, &hints) else {
let Ok(pk) = PublicKey::from_hex(&hex) else {
return;
};
let Some(nip05) = profile.nip05 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 advertised = svc
.fetch_profile_blocking(&hex, &hints)
.and_then(|p| p.nip05);
let Some(nip05) = advertised.or(cached_nip05) else {
return; // anonymous and nothing cached — nothing to check
};
let Some((name, domain)) = nip05.split_once('@') else {
return;
};
let Ok(pk) = PublicKey::from_hex(&hex) else {
return;
};
// Trust the handle only if it maps back to this key.
let verified = tokio::runtime::Builder::new_current_thread()
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()
.map(|rt| rt.block_on(crate::nostr::nip05::verify(&pk, name, domain)))
.unwrap_or(false);
if !verified {
else {
return;
}
};
let check = rt.block_on(crate::nostr::nip05::check(&pk, name, domain));
if let Some(mut c) = svc.store.contact(&hex) {
c.nip05 = Some(nip05.clone());
c.nip05_verified_at = Some(unix_time());
svc.store.save_contact(&c);
if apply_nip05_check(&mut c, &nip05, check) {
svc.store.save_contact(&c);
}
}
});
}
}
/// Apply a name re-check outcome to a contact in place; returns true if it
/// changed and should be saved. `Verified` records/refreshes the handle;
/// `Mismatch` (released or reassigned) clears it so the npub takes over;
/// `Unreachable` leaves it alone. A user-set petname is never touched.
fn apply_nip05_check(c: &mut Contact, nip05: &str, check: crate::nostr::nip05::Nip05Check) -> bool {
use crate::nostr::nip05::Nip05Check;
match check {
Nip05Check::Verified => {
c.nip05 = Some(nip05.to_string());
c.nip05_verified_at = Some(unix_time());
true
}
Nip05Check::Mismatch => {
let had = c.nip05.is_some() || c.nip05_verified_at.is_some();
c.nip05 = None;
c.nip05_verified_at = None;
had
}
Nip05Check::Unreachable => false,
}
}
/// Main service loop: connect, publish identity, catch up, listen.
async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// Publish the service runtime handle so worker-thread one-shots (profile
@@ -804,6 +839,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_heartbeat = unix_time();
let mut last_prune = unix_time();
let mut last_name_sweep = unix_time();
loop {
if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() {
break;
@@ -833,6 +869,28 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
last_prune = now;
}
}
// Re-validate cached @usernames so a released/reassigned name
// stops showing. Only the stalest few per sweep (capped) to bound
// mixnet lookups; each worker re-checks against the identity server.
if now - last_name_sweep >= NAME_REVERIFY_INTERVAL_SECS {
last_name_sweep = now;
let mut due: Vec<_> = svc
.store
.all_contacts()
.into_iter()
.filter(|c| {
c.nip05.is_some()
&& c.nip05_verified_at
.map(|at| now - at >= NAME_REVERIFY_INTERVAL_SECS)
.unwrap_or(true)
})
.collect();
// Stalest first (oldest verification), so a big list rolls through.
due.sort_by_key(|c| c.nip05_verified_at.unwrap_or(0));
for c in due.into_iter().take(NAME_REVERIFY_MAX_PER_TICK) {
svc.resolve_contact_identity(&c.npub);
}
}
}
}
}
@@ -1351,6 +1409,69 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
mod tests {
use super::*;
fn sample_contact() -> Contact {
Contact {
ver: 1,
npub: "abc".to_string(),
petname: Some("Mom".to_string()),
nip05: Some("ada@goblin.st".to_string()),
nip05_verified_at: Some(1000),
relays: vec![],
hue: 0,
unknown: false,
added_at: 1,
last_paid_at: None,
blocked: false,
}
}
#[test]
fn name_recheck_clears_on_mismatch_keeps_petname() {
use crate::nostr::nip05::Nip05Check;
// Released/reassigned → clear the handle, but never the user's petname.
let mut c = sample_contact();
assert!(apply_nip05_check(
&mut c,
"ada@goblin.st",
Nip05Check::Mismatch
));
assert_eq!(c.nip05, None);
assert_eq!(c.nip05_verified_at, None);
assert_eq!(c.petname.as_deref(), Some("Mom"));
// Unreachable → no change at all (don't drop a good name on a blip).
let mut c = sample_contact();
assert!(!apply_nip05_check(
&mut c,
"ada@goblin.st",
Nip05Check::Unreachable
));
assert_eq!(c.nip05.as_deref(), Some("ada@goblin.st"));
assert_eq!(c.nip05_verified_at, Some(1000));
// Verified → record the handle and refresh the timestamp.
let mut c = sample_contact();
c.nip05 = None;
c.nip05_verified_at = None;
assert!(apply_nip05_check(
&mut c,
"bob@goblin.st",
Nip05Check::Verified
));
assert_eq!(c.nip05.as_deref(), Some("bob@goblin.st"));
assert!(c.nip05_verified_at.is_some());
// Mismatch on an already-nameless contact → nothing to do.
let mut c = sample_contact();
c.nip05 = None;
c.nip05_verified_at = None;
assert!(!apply_nip05_check(
&mut c,
"ada@goblin.st",
Nip05Check::Mismatch
));
}
#[test]
fn terminal_states_do_not_expire() {
assert!(expiry_terminal(NostrSendStatus::Finalized));
+78
View File
@@ -88,6 +88,52 @@ pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool {
}
}
/// Outcome of re-checking whether a name still belongs to a key — distinguishes
/// a definitive "no longer ours" from a transient network failure, so a cached
/// name is only cleared when the server actually says it's gone/reassigned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nip05Check {
/// Server reachable; the name maps to this key.
Verified,
/// Server reachable and answered, but the name is absent or maps to a
/// DIFFERENT key (released, or reassigned to someone else).
Mismatch,
/// Couldn't reach/parse the server — unknown; keep what we have.
Unreachable,
}
/// Freshness-aware NIP-05 check (see [`Nip05Check`]). Only returns `Mismatch`
/// when the server gives a well-formed answer that doesn't include this key —
/// any network error or non-well-known response is `Unreachable`.
pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check {
let url = format!(
"https://{}/.well-known/nostr.json?name={}",
domain,
urlencode(name)
);
let Some(body) = nym::http_request("GET", url, None, vec![]).await else {
return Nip05Check::Unreachable;
};
check_body(&body, pubkey, name)
}
/// Decide a [`Nip05Check`] from a fetched well-known body (split out for tests).
fn check_body(body: &str, pubkey: &PublicKey, name: &str) -> Nip05Check {
// A reachable server that returns non-JSON, or a doc without a `names` map,
// is treated as Unreachable — never clear a good name on a server hiccup.
let Ok(doc) = serde_json::from_str::<Value>(body) else {
return Nip05Check::Unreachable;
};
let Some(names) = doc.get("names").and_then(|n| n.as_object()) else {
return Nip05Check::Unreachable;
};
match names.get(name).and_then(|v| v.as_str()) {
Some(hex) if PublicKey::from_hex(hex).ok().as_ref() == Some(pubkey) => Nip05Check::Verified,
// Name absent, or present but a different key → definitively not ours.
_ => Nip05Check::Mismatch,
}
}
/// Parse a .well-known/nostr.json document for a specific name.
pub fn parse_well_known(body: &str, name: &str) -> Option<Nip05Resolution> {
let doc: Value = serde_json::from_str(body).ok()?;
@@ -403,4 +449,36 @@ mod tests {
assert!(parse_well_known(body, "bob").is_none());
assert!(parse_well_known("not json", "ada").is_none());
}
#[test]
fn check_body_classifies() {
let ada_hex = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05";
let ada = PublicKey::from_hex(ada_hex).unwrap();
let other =
PublicKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.unwrap();
let body = format!(r#"{{"names":{{"ada":"{ada_hex}"}},"relays":{{}}}}"#);
// Name maps to this key → Verified.
assert_eq!(check_body(&body, &ada, "ada"), Nip05Check::Verified);
// Name present but a DIFFERENT key (reassigned) → Mismatch.
assert_eq!(check_body(&body, &other, "ada"), Nip05Check::Mismatch);
// Name absent from a valid doc (released) → Mismatch.
assert_eq!(check_body(&body, &ada, "bob"), Nip05Check::Mismatch);
// Empty names map (the exact "released" shape) → Mismatch.
assert_eq!(
check_body(r#"{"names":{},"relays":{}}"#, &ada, "testuser"),
Nip05Check::Mismatch
);
// Non-JSON / server error → Unreachable (never clears a good name).
assert_eq!(
check_body("503 Service Unavailable", &ada, "ada"),
Nip05Check::Unreachable
);
// Valid JSON but no `names` map (unexpected response) → Unreachable.
assert_eq!(
check_body(r#"{"error":"oops"}"#, &ada, "ada"),
Nip05Check::Unreachable
);
}
}