Build 17: elastic recipient search w/ nostr verification, numpad, copy

Recipient picker (send.rs) is now type-ahead instead of button-driven:
- as you type, instant local-contact matches + a debounced network
  lookup surface as tappable cards (no more "Find recipient" button)
- a name or @handle is treated as a goblin username (nip05 resolve) and
  shown as a tappable card — tap the right identity to select it
- a pasted npub/hex is verified over nostr: fetch its kind-0 profile and
  badge the card "✓ on nostr"; a valid key with no profile shows "no
  profile" and, when tapped, asks "Pay an unverified key?" before
  continuing (owner choice — never hard-blocked)

NostrService::fetch_profile_blocking (client.rs) — one-shot kind-0 fetch
over the connected relay pool (nrelay + damus + nos.lol), so arbitrary
npubs resolve across the public network. data::search_contacts for the
local half.

Numpad (widgets.rs): centered, evenly-columned grid — the trailing
per-key space was drifting it off-center under the amount; hovered keys
now highlight in accent.

Onboarding identity step: added a plain-spoken line that you can swap in
a fresh key any time to unlink from your old identity.

34 lib tests green. Verified live on :2: numpad centers (92/198/300),
fiatjaf npub → "✓ on nostr", an unprofiled key → "no profile" → confirm
gate → Pay anyway → Review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-11 14:59:01 -04:00
parent 6dbd0f8e9d
commit 86f042facb
6 changed files with 428 additions and 145 deletions
+31
View File
@@ -167,3 +167,34 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String
.map(|c| (display_name(&c), c.hue as usize, c.npub))
.collect()
}
/// Local contacts whose petname / nip05 / npub contains `query` (case-
/// insensitive) — the instant, no-network half of the recipient search.
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> {
let store = match wallet.nostr_service() {
Some(s) => s.store.clone(),
None => return vec![],
};
let q = query.trim().trim_start_matches('@').to_lowercase();
if q.is_empty() {
return vec![];
}
let mut hits: Vec<(String, usize, String)> = store
.all_contacts()
.into_iter()
.filter(|c| {
c.petname
.as_deref()
.map(|p| p.to_lowercase().contains(&q))
.unwrap_or(false)
|| c.nip05
.as_deref()
.map(|n| n.to_lowercase().contains(&q))
.unwrap_or(false)
|| c.npub.to_lowercase().contains(&q)
})
.map(|c| (display_name(&c), c.hue as usize, c.npub))
.collect();
hits.truncate(limit);
hits
}
+10
View File
@@ -725,6 +725,16 @@ impl OnboardingContent {
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Want a clean slate later? Swap in a brand-new key any \
time from Settings — your grin stays put, and the new \
you isn't linked to the old one. Same wallet, fresh face.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(14.0);
+341 -139
View File
@@ -28,7 +28,7 @@ use crate::wallet::Wallet;
use crate::wallet::types::WalletTask;
use super::avatars::AvatarTextures;
use super::data::{self, display_name, recent_peers, short_npub};
use super::data::{self, display_name, recent_peers, search_contacts, short_npub};
use super::widgets::{self as w, HoldToSend};
/// Avatar texture for a display handle ("@name"), if one is cached.
@@ -66,8 +66,28 @@ struct Recipient {
hue: usize,
}
/// Outcome of an async NIP-05 resolution: (pubkey hex, optional nip05 id).
type ResolveOutcome = Result<(String, Option<String>), String>;
/// A recipient search hit shown as a tappable card.
#[derive(Clone)]
struct Candidate {
name: String,
npub: String,
hue: usize,
/// Known contact, resolved goblin handle, or has a published nostr
/// profile. Unverified = a syntactically valid key with no profile.
verified: bool,
/// Short provenance tag shown on the card ("on nostr", "@goblin.st", …).
tag: &'static str,
}
/// Async network lookup result for the typed query.
enum LookupResult {
/// A resolved/verified identity (nip05 hit or kind-0 profile found).
Found(Candidate),
/// A syntactically valid key with no published profile.
Unverified(Candidate),
/// Nothing found for a handle/name.
NotFound(String),
}
/// The send flow state.
pub struct SendFlow {
@@ -78,10 +98,18 @@ pub struct SendFlow {
note: String,
hold: HoldToSend,
error: Option<String>,
/// Async NIP-05 resolution result, written by a worker thread.
resolve_slot: std::sync::Arc<std::sync::Mutex<Option<ResolveOutcome>>>,
/// Resolution in flight (bech32/nip05).
resolving: bool,
/// Async lookup result for the current query, written by a worker.
lookup_slot: std::sync::Arc<std::sync::Mutex<Option<LookupResult>>>,
/// Network lookup in flight.
looking_up: bool,
/// The query the current network result/`looking_up` belongs to.
lookup_query: String,
/// egui time of the last search edit, for debounce.
input_changed_at: f64,
/// Network candidate from the last completed lookup (deduped into view).
net_candidate: Option<Candidate>,
/// Pending "pay an unverified key?" confirm gate.
confirm_unverified: Option<Candidate>,
/// Camera QR scanner when scanning for a recipient.
scan: Option<CameraContent>,
/// Start scanning on the next recipient frame (entry from header icon).
@@ -98,8 +126,12 @@ impl Default for SendFlow {
note: String::new(),
hold: HoldToSend::default(),
error: None,
resolve_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
resolving: false,
lookup_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
looking_up: false,
lookup_query: String::new(),
input_changed_at: 0.0,
net_candidate: None,
confirm_unverified: None,
scan: None,
start_scan: false,
}
@@ -269,84 +301,209 @@ impl SendFlow {
if search != self.search {
self.search = search;
self.error = None;
self.input_changed_at = ui.input(|i| i.time);
// New query — drop the stale network result until it re-resolves.
self.net_candidate = None;
}
ui.add_space(14.0);
// Poll the async resolution result (set by a worker thread).
self.poll_resolution(wallet);
// Resolve button when input looks like a handle/npub.
if !self.search.trim().is_empty() {
if self.resolving {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new("Looking up handle…")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.ctx().request_repaint();
} else if w::big_action(ui, "Find recipient", false).clicked() {
self.resolve_search(wallet);
}
ui.add_space(16.0);
// The pay-an-unverified-key gate pre-empts the picker.
if let Some(cand) = self.confirm_unverified.clone() {
self.unverified_gate_ui(ui, &cand);
return false;
}
// Shown outside the non-empty-search gate: scan failures land here
// with the search box still empty.
if let Some(err) = &self.error {
// Drive the debounced network lookup + poll its result.
self.drive_lookup(wallet, ui.input(|i| i.time));
let query = self.search.trim().to_string();
if query.is_empty() {
// Empty query → suggested recent peers, as before.
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
RichText::new(format!("{} Suggested", USERS))
.font(fonts::kicker())
.color(t.text_mute),
);
ui.add_space(16.0);
ui.add_space(8.0);
let peers = recent_peers(wallet, 20);
let texs: Vec<Option<egui::TextureHandle>> = peers
.iter()
.map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name))
.collect();
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
if peers.is_empty() {
ui.add_space(20.0);
ui.label(
RichText::new("No contacts yet. Find someone by their @handle.")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
}
for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) {
if w::activity_row(
ui,
&name,
&short_npub(&npub),
hue,
"",
false,
false,
tex.as_ref(),
)
.clicked()
{
self.pick(Candidate {
name,
npub,
hue,
verified: true,
tag: "",
});
}
}
});
return false;
}
// Suggested contacts / recent peers.
ui.label(
RichText::new(format!("{} Suggested", USERS))
.font(fonts::kicker())
.color(t.text_mute),
);
ui.add_space(8.0);
let peers = recent_peers(wallet, 20);
let texs: Vec<Option<egui::TextureHandle>> = peers
// Type-ahead results: instant local matches + the network candidate.
let mut cands: Vec<Candidate> = search_contacts(wallet, &query, 6)
.into_iter()
.map(|(name, hue, npub)| Candidate {
name,
npub,
hue,
verified: true,
tag: "contact",
})
.collect();
if let Some(net) = &self.net_candidate {
if !cands.iter().any(|c| c.npub == net.npub) {
cands.push(net.clone());
}
}
let texs: Vec<Option<egui::TextureHandle>> = cands
.iter()
.map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name))
.map(|c| tex_for(avatars, ui.ctx(), wallet, &c.name))
.collect();
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
if peers.is_empty() {
ui.add_space(20.0);
ui.label(
RichText::new("No contacts yet. Find someone by their @handle.")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
}
for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) {
let resp = w::activity_row(
ui,
&name,
&short_npub(&npub),
hue,
"",
false,
false,
tex.as_ref(),
);
if resp.clicked() {
self.recipient = Some(Recipient { name, npub, hue });
self.stage = Stage::Amount;
for (c, tex) in cands.iter().zip(texs.iter()) {
let tag = if c.verified {
format!("{}", c.tag)
} else {
"no profile".to_string()
};
if w::activity_row(ui, &c.name, &tag, c.hue, "", false, false, tex.as_ref())
.clicked()
{
self.pick(c.clone());
}
}
if self.looking_up {
ui.add_space(10.0);
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new("Searching nostr…")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.ctx().request_repaint();
}
if let Some(err) = &self.error {
ui.add_space(10.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
});
false
}
/// Select a candidate: verified ones go straight in; unverified keys hit
/// the confirm gate first.
fn pick(&mut self, cand: Candidate) {
if cand.verified {
self.recipient = Some(Recipient {
name: cand.name,
npub: cand.npub,
hue: cand.hue,
});
let preset = amount_from_hr_string(&self.amount)
.map(|a| a > 0)
.unwrap_or(false);
self.stage = if preset { Stage::Review } else { Stage::Amount };
} else {
self.confirm_unverified = Some(cand);
}
}
/// "Pay an unverified key?" gate for a valid key with no nostr profile.
fn unverified_gate_ui(&mut self, ui: &mut egui::Ui, cand: &Candidate) {
let t = theme::tokens();
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new("Pay an unverified key?")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"No nostr profile is published for this key — it may be \
brand new, anonymous, or mistyped. Double-check it's the \
right one before sending.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
ui.label(
RichText::new(short_npub(&cand.npub))
.font(FontId::new(12.0, fonts::mono()))
.color(t.surface_text_mute),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, "Keep looking").clicked() {
self.confirm_unverified = None;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action(ui, "Pay anyway", false).clicked() {
let mut c = cand.clone();
c.verified = true;
self.confirm_unverified = None;
self.pick(c);
}
},
);
});
});
}
/// Camera feed scanning for a recipient QR (npub / nostr: URI / @handle).
fn scan_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
const NO_RECIPIENT: &str = "That QR isn't a goblin recipient — expected an npub or @handle";
@@ -370,11 +527,13 @@ impl SendFlow {
.strip_prefix("nostr:")
.or_else(|| text.strip_prefix("NOSTR:"))
.unwrap_or(text);
// Drop the scanned key into the search box; the picker's
// debounced lookup resolves + verifies it like typed input.
self.search = text.to_string();
self.resolve_search(wallet);
if self.error.is_some() {
self.error = Some(NO_RECIPIENT.to_string());
}
self.input_changed_at = ui.input(|i| i.time);
self.lookup_query.clear();
self.net_candidate = None;
let _ = wallet;
}
_ => self.error = Some(NO_RECIPIENT.to_string()),
}
@@ -395,83 +554,126 @@ impl SendFlow {
}
}
fn resolve_search(&mut self, wallet: &Wallet) {
let input = self.search.trim().to_string();
/// Debounced network resolution: ~0.4s after the last keystroke, kick off
/// one lookup for the current query (npub/hex → nostr kind-0 profile;
/// name/@handle → goblin.st nip05). Poll the worker's result each frame.
fn drive_lookup(&mut self, wallet: &Wallet, now: f64) {
// Poll a finished lookup first.
if self.looking_up {
if let Some(res) = self.lookup_slot.lock().unwrap().take() {
self.looking_up = false;
match res {
LookupResult::Found(c) | LookupResult::Unverified(c) => {
self.net_candidate = Some(c);
self.error = None;
}
LookupResult::NotFound(label) => {
self.net_candidate = None;
self.error = Some(format!("No one found for {label}"));
}
}
} else {
return; // still in flight
}
}
let query = self.search.trim().to_string();
if query.is_empty() {
self.lookup_query.clear();
return;
}
// Debounce, and only resolve a given query once.
if query == self.lookup_query || now - self.input_changed_at < 0.4 {
return;
}
self.lookup_query = query.clone();
self.error = None;
// Instant cases (no network): bech32 npub or raw hex pubkey.
use nostr_sdk::{FromBech32, PublicKey};
if let Ok(pk) = PublicKey::from_bech32(&input) {
self.set_recipient_from_hex(wallet, &pk.to_hex(), None);
return;
}
if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) {
self.set_recipient_from_hex(wallet, &input.to_lowercase(), None);
return;
}
// NIP-05 resolution over Tor: run on a worker thread, never block the
// UI. The result is written to `resolve_slot` and polled each frame.
if let Some((name, domain)) = nip05::split_identifier(&input) {
self.resolving = true;
*self.resolve_slot.lock().unwrap() = None;
let slot = self.resolve_slot.clone();
let hex = if let Ok(pk) = PublicKey::from_bech32(&query) {
Some(pk.to_hex())
} else if query.len() == 64 && query.chars().all(|c| c.is_ascii_hexdigit()) {
Some(query.to_lowercase())
} else {
None
};
let slot = self.lookup_slot.clone();
if let Some(hex) = hex {
// Valid key → confirm it's a live identity via its kind-0 profile.
self.looking_up = true;
let service = wallet.nostr_service();
let known = wallet.nostr_service().and_then(|s| {
s.store
.contact(&hex)
.map(|c| (display_name(&c), c.hue as usize))
});
std::thread::spawn(move || {
let outcome: ResolveOutcome = match resolve_nip05_blocking(&name, &domain) {
Some(res) => Ok((res.pubkey.to_hex(), Some(format!("{}@{}", name, domain)))),
None => Err("Couldn't find that handle".to_string()),
let hue = data::hue_of(&hex);
let profile = service.and_then(|s| s.fetch_profile_blocking(&hex));
let res = match (known, profile) {
// Already a saved contact — trust it.
(Some((name, hue)), _) => LookupResult::Found(Candidate {
name,
npub: hex,
hue,
verified: true,
tag: "contact",
}),
(None, Some(p)) => {
let name = p
.nip05
.as_deref()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.or(p.name)
.unwrap_or_else(|| short_npub(&hex));
LookupResult::Found(Candidate {
name,
npub: hex,
hue,
verified: true,
tag: "on nostr",
})
}
(None, None) => LookupResult::Unverified(Candidate {
name: short_npub(&hex),
npub: hex,
hue,
verified: false,
tag: "",
}),
};
*slot.lock().unwrap() = Some(outcome);
*slot.lock().unwrap() = Some(res);
});
} else if let Some((name, domain)) = nip05::split_identifier(&query) {
// Name / @handle → goblin.st (or other) nip05 resolution.
self.looking_up = true;
let label = format!("@{name}");
std::thread::spawn(move || {
let res = match resolve_nip05_blocking(&name, &domain) {
Some(r) => {
let hex = r.pubkey.to_hex();
LookupResult::Found(Candidate {
name: format!("@{name}"),
npub: hex.clone(),
hue: data::hue_of(&hex),
verified: true,
tag: if domain == "goblin.st" {
"@goblin.st"
} else {
"nip-05"
},
})
}
None => LookupResult::NotFound(label),
};
*slot.lock().unwrap() = Some(res);
});
} else {
self.error = Some("Enter an @handle, npub, or name".to_string());
}
}
/// Apply an async NIP-05 resolution result once the worker finishes.
fn poll_resolution(&mut self, wallet: &Wallet) {
if !self.resolving {
return;
}
let outcome = self.resolve_slot.lock().unwrap().take();
if let Some(outcome) = outcome {
self.resolving = false;
match outcome {
Ok((hex, nip05)) => self.set_recipient_from_hex(wallet, &hex, nip05),
Err(e) => self.error = Some(e),
}
}
}
fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option<String>) {
let (name, hue) = wallet
.nostr_service()
.map(|s| {
if let Some(c) = s.store.contact(hex) {
(display_name(&c), c.hue as usize)
} else {
let hue = data::hue_of(hex);
(
nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| short_npub(hex)),
hue,
)
}
})
.unwrap_or_else(|| (short_npub(hex), 0));
self.recipient = Some(Recipient {
name,
npub: hex.to_string(),
hue,
});
// Amount-first (Pay tab): the amount is already set, go straight
// to review; otherwise continue to amount entry.
let preset = amount_from_hr_string(&self.amount)
.map(|a| a > 0)
.unwrap_or(false);
self.stage = if preset { Stage::Review } else { Stage::Amount };
}
fn amount_ui(
&mut self,
ui: &mut egui::Ui,
+13 -5
View File
@@ -535,17 +535,26 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
[".", "0", "<"],
];
let key_h = 56.0;
let gap = 10.0;
// Center a fixed-width pad so the three columns line up directly under
// the centered amount above, on any width.
let pad_w = ui.available_width().min(300.0);
let key_w = (pad_w - 2.0 * gap) / 3.0;
let side = ((ui.available_width() - pad_w) / 2.0).max(0.0);
for row in keys.iter() {
ui.horizontal(|ui| {
let w = (ui.available_width() - 2.0 * 8.0) / 3.0;
for &k in row.iter() {
let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, key_h), Sense::click());
ui.add_space(side);
for (i, &k) in row.iter().enumerate() {
if i > 0 {
ui.add_space(gap);
}
let (rect, resp) = ui.allocate_exact_size(Vec2::new(key_w, key_h), Sense::click());
let label = if k == "<" {
crate::gui::icons::BACKSPACE.to_string()
} else {
k.to_string()
};
let col = if resp.hovered() { t.text } else { t.text };
let col = if resp.hovered() { t.accent } else { t.text };
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
@@ -557,7 +566,6 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
apply_key(amount, k);
changed = true;
}
ui.add_space(8.0);
}
});
ui.add_space(4.0);
+32
View File
@@ -36,6 +36,13 @@ use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
use crate::tor::transport::ArtiWebSocketTransport;
use crate::wallet::Wallet;
/// A peer's published nostr profile (kind-0 metadata), used to confirm a
/// pasted key belongs to a live identity before paying it.
pub struct NostrProfile {
pub name: Option<String>,
pub nip05: Option<String>,
}
/// Subscription look-back window beyond the last connection time: gift wrap
/// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days.
const LOOKBACK_SECS: i64 = 3 * 86_400;
@@ -127,6 +134,31 @@ impl NostrService {
self.keys.secret_key().to_bech32().ok()
}
/// Fetch a pubkey's published kind-0 profile over the connected relay
/// pool (one shot, short timeout). `Some` means the key is a live nostr
/// identity; `None` means no profile is published (new/anonymous key) or
/// the relays were unreachable. Blocking — call from a worker thread.
pub fn fetch_profile_blocking(&self, hex: &str) -> Option<NostrProfile> {
let client = self.client.read().clone()?;
let pk = PublicKey::from_hex(hex).ok()?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(async {
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
let events = client
.fetch_events(filter, Duration::from_secs(10))
.await
.ok()?;
let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?;
Some(NostrProfile {
name: md.name.filter(|s| !s.is_empty()),
nip05: md.nip05.filter(|s| !s.is_empty()),
})
})
}
/// Read the current outgoing-send phase (see [`send_phase`]).
pub fn send_phase(&self) -> u8 {
self.send_phase.load(Ordering::Relaxed)
+1 -1
View File
@@ -37,7 +37,7 @@ pub mod ingest;
pub use ingest::*;
mod client;
pub use client::{NostrService, send_phase};
pub use client::{NostrProfile, NostrService, send_phase};
pub mod avatar;
pub mod nip05;