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:
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(¬edeck::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)
})