From 77c1c0934bd727e5de27c394ea03244c41201f26 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:54:17 -0400 Subject: [PATCH] goblin: no npub association => anonymous avatar on tx rows Wiping payment history clears tx_meta (the tx<->npub link), so build_item already anonymizes a wiped row's title/npub. But the avatar was resolved from the row's name string independently of the npub, so a leftover name or cached profile could still render a real profile picture and betray who a wiped tx was with. Gate avatar resolution on the presence of an npub association: a tx row with no npub (wiped metadata, or a non-nostr/plain tx) now renders the anonymous yellow-goblin tile and never fetches a real avatar, across the activity feed, receipt and profile-history surfaces. Contacts are kept by design (the address book) and are documented/asserted as such. Adds a store-level wipe test (no UI query can rejoin a wiped tx to an npub or name) and a pure resolution-gate test. --- src/gui/views/goblin/activity.rs | 7 ++- src/gui/views/goblin/data.rs | 23 +++++++ src/gui/views/goblin/profile.rs | 1 + src/gui/views/goblin/receipt.rs | 24 ++++--- src/gui/views/goblin/send.rs | 2 + src/gui/views/goblin/widgets.rs | 9 ++- src/nostr/store.rs | 103 +++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 11 deletions(-) diff --git a/src/gui/views/goblin/activity.rs b/src/gui/views/goblin/activity.rs index 18ef3086..b29ec25e 100644 --- a/src/gui/views/goblin/activity.rs +++ b/src/gui/views/goblin/activity.rs @@ -188,7 +188,11 @@ impl GoblinWalletView { format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU) }; let (note, time) = Self::activity_note_time(item); - let tex = if anon { + // No npub association (a non-nostr tx, or metadata cleared by a payment + // history wipe) => never resolve a real avatar for this row; render the + // anonymous yellow-goblin tile so the picture can't leak who it was with. + let anon_avatar = data::tx_row_anonymous(item.npub.as_deref(), item.system); + let tex = if anon || item.npub.is_none() { None } else { self.handle_tex(ui.ctx(), wallet, &item.title) @@ -210,6 +214,7 @@ impl GoblinWalletView { item.system, tex.as_ref(), anon, + anon_avatar, ); // Per-identity cue (owner-approved): only when the wallet holds MORE THAN // ONE identity, and never on system (mining) rows. A small corner badge on diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 8efb75b7..fa86a611 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -193,6 +193,17 @@ fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool { .unwrap_or(false)) } +/// Whether a transaction row must render the anonymous yellow-goblin avatar +/// instead of resolving a real profile picture: true when the row has no npub +/// association (a non-nostr tx, or one whose nostr metadata was cleared by a +/// payment-history wipe). System (mining) rows draw their own cube tile, so this +/// never applies to them. The rule is: no npub association => anonymous avatar, +/// so the picture can never leak who a wiped tx was with once the name/npub are +/// gone from the row. +pub fn tx_row_anonymous(npub: Option<&str>, system: bool) -> bool { + !system && npub.is_none() +} + /// Resolve the display title for a contact npub. pub fn contact_title(store: &NostrStore, npub: &str) -> String { if let Some(contact) = store.contact(npub) { @@ -604,6 +615,18 @@ pub fn split_urls(s: &str) -> Vec<(String, bool)> { mod tests { use super::*; + #[test] + fn tx_row_anonymous_gates_on_npub() { + // A row with a counterparty npub resolves its real avatar/name. + assert!(!tx_row_anonymous(Some("abc123"), false)); + // No npub (non-nostr tx, or wiped metadata) => anonymous yellow goblin. + assert!(tx_row_anonymous(None, false)); + // System (mining) rows never take the anonymous-avatar path — they draw + // their own cube tile — regardless of npub. + assert!(!tx_row_anonymous(None, true)); + assert!(!tx_row_anonymous(Some("abc123"), true)); + } + #[test] fn split_urls_isolates_links() { let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now"); diff --git a/src/gui/views/goblin/profile.rs b/src/gui/views/goblin/profile.rs index 48fdfb90..657ca9d8 100644 --- a/src/gui/views/goblin/profile.rs +++ b/src/gui/views/goblin/profile.rs @@ -124,6 +124,7 @@ impl GoblinWalletView { item.system, htex.as_ref(), false, + data::tx_row_anonymous(item.npub.as_deref(), item.system), ) .clicked() { diff --git a/src/gui/views/goblin/receipt.rs b/src/gui/views/goblin/receipt.rs index cd87c0cc..239f6fc1 100644 --- a/src/gui/views/goblin/receipt.rs +++ b/src/gui/views/goblin/receipt.rs @@ -22,8 +22,11 @@ impl GoblinWalletView { pub(super) fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool { let t = theme::tokens(); let d = data::receipt_detail(wallet, tx_id); + // Only resolve a real avatar when the tx has an npub association; a wiped + // or non-nostr tx (no npub) must never resolve a profile picture. let tex = d .as_ref() + .filter(|d| d.npub.is_some()) .and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title)); let mut close = false; let mut open_profile: Option = None; @@ -60,13 +63,20 @@ impl GoblinWalletView { .show(ui, |ui| { ui.add_space(8.0); ui.vertical_centered(|ui| { - let resp = w::avatar_any( - ui, - &d.title, - d.npub.as_deref().unwrap_or(""), - 64.0, - tex.as_ref(), - ); + let resp = if d.npub.is_some() { + w::avatar_any( + ui, + &d.title, + d.npub.as_deref().unwrap_or(""), + 64.0, + tex.as_ref(), + ) + } else { + // No npub association (wiped or non-nostr tx): + // the anonymous yellow-goblin tile, never a real + // profile picture. + w::avatar_censored(ui, 64.0) + }; ui.add_space(10.0); ui.label( RichText::new(&d.title) diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 1e470b05..c6ff8b52 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -636,6 +636,7 @@ impl SendFlow { false, tex.as_ref(), false, + false, ) .clicked() { @@ -700,6 +701,7 @@ impl SendFlow { false, tex.as_ref(), false, + false, ) .clicked() { diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index d6d89bdb..62cd15b0 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -696,6 +696,7 @@ pub fn activity_row( system: bool, tex: Option<&egui::TextureHandle>, anon: bool, + anon_avatar: bool, ) -> Response { let t = theme::tokens(); // A touch taller than a single-line row so the amount can sit centered @@ -726,9 +727,11 @@ pub fn activity_row( FontId::new(20.0, fonts::regular()), t.text, ); - } else if anon { - // Anonymous mode: the uniform censored tile stands in for every - // counterparty avatar, so nothing about who they are leaks. + } else if anon || anon_avatar { + // Anonymous mode, OR a row with no npub association (e.g. after a + // payment-history wipe, or a non-nostr tx): the uniform yellow-goblin + // tile stands in for every counterparty avatar, so a real profile + // picture can never betray who a wiped/unknown tx was with. avatar_censored(ui, 40.0); } else { avatar_any(ui, title, id, 40.0, tex); diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 313eaf47..483914e1 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -448,4 +448,107 @@ mod tests { "untagged English variant must survive the cap" ); } + + fn temp_store(tag: &str) -> NostrStore { + let dir = std::env::temp_dir().join(format!( + "goblin-store-test-{}-{}-{:?}", + std::process::id(), + tag, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = fs::remove_dir_all(&dir); + NostrStore::new(dir) + } + + fn sample_meta(slate_id: &str, npub: &str) -> TxNostrMeta { + TxNostrMeta { + ver: 1, + slate_id: slate_id.to_string(), + npub: npub.to_string(), + direction: NostrTxDirection::Sent, + note: Some("lunch".to_string()), + status: NostrSendStatus::Finalized, + sent_event_id: None, + received_rumor_id: None, + created_at: 100, + updated_at: 100, + proof_mode: false, + proof_order: None, + proof_notify: None, + proof_amount: None, + proof_delivered: false, + receipt_sent: false, + recipient_pubkey: String::new(), + proof_address: None, + } + } + + fn sample_contact(npub: &str) -> Contact { + Contact { + ver: 1, + npub: npub.to_string(), + petname: Some("Alice".to_string()), + nip05: Some("alice@goblin.st".to_string()), + nip05_verified_at: Some(100), + relays: vec![], + nip44_v3: false, + hue: 0, + unknown: false, + added_at: 100, + last_paid_at: Some(100), + blocked: false, + } + } + + fn sample_request(rumor_id: &str, npub: &str) -> PaymentRequest { + PaymentRequest { + ver: 1, + rumor_id: rumor_id.to_string(), + slate_id: "slate-req".to_string(), + slatepack: "BEGINSLATEPACK.END".to_string(), + npub: npub.to_string(), + amount: 42, + note: None, + received_at: 100, + status: RequestStatus::Pending, + } + } + + /// After a payment-history wipe, none of the queries the activity UI reads + /// (tx metadata, payment requests, processed markers) can resolve a + /// counterparty for a wiped tx anymore — so no leftover name/npub is left to + /// resolve a profile picture. Contacts are intentionally kept (the address + /// book), which is asserted here so a future change to that is deliberate. + #[test] + fn wipe_archive_clears_tx_association_but_keeps_contacts() { + let store = temp_store("wipe"); + let npub = "abc123def456"; + store.save_tx_meta(&sample_meta("slate-1", npub)); + store.save_contact(&sample_contact(npub)); + store.save_request(&sample_request("rumor-1", npub)); + store.mark_processed("slate-1:S1"); + + // Pre-wipe: the tx resolves its counterparty. + assert_eq!(store.all_tx_meta().len(), 1); + assert!(store.tx_meta("slate-1").is_some()); + assert_eq!(store.pending_requests().len(), 1); + assert!(store.is_processed("slate-1:S1")); + + store.wipe_archive(); + + // Post-wipe: nothing the activity feed / receipt reads can join a + // surviving grin tx row back to an npub, name, or avatar. + assert!(store.all_tx_meta().is_empty()); + assert!(store.tx_meta("slate-1").is_none()); + assert!(store.all_requests().is_empty()); + assert!(store.pending_requests().is_empty()); + assert!(!store.is_processed("slate-1:S1")); + + // Contacts survive by design (the address book, not payment history). + assert_eq!(store.all_contacts().len(), 1); + assert!(store.contact(npub).is_some()); + } }