notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 70e2be09f8862e3bce93423d350460d1a5c5604b
parent 749a8e20ea39d51d764e4dff27e994869b17551e
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 10 Feb 2026 13:03:31 -0800

nip05: add async validation for verified checkmark

The verified checkmark badge was displaying for any profile with a
nip05 field set, without actually validating it. Add Nip05Cache that
performs async HTTP validation against .well-known/nostr.json and only
shows the checkmark when the pubkey matches.

Closes: #1274

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck/src/app.rs | 6++++++
Mcrates/notedeck/src/context.rs | 5+++--
Mcrates/notedeck/src/lib.rs | 2++
Mcrates/notedeck/src/name.rs | 3+++
Acrates/notedeck/src/nip05.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/note/mod.rs | 2++
Mcrates/notedeck_clndash/src/ui.rs | 1+
Mcrates/notedeck_columns/src/nav.rs | 1+
Mcrates/notedeck_columns/src/ui/note/post.rs | 1+
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 14+++++++++++++-
Mcrates/notedeck_dave/src/backend/claude.rs | 2+-
Mcrates/notedeck_dave/src/lib.rs | 8++------
Mcrates/notedeck_dave/src/ui/dave.rs | 1+
Mcrates/notedeck_ui/src/profile/mod.rs | 8++++----
14 files changed, 178 insertions(+), 14 deletions(-)

diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -1,5 +1,6 @@ use crate::account::FALLBACK_PUBKEY; use crate::i18n::Localization; +use crate::nip05::Nip05Cache; use crate::persist::{AppSizeHandler, SettingsHandler}; use crate::unknowns::unknown_id_send; use crate::wallet::GlobalWallet; @@ -79,6 +80,7 @@ pub struct Notedeck { frame_history: FrameHistory, job_pool: JobPool, media_jobs: MediaJobs, + nip05_cache: Nip05Cache, i18n: Localization, #[cfg(target_os = "android")] @@ -131,6 +133,8 @@ impl eframe::App for Notedeck { crate::deliver_completed_media_job(completed, &mut self.img_cache.textures) }); + self.nip05_cache.poll(); + // handle account updates self.accounts.update(&mut self.ndb, &mut self.pool, ctx); @@ -326,6 +330,7 @@ impl Notedeck { zaps, job_pool, media_jobs: media_job_cache, + nip05_cache: Nip05Cache::new(), i18n, #[cfg(target_os = "android")] android_app: None, @@ -392,6 +397,7 @@ impl Notedeck { frame_history: &mut self.frame_history, job_pool: &mut self.job_pool, media_jobs: &mut self.media_jobs, + nip05_cache: &mut self.nip05_cache, i18n: &mut self.i18n, #[cfg(target_os = "android")] android: self.android_app.as_ref().unwrap().clone(), diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -1,7 +1,7 @@ use crate::{ account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization, - wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, MediaJobs, NoteCache, - SettingsHandler, UnknownIds, + nip05::Nip05Cache, wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, + MediaJobs, NoteCache, SettingsHandler, UnknownIds, }; use egui_winit::clipboard::Clipboard; @@ -29,6 +29,7 @@ pub struct AppContext<'a> { pub frame_history: &'a mut FrameHistory, pub job_pool: &'a mut JobPool, pub media_jobs: &'a mut MediaJobs, + pub nip05_cache: &'a mut Nip05Cache, pub i18n: &'a mut Localization, #[cfg(target_os = "android")] diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -18,6 +18,7 @@ pub mod media; mod muted; pub mod name; pub mod nav; +pub mod nip05; mod nip51_set; pub mod note; mod notecache; @@ -69,6 +70,7 @@ pub use media::{ pub use muted::{MuteFun, Muted}; pub use name::NostrName; pub use nav::DragResponse; +pub use nip05::{Nip05Cache, Nip05Status}; pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache}; pub use note::{ get_p_tags, BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, diff --git a/crates/notedeck/src/name.rs b/crates/notedeck/src/name.rs @@ -4,6 +4,7 @@ pub struct NostrName<'a> { pub username: Option<&'a str>, pub display_name: Option<&'a str>, pub nip05: Option<&'a str>, + pub nip05_valid: bool, } impl<'a> NostrName<'a> { @@ -34,6 +35,7 @@ impl<'a> NostrName<'a> { username: None, display_name: None, nip05: None, + nip05_valid: false, } } } @@ -72,5 +74,6 @@ pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> username, display_name, nip05, + nip05_valid: false, } } diff --git a/crates/notedeck/src/nip05.rs b/crates/notedeck/src/nip05.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::{Duration, Instant}; + +use enostr::Pubkey; + +const NIP05_TTL: Duration = Duration::from_secs(8 * 3600); // 8 hours + +#[derive(Debug, Clone, PartialEq)] +pub enum Nip05Status { + Pending, + Valid, + Invalid, +} + +struct CacheEntry { + status: Nip05Status, + checked_at: Instant, +} + +struct Completion { + pubkey: Pubkey, + status: Nip05Status, +} + +pub struct Nip05Cache { + cache: HashMap<Pubkey, CacheEntry>, + tx: Sender<Completion>, + rx: Receiver<Completion>, +} + +impl Default for Nip05Cache { + fn default() -> Self { + Self::new() + } +} + +impl Nip05Cache { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + cache: HashMap::new(), + tx, + rx, + } + } + + pub fn status(&self, pubkey: &Pubkey) -> Option<&Nip05Status> { + self.cache.get(pubkey).map(|entry| &entry.status) + } + + pub fn request_validation(&mut self, pubkey: Pubkey, nip05: &str) { + if let Some(entry) = self.cache.get(&pubkey) { + if entry.checked_at.elapsed() < NIP05_TTL { + return; + } + } + + self.cache.insert( + pubkey, + CacheEntry { + status: Nip05Status::Pending, + checked_at: Instant::now(), + }, + ); + + let tx = self.tx.clone(); + let nip05 = nip05.to_string(); + + tokio::spawn(async move { + let status = validate_nip05(&pubkey, &nip05).await; + let _ = tx.send(Completion { pubkey, status }); + }); + } + + pub fn poll(&mut self) { + while let Ok(completion) = self.rx.try_recv() { + self.cache.insert( + completion.pubkey, + CacheEntry { + status: completion.status, + checked_at: Instant::now(), + }, + ); + } + } +} + +async fn validate_nip05(pubkey: &Pubkey, nip05: &str) -> Nip05Status { + let Some((user, domain)) = parse_nip05(nip05) else { + return Nip05Status::Invalid; + }; + + let url = format!("https://{}/.well-known/nostr.json?name={}", domain, user); + + let resp = match crate::media::network::http_req(&url).await { + Ok(resp) => resp, + Err(e) => { + tracing::warn!("NIP-05 validation failed for {}: {}", nip05, e); + return Nip05Status::Invalid; + } + }; + + let json: serde_json::Value = match serde_json::from_slice(&resp.bytes) { + Ok(v) => v, + Err(e) => { + tracing::warn!("NIP-05 JSON parse failed for {}: {}", nip05, e); + return Nip05Status::Invalid; + } + }; + + let expected_hex = pubkey.hex(); + + let valid = json + .get("names") + .and_then(|names| names.get(user)) + .and_then(|v| v.as_str()) + .map(|hex| hex.eq_ignore_ascii_case(&expected_hex)) + .unwrap_or(false); + + if valid { + Nip05Status::Valid + } else { + Nip05Status::Invalid + } +} + +fn parse_nip05(nip05: &str) -> Option<(&str, &str)> { + let at_pos = nip05.find('@')?; + let user = &nip05[..at_pos]; + let domain = &nip05[at_pos + 1..]; + + if user.is_empty() || domain.is_empty() { + return None; + } + + Some((user, domain)) +} diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -5,6 +5,7 @@ pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; use crate::jobs::MediaJobSender; +use crate::nip05::Nip05Cache; use crate::Accounts; use crate::GlobalWallet; use crate::Localization; @@ -29,6 +30,7 @@ pub struct NoteContext<'d> { pub pool: &'d mut RelayPool, pub jobs: &'d MediaJobSender, pub unknown_ids: &'d mut UnknownIds, + pub nip05_cache: &'d mut Nip05Cache, pub clipboard: &'d mut egui_winit::clipboard::Clipboard, } diff --git a/crates/notedeck_clndash/src/ui.rs b/crates/notedeck_clndash/src/ui.rs @@ -51,6 +51,7 @@ pub fn note_hover_ui( pool: ctx.pool, jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, + nip05_cache: ctx.nip05_cache, clipboard: ctx.clipboard, i18n: ctx.i18n, global_wallet: ctx.global_wallet, diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -643,6 +643,7 @@ fn render_nav_body( pool: ctx.pool, jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, + nip05_cache: ctx.nip05_cache, clipboard: ctx.clipboard, i18n: ctx.i18n, global_wallet: ctx.global_wallet, diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -878,6 +878,7 @@ mod preview { pool: app.pool, jobs: app.media_jobs.sender(), unknown_ids: app.unknown_ids, + nip05_cache: app.nip05_cache, clipboard: app.clipboard, i18n: app.i18n, }; diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -255,7 +255,19 @@ fn profile_body( ui.add_space(18.0); - ui.add(display_name_widget(&get_display_name(profile), false)); + let mut name = get_display_name(profile); + if let Some(raw_nip05) = profile + .and_then(|p| p.record().profile()) + .and_then(|p| p.nip05()) + { + note_context + .nip05_cache + .request_validation(*pubkey, raw_nip05); + if note_context.nip05_cache.status(pubkey) == Some(&notedeck::Nip05Status::Valid) { + name.nip05_valid = true; + } + } + ui.add(display_name_widget(&name, false)); ui.add_space(8.0); diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -31,7 +31,7 @@ fn tool_result_content_to_value(content: &Option<ToolResultContent>) -> serde_js match content { Some(ToolResultContent::Text(s)) => serde_json::Value::String(s.clone()), Some(ToolResultContent::Blocks(blocks)) => { - serde_json::Value::Array(blocks.iter().cloned().collect()) + serde_json::Value::Array(blocks.to_vec()) } None => serde_json::Value::Null, } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -940,9 +940,7 @@ fn activate_app(ctx: &egui::Context) { #[cfg(target_os = "macos")] { use objc2::MainThreadMarker; - use objc2_app_kit::{ - NSApplication, NSApplicationActivationOptions, NSRunningApplication, - }; + use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication}; // Safety: UI update runs on the main thread if let Some(mtm) = MainThreadMarker::new() { @@ -951,9 +949,7 @@ fn activate_app(ctx: &egui::Context) { // Activate via NSRunningApplication for per-process activation let current = unsafe { NSRunningApplication::currentApplication() }; unsafe { - current.activateWithOptions( - NSApplicationActivationOptions::ActivateAllWindows, - ); + current.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows); }; // Also force the key window to front regardless of Stage Manager diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -868,6 +868,7 @@ impl<'a> DaveUi<'a> { pool: ctx.pool, jobs: ctx.media_jobs.sender(), unknown_ids: ctx.unknown_ids, + nip05_cache: ctx.nip05_cache, clipboard: ctx.clipboard, i18n: ctx.i18n, global_wallet: ctx.global_wallet, diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -44,16 +44,16 @@ pub fn display_name_widget<'a>( ) }); - if name.username.is_some() && name.nip05.is_some() { + let nip05_verified = name.nip05.filter(|_| name.nip05_valid); + + if name.username.is_some() && nip05_verified.is_some() { ui.end_row(); } - let nip05_resp = name.nip05.map(|nip05| { + let nip05_resp = nip05_verified.map(|nip05| { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - ui.add(app_images::verified_image()); - ui.label(RichText::new(nip05).size(16.0).color(crate::colors::TEAL)) .on_hover_text(nip05) })