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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -124,6 +124,7 @@ impl GoblinWalletView {
|
||||
item.system,
|
||||
htex.as_ref(),
|
||||
false,
|
||||
data::tx_row_anonymous(item.npub.as_deref(), item.system),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user