1
0
forked from GRIN/grim

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.
This commit is contained in:
2ro
2026-07-09 23:54:17 -04:00
parent 6cf36402ec
commit 77c1c0934b
7 changed files with 158 additions and 11 deletions
+6 -1
View File
@@ -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
+23
View File
@@ -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");
+1
View File
@@ -124,6 +124,7 @@ impl GoblinWalletView {
item.system,
htex.as_ref(),
false,
data::tx_row_anonymous(item.npub.as_deref(), item.system),
)
.clicked()
{
+17 -7
View File
@@ -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<String> = 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)
+2
View File
@@ -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()
{
+6 -3
View File
@@ -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);
+103
View File
@@ -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());
}
}