1
0
forked from GRIN/grim

Build 78: honest transport labels, request decline/cancel, NIP-05 on requests, full localization

Settings now says "Manual transaction" and the privacy row reads "Messages &
lookups" opening a new Network privacy page that tells the truth: messages,
names, price and avatars ride the Nym mixnet; the grin node connects directly.
README and lander updated to match.

Requests are messages, payments are final: declining a request now sends the
requester a void control message (NIP-17), a requester can cancel a request they
sent (cancels the local invoice and notifies the payer), and incoming requests
resolve the sender's verified @username instead of a bare npub. The Requested
amount on the success screen is centered. New NostrDecline/NostrCancel tasks and
a goblin-action control message carry it, bound to the stored counterparty.

Localization: every Goblin-screen string moved to t!() keys (370 keys) and
translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test.
System-locale auto-detect now matches region locales like zh-CN.
This commit is contained in:
2ro
2026-06-14 21:44:24 -04:00
parent 0644807f51
commit f0b854171c
19 changed files with 3610 additions and 578 deletions
File diff suppressed because it is too large Load Diff
+119 -101
View File
@@ -185,22 +185,18 @@ impl OnboardingContent {
);
});
ui.add_space(26.0);
let lines: [(&str, &str); 3] = [
let lines: [(String, String); 3] = [
(
"Private money",
"Goblin is a wallet for grin — digital cash with no amounts \
or addresses on its chain.",
t!("goblin.onboarding.intro.private_money_head").to_string(),
t!("goblin.onboarding.intro.private_money_body").to_string(),
),
(
"Send like a message",
"Pay a @username or npub and it arrives as an end-to-end \
encrypted message over nostr and the Nym mixnet — no one in \
between can see the amount or who's involved.",
t!("goblin.onboarding.intro.send_like_message_head").to_string(),
t!("goblin.onboarding.intro.send_like_message_body").to_string(),
),
(
"Yours alone",
"Keys, names and history live on this device. Built on the \
GRIM wallet.",
t!("goblin.onboarding.intro.yours_alone_head").to_string(),
t!("goblin.onboarding.intro.yours_alone_body").to_string(),
),
];
for (head, body) in lines {
@@ -221,13 +217,13 @@ impl OnboardingContent {
ui.add_space(10.0);
}
ui.add_space(16.0);
if w::big_action(ui, "Get started", false).clicked() {
if w::big_action(ui, &t!("goblin.onboarding.intro.get_started"), false).clicked() {
self.step = Step::Node;
}
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Takes about a minute. You can change everything later.")
RichText::new(t!("goblin.onboarding.intro.footnote"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
@@ -293,17 +289,16 @@ impl OnboardingContent {
let t = theme::tokens();
self.step_header(
ui,
"STEP 1 OF 3 · NETWORK",
"How should Goblin\nwatch the chain?",
&t!("goblin.onboarding.node.kicker"),
&t!("goblin.onboarding.node.title"),
Step::Intro,
);
if Self::node_card(
ui,
self.integrated,
"Run my own node",
"Private",
"Trusts no one — your wallet checks the chain itself. Syncs in \
the background while you finish setup.",
&t!("goblin.onboarding.node.own_title"),
&t!("goblin.onboarding.node.own_badge"),
&t!("goblin.onboarding.node.own_body"),
) {
self.integrated = true;
}
@@ -311,9 +306,9 @@ impl OnboardingContent {
if Self::node_card(
ui,
!self.integrated,
"Connect to a node",
"Instant",
"No sync wait. The node you pick can see your wallet's queries.",
&t!("goblin.onboarding.node.connect_title"),
&t!("goblin.onboarding.node.connect_badge"),
&t!("goblin.onboarding.node.connect_body"),
) {
self.integrated = false;
}
@@ -330,7 +325,7 @@ impl OnboardingContent {
}
ui.add_space(8.0);
ui.label(
RichText::new("Changeable any time in Settings → Node.")
RichText::new(t!("goblin.onboarding.node.changeable"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
@@ -338,13 +333,13 @@ impl OnboardingContent {
let url_ok = self.integrated
|| self.ext_url.trim().starts_with("http://")
|| self.ext_url.trim().starts_with("https://");
if w::big_action(ui, "Continue", false).clicked() && url_ok {
if w::big_action(ui, &t!("goblin.onboarding.node.continue"), false).clicked() && url_ok {
self.step = Step::WalletSetup;
}
if !url_ok {
ui.add_space(8.0);
ui.label(
RichText::new("Node URL must start with http:// or https://")
RichText::new(t!("goblin.onboarding.node.url_invalid"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
@@ -355,12 +350,20 @@ impl OnboardingContent {
fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node);
self.step_header(
ui,
&t!("goblin.onboarding.wallet.kicker"),
&t!("goblin.onboarding.wallet.title"),
Step::Node,
);
// Create / Restore segmented choice.
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] {
for (restore, label) in [
(false, t!("goblin.onboarding.wallet.create_new")),
(true, t!("goblin.onboarding.wallet.restore_from_seed")),
] {
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
@@ -368,7 +371,7 @@ impl OnboardingContent {
)),
|ui| {
let active = self.restore == restore;
let resp = w::chip(ui, label, active);
let resp = w::chip(ui, &label, active);
if resp.clicked() {
self.restore = restore;
}
@@ -382,7 +385,7 @@ impl OnboardingContent {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_name"))
.focus(false)
.hint_text("Wallet name")
.hint_text(t!("goblin.onboarding.wallet.name_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.name, cb);
@@ -391,7 +394,7 @@ impl OnboardingContent {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_pass"))
.focus(false)
.hint_text("Password")
.hint_text(t!("goblin.onboarding.wallet.password_hint"))
.password()
.text_color(t.surface_text)
.body()
@@ -401,7 +404,7 @@ impl OnboardingContent {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_pass2"))
.focus(false)
.hint_text("Repeat password")
.hint_text(t!("goblin.onboarding.wallet.repeat_password_hint"))
.password()
.text_color(t.surface_text)
.body()
@@ -410,10 +413,9 @@ impl OnboardingContent {
ui.add_space(10.0);
ui.label(
RichText::new(if self.restore {
"Have your seed words ready — you'll enter them next."
t!("goblin.onboarding.wallet.restore_hint")
} else {
"Next you'll get 24 seed words to write down. They are the \
money — anyone holding them holds your funds."
t!("goblin.onboarding.wallet.create_hint")
})
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
@@ -422,7 +424,10 @@ impl OnboardingContent {
let pass_ok = !self.pass.is_empty() && self.pass == self.pass2;
let name_ok = !self.name.trim().is_empty();
if w::big_action(ui, "Continue", false).clicked() && pass_ok && name_ok {
if w::big_action(ui, &t!("goblin.onboarding.wallet.continue"), false).clicked()
&& pass_ok
&& name_ok
{
self.mnemonic_setup.reset();
self.mnemonic_setup.mnemonic.set_mode(if self.restore {
PhraseMode::Import
@@ -436,7 +441,7 @@ impl OnboardingContent {
if !self.pass.is_empty() && self.pass != self.pass2 {
ui.add_space(8.0);
ui.label(
RichText::new("Passwords don't match")
RichText::new(t!("goblin.onboarding.wallet.passwords_no_match"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
@@ -453,14 +458,15 @@ impl OnboardingContent {
) {
let t = theme::tokens();
let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import;
let words_title = if restore {
t!("goblin.onboarding.words.title_restore")
} else {
t!("goblin.onboarding.words.title_create")
};
self.step_header(
ui,
"STEP 2 OF 3 · WALLET",
if restore {
"Enter your seed words"
} else {
"Write these words down"
},
&t!("goblin.onboarding.words.kicker"),
&words_title,
Step::WalletSetup,
);
if restore {
@@ -478,12 +484,9 @@ impl OnboardingContent {
ui.add_space(10.0);
} else {
ui.label(
RichText::new(
"On paper, in order. Anyone with these words can take \
your funds; without them a lost device means lost funds.",
)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
RichText::new(t!("goblin.onboarding.words.write_down_hint"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
}
@@ -501,7 +504,7 @@ impl OnboardingContent {
Vec2::new(half, 44.0),
)),
|ui| {
if w::chip(ui, "Paste", false).clicked() {
if w::chip(ui, &t!("goblin.onboarding.words.paste"), false).clicked() {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
}
@@ -514,7 +517,7 @@ impl OnboardingContent {
Vec2::new(half, 44.0),
)),
|ui| {
if w::chip(ui, "Scan QR", false).clicked() {
if w::chip(ui, &t!("goblin.onboarding.words.scan_qr"), false).clicked() {
self.scan_modal = Some(CameraScanContent::default());
Modal::new(OB_PHRASE_SCAN_MODAL)
.position(ModalPosition::CenterTop)
@@ -527,7 +530,7 @@ impl OnboardingContent {
);
});
ui.add_space(14.0);
} else if w::chip(ui, "Copy to clipboard (avoid this)", false).clicked() {
} else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() {
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
}
if !restore {
@@ -540,12 +543,12 @@ impl OnboardingContent {
true
};
let label = if restore {
"Restore wallet"
t!("goblin.onboarding.words.restore_wallet")
} else {
"I wrote them down"
t!("goblin.onboarding.words.wrote_them_down")
};
if ready {
if w::big_action(ui, label, false).clicked() {
if w::big_action(ui, &label, false).clicked() {
if restore {
self.create_wallet(wallets);
} else {
@@ -554,7 +557,7 @@ impl OnboardingContent {
}
} else {
ui.label(
RichText::new("Fill every word — tap a word to edit it, or paste the phrase.")
RichText::new(t!("goblin.onboarding.words.fill_every_word"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_mute),
);
@@ -569,27 +572,32 @@ impl OnboardingContent {
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words);
self.step_header(
ui,
&t!("goblin.onboarding.confirm.kicker"),
&t!("goblin.onboarding.confirm.title"),
Step::Words,
);
ui.label(
RichText::new("Enter the words you just wrote down. Tap a word to type it.")
RichText::new(t!("goblin.onboarding.confirm.enter_hint"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
self.mnemonic_setup.word_list_ui(ui, true);
ui.add_space(14.0);
if w::chip(ui, "Paste", false).clicked() {
if w::chip(ui, &t!("goblin.onboarding.confirm.paste"), false).clicked() {
let data = ZeroingString::from(cb.get_string_from_buffer());
self.mnemonic_setup.mnemonic.import(&data);
}
ui.add_space(14.0);
if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() {
if w::big_action(ui, "Create wallet", false).clicked() {
if w::big_action(ui, &t!("goblin.onboarding.confirm.create_wallet"), false).clicked() {
self.create_wallet(wallets);
}
} else {
ui.label(
RichText::new("Keep going — every word, in order.")
RichText::new(t!("goblin.onboarding.confirm.keep_going"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_mute),
);
@@ -650,10 +658,20 @@ impl OnboardingContent {
self.error = None;
self.step = Step::Identity;
}
Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)),
Err(e) => {
self.error = Some(
t!("goblin.onboarding.errors.cant_open", err => format!("{:?}", e))
.to_string(),
)
}
}
}
Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)),
Err(e) => {
self.error = Some(
t!("goblin.onboarding.errors.cant_create", err => format!("{:?}", e))
.to_string(),
)
}
}
}
@@ -663,13 +681,13 @@ impl OnboardingContent {
let t = theme::tokens();
// No back from here: the wallet exists now.
ui.label(
RichText::new("STEP 3 OF 3 · IDENTITY")
RichText::new(t!("goblin.onboarding.identity.kicker"))
.font(fonts::kicker())
.color(t.text_mute),
);
ui.add_space(18.0);
ui.label(
RichText::new("Your payment identity")
RichText::new(t!("goblin.onboarding.identity.title"))
.font(FontId::new(26.0, fonts::bold()))
.color(t.text),
);
@@ -698,7 +716,7 @@ impl OnboardingContent {
let short = if npub.len() > 20 {
format!("{}{}", &npub[..12], &npub[npub.len() - 6..])
} else if npub.is_empty() {
"key being made…".to_string()
t!("goblin.onboarding.identity.key_being_made").to_string()
} else {
npub.clone()
};
@@ -709,9 +727,9 @@ impl OnboardingContent {
);
ui.label(
RichText::new(if connected {
"connected over Nym"
t!("goblin.onboarding.identity.connected_nym")
} else {
"connecting over Nym…"
t!("goblin.onboarding.identity.connecting_nym")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
@@ -720,24 +738,15 @@ impl OnboardingContent {
});
ui.add_space(8.0);
ui.label(
RichText::new(
"A fresh key, made for payments — deliberately not part \
of your seed, so you can rotate it anytime to maintain \
your privacy, without ever touching your funds. Back it \
up in Settings → Identity.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
RichText::new(t!("goblin.onboarding.identity.fresh_key_blurb"))
.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? Swap in a brand-new key any time — \
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),
RichText::new(t!("goblin.onboarding.identity.clean_slate_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(14.0);
@@ -752,8 +761,13 @@ impl OnboardingContent {
self.claim.message = Some(msg.to_string());
}
ClaimMsg::Registered(nip05) => {
self.claim.message =
Some(format!("You're @{}", nip05.split('@').next().unwrap_or("")));
self.claim.message = Some(
t!(
"goblin.onboarding.identity.youre",
name => nip05.split('@').next().unwrap_or("")
)
.to_string(),
);
self.claim.available = Some(true);
if let Some(s) = wallet.nostr_service() {
{
@@ -779,19 +793,15 @@ impl OnboardingContent {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new("Pick a username — optional")
RichText::new(t!("goblin.onboarding.identity.pick_username"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"Friends pay @you instead of a long key. Public on \
goblin.st; payments stay encrypted. Skip it and \
you're simply anonymous — claim one any time later.",
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
RichText::new(t!("goblin.onboarding.identity.username_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
w::field_well(ui, |ui| {
@@ -804,7 +814,7 @@ impl OnboardingContent {
let before = self.claim.input.clone();
TextEdit::new(egui::Id::from("onb_claim"))
.focus(false)
.hint_text("yourname")
.hint_text(t!("goblin.onboarding.identity.username_field_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.claim.input, cb);
@@ -833,21 +843,29 @@ impl OnboardingContent {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(RichText::new("Working…").color(t.surface_text_dim));
ui.label(
RichText::new(t!("goblin.onboarding.identity.working"))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else {
ui.add_enabled_ui(valid && connected, |ui| {
if w::big_action_on_card(ui, "Claim username").clicked() {
if w::big_action_on_card(
ui,
&t!("goblin.onboarding.identity.claim_username"),
)
.clicked()
{
start_claim_flow(&mut self.claim, &name, &wallet);
}
});
if !connected {
ui.add_space(6.0);
ui.label(
RichText::new(
"Available once the mixnet connects — or skip and claim later.",
)
RichText::new(t!(
"goblin.onboarding.identity.available_when_connected"
))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
@@ -865,11 +883,11 @@ impl OnboardingContent {
}
let main_label = if registered {
"Open my wallet"
t!("goblin.onboarding.identity.open_wallet")
} else {
"Skip for now"
t!("goblin.onboarding.identity.skip_for_now")
};
if w::big_action(ui, main_label, false).clicked() {
if w::big_action(ui, &main_label, false).clicked() {
return Some(wallet);
}
None
+150 -111
View File
@@ -288,21 +288,21 @@ impl SendFlow {
// Scan-to-pay screen: a Scan | My Code toggle over the camera or your own
// payment QR. Replaces the picker until closed.
if self.scan_open {
if self.back_header(
ui,
if self.request {
"Scan to request"
} else {
"Scan to pay"
},
) {
let title = if self.request {
t!("goblin.send.scan_to_request")
} else {
t!("goblin.send.scan_to_pay")
};
if self.back_header(ui, &title) {
cb.stop_camera();
self.scan = None;
self.scan_open = false;
return false;
}
let sel = if self.scan_tab == ScanTab::Scan { 0 } else { 1 };
if let Some(i) = w::segmented(ui, &["Scan", "My Code"], sel) {
let (tab_scan, tab_my_code) =
(t!("goblin.send.tab_scan"), t!("goblin.send.tab_my_code"));
if let Some(i) = w::segmented(ui, &[&tab_scan, &tab_my_code], sel) {
self.scan_tab = if i == 0 {
ScanTab::Scan
} else {
@@ -331,14 +331,12 @@ impl SendFlow {
return false;
}
if self.back_header(
ui,
if self.request {
"Request from"
} else {
"Send to"
},
) {
let title = if self.request {
t!("goblin.send.request_from")
} else {
t!("goblin.send.send_to")
};
if self.back_header(ui, &title) {
return true;
}
@@ -365,7 +363,7 @@ impl SendFlow {
ui.add_space(8.0);
let mut te = TextEdit::new(egui::Id::from("send_search"))
.focus(false)
.hint_text("@handle, npub, or name")
.hint_text(t!("goblin.send.search_hint"))
.text_color(t.surface_text)
.body()
.scan_qr();
@@ -405,7 +403,7 @@ impl SendFlow {
if query.is_empty() {
// Empty query → suggested recent peers, as before.
ui.label(
RichText::new(format!("{} Suggested", USERS))
RichText::new(t!("goblin.send.suggested", "icon" => USERS))
.font(fonts::kicker())
.color(t.text_mute),
);
@@ -421,7 +419,7 @@ impl SendFlow {
if peers.is_empty() {
ui.add_space(20.0);
ui.label(
RichText::new("No contacts yet. Find someone by their @handle.")
RichText::new(t!("goblin.send.no_contacts"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
@@ -480,9 +478,16 @@ impl SendFlow {
.show(ui, |ui| {
for (c, tex) in cands.iter().zip(texs.iter()) {
let tag = if c.verified {
format!("{}", c.tag)
// Localize the human-worded provenance tags; domain / protocol
// tags (@goblin.st, nip-05) display verbatim.
let label = match c.tag {
"contact" => t!("goblin.send.tag_contact"),
"on nostr" => t!("goblin.send.tag_on_nostr"),
other => std::borrow::Cow::Borrowed(other),
};
format!("{}", label)
} else {
"no profile".to_string()
t!("goblin.send.no_profile").to_string()
};
if w::activity_row(
ui,
@@ -506,7 +511,7 @@ impl SendFlow {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new("Searching nostr…")
RichText::new(t!("goblin.send.searching_nostr"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
@@ -550,19 +555,15 @@ impl SendFlow {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new("Pay an unverified key?")
RichText::new(t!("goblin.send.unverified_title"))
.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),
RichText::new(t!("goblin.send.unverified_body"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
ui.label(
@@ -579,7 +580,7 @@ impl SendFlow {
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, "Keep looking").clicked() {
if w::big_action_on_card(ui, &t!("goblin.send.keep_looking")).clicked() {
self.confirm_unverified = None;
}
},
@@ -591,7 +592,7 @@ impl SendFlow {
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action(ui, "Pay anyway", false).clicked() {
if w::big_action(ui, &t!("goblin.send.pay_anyway"), false).clicked() {
let mut c = cand.clone();
c.verified = true;
self.confirm_unverified = None;
@@ -605,7 +606,6 @@ impl SendFlow {
/// 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";
let t = theme::tokens();
let result = self.scan.as_mut().and_then(|cam| {
let res = cam.qr_scan_result();
@@ -637,14 +637,14 @@ impl SendFlow {
self.net_candidate = None;
let _ = wallet;
}
_ => self.error = Some(NO_RECIPIENT.to_string()),
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
}
return;
}
ui.add_space(14.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Position a goblin code in view to activate")
RichText::new(t!("goblin.send.scan_prompt"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
@@ -676,7 +676,7 @@ impl SendFlow {
);
ui.add_space(2.0);
ui.label(
RichText::new("Scan to pay me")
RichText::new(t!("goblin.send.scan_to_pay_me"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
@@ -688,7 +688,7 @@ impl SendFlow {
});
ui.add_space(12.0);
if w::big_action(ui, &format!("{} Share", SHARE), false).clicked() {
if w::big_action(ui, &t!("goblin.send.share_btn", "icon" => SHARE), false).clicked() {
// Share the full nostr identity (npub + relay hints), with the bare
// npub as a fallback line, via the platform's native share sheet.
let link = if nprofile.is_empty() {
@@ -696,7 +696,13 @@ impl SendFlow {
} else {
format!("nostr:{}", nprofile)
};
let msg = format!("Pay me on Goblin — {}\n{}\nnpub: {}", handle, link, npub);
let msg = t!(
"goblin.send.share_message",
"handle" => handle,
"link" => link,
"npub" => npub
)
.to_string();
cb.share_text(msg);
}
}
@@ -716,7 +722,8 @@ impl SendFlow {
}
LookupResult::NotFound(label) => {
self.net_candidate = None;
self.error = Some(format!("No one found for {label}"));
self.error =
Some(t!("goblin.send.none_found", "label" => label).to_string());
}
}
} else {
@@ -841,7 +848,7 @@ impl SendFlow {
*slot.lock().unwrap() = Some(res);
});
} else {
self.error = Some("Enter an @handle, npub, or name".to_string());
self.error = Some(t!("goblin.send.enter_recipient").to_string());
}
}
@@ -853,7 +860,7 @@ impl SendFlow {
cb: &dyn PlatformCallbacks,
) -> bool {
let t = theme::tokens();
if self.back_header(ui, "Amount") {
if self.back_header(ui, &t!("goblin.send.amount_title")) {
self.stage = Stage::Recipient;
return false;
}
@@ -870,7 +877,7 @@ impl SendFlow {
.unwrap_or(false);
// Recipient chip, centered per the design.
let name_label = format!("To {}", recipient.name);
let name_label = t!("goblin.send.to_name", "name" => recipient.name).to_string();
let name_galley = ui.painter().layout_no_wrap(
name_label.clone(),
FontId::new(14.0, fonts::semibold()),
@@ -924,7 +931,7 @@ impl SendFlow {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("You don't have enough grin")
RichText::new(t!("goblin.send.not_enough"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.neg),
);
@@ -936,7 +943,12 @@ impl SendFlow {
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0);
for v in ["1", "10", "100", "Max"] {
if w::chip_outline(ui, v).clicked() {
let label = if v == "Max" {
t!("goblin.send.max")
} else {
std::borrow::Cow::Borrowed(v)
};
if w::chip_outline(ui, &label).clicked() {
if v == "Max" {
let max = wallet
.get_data()
@@ -957,7 +969,7 @@ impl SendFlow {
w::card(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new("Note")
RichText::new(t!("goblin.send.note_label"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.surface_text_dim),
);
@@ -965,7 +977,7 @@ impl SendFlow {
let note_id = egui::Id::from("send_note");
TextEdit::new(note_id)
.focus(false)
.hint_text("Add a note…")
.hint_text(t!("goblin.send.note_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.note, cb);
@@ -988,7 +1000,7 @@ impl SendFlow {
.map(|a| a > 0)
.unwrap_or(false);
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, "Review", false).clicked() {
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() {
if over {
cb.vibrate_error();
} else {
@@ -1006,14 +1018,12 @@ impl SendFlow {
avatars: &mut AvatarTextures,
) -> bool {
let t = theme::tokens();
if self.back_header(
ui,
if self.request {
"Confirm request"
} else {
"Review"
},
) {
let title = if self.request {
t!("goblin.send.confirm_request")
} else {
t!("goblin.send.review_title")
};
if self.back_header(ui, &title) {
// Requests fix the amount on the Pay tab, so back returns to the
// recipient picker rather than the send-style amount step.
self.stage = if self.request {
@@ -1045,9 +1055,9 @@ impl SendFlow {
ui.set_min_width(ui.available_width());
ui.add_space(8.0);
let label = if self.request {
format!("Requesting from {}", recipient.name)
t!("goblin.send.requesting_from", "name" => recipient.name).to_string()
} else {
format!("You're sending {}", recipient.name)
t!("goblin.send.youre_sending", "name" => recipient.name).to_string()
};
// Centered avatar + caption. A long counterparty (a bare npub) wraps
// and stays centered instead of overflowing the card.
@@ -1073,34 +1083,59 @@ impl SendFlow {
});
ui.add_space(16.0);
w::info_row(
ui,
if self.request { "From" } else { "To" },
&recipient.name,
);
let from_to = if self.request {
t!("goblin.send.row_from")
} else {
t!("goblin.send.row_to")
};
w::info_row(ui, &from_to, &recipient.name);
if !self.note.trim().is_empty() {
w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim()));
w::info_row(
ui,
&t!("goblin.send.row_note"),
&format!("\u{201C}{}\u{201D}", self.note.trim()),
);
}
if self.request {
w::info_row(ui, "They pay", "Only if they approve");
w::info_row(ui, "Delivery", "NIP-44 encrypted, over Nym");
w::info_row(
ui,
&t!("goblin.send.row_they_pay"),
&t!("goblin.send.row_they_pay_val"),
);
w::info_row(
ui,
&t!("goblin.send.row_delivery"),
&t!("goblin.send.row_delivery_val"),
);
} else {
w::info_row(ui, "Network fee", "Deducted from your balance");
w::info_row(ui, "Privacy", "Mimblewimble + Nym");
w::info_row(ui, "Delivery", "NIP-44 encrypted, over Nym");
w::info_row(
ui,
&t!("goblin.send.row_network_fee"),
&t!("goblin.send.row_network_fee_val"),
);
w::info_row(
ui,
&t!("goblin.send.row_privacy"),
&t!("goblin.send.row_privacy_val"),
);
w::info_row(
ui,
&t!("goblin.send.row_delivery"),
&t!("goblin.send.row_delivery_val"),
);
}
ui.add_space(16.0);
// Requests are not a spend: one tap sends the ask, no hold-to-confirm.
if self.request {
if w::big_action(ui, "Send request", false).clicked() {
if w::big_action(ui, &t!("goblin.send.send_request_btn"), false).clicked() {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("They'll get a request to approve")
RichText::new(t!("goblin.send.request_approve_hint"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
@@ -1111,7 +1146,7 @@ impl SendFlow {
if over {
ui.vertical_centered(|ui| {
ui.label(
RichText::new("You don't have enough grin")
RichText::new(t!("goblin.send.not_enough"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.neg),
);
@@ -1121,7 +1156,7 @@ impl SendFlow {
// Greyed out while over balance; the `&& !over` also refuses the send in
// case the hold widget ignores the disabled state.
ui.add_enabled_ui(!over, |ui| {
if self.hold.ui(ui, "Hold to send") && !over {
if self.hold.ui(ui, &t!("goblin.send.hold_to_send")) && !over {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
@@ -1130,9 +1165,9 @@ impl SendFlow {
ui.vertical_centered(|ui| {
ui.label(
RichText::new(if over {
"Go back and lower the amount"
t!("goblin.send.lower_amount")
} else {
"Press and hold to confirm"
t!("goblin.send.hold_confirm_hint")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
@@ -1183,9 +1218,9 @@ impl SendFlow {
ui.add_space(16.0);
ui.label(
RichText::new(if self.request {
"Requesting…"
t!("goblin.send.requesting")
} else {
"Sending…"
t!("goblin.send.sending")
})
.font(FontId::new(18.0, fonts::semibold()))
.color(t.text),
@@ -1204,11 +1239,8 @@ impl SendFlow {
.recipient
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_else(|| "They".to_string());
self.error = Some(format!(
"{} isn't accepting requests. Ask them to send you grin instead.",
who
));
.unwrap_or_else(|| t!("goblin.send.they").to_string());
self.error = Some(t!("goblin.send.request_blocked", "who" => who).to_string());
self.stage = Stage::Failed;
}
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
@@ -1230,9 +1262,9 @@ impl SendFlow {
ui.add_space(16.0);
ui.label(
RichText::new(if self.request {
"Couldn't request"
t!("goblin.send.failed_request_title")
} else {
"Couldn't send"
t!("goblin.send.failed_send_title")
})
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
@@ -1241,10 +1273,9 @@ impl SendFlow {
ui.label(
RichText::new(self.error.clone().unwrap_or_else(|| {
if self.request {
"We couldn't deliver the request. Ask them to send you grin instead."
.to_string()
t!("goblin.send.failed_request_body").to_string()
} else {
"The payment wasn't delivered. Your grin is safe — try again.".to_string()
t!("goblin.send.failed_send_body").to_string()
}
}))
.font(FontId::new(14.0, fonts::regular()))
@@ -1252,12 +1283,12 @@ impl SendFlow {
);
});
ui.add_space(24.0);
if w::big_action(ui, "Try again", false).clicked() {
if w::big_action(ui, &t!("goblin.send.try_again_btn"), false).clicked() {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
ui.add_space(10.0);
if w::big_action(ui, "Close", true).clicked() {
if w::big_action(ui, &t!("goblin.send.close_btn"), true).clicked() {
done = true;
}
done
@@ -1281,32 +1312,40 @@ impl SendFlow {
);
ui.add_space(24.0);
ui.label(
RichText::new(if self.request { "Requested" } else { "Sent" })
RichText::new(if self.request { t!("goblin.send.success.requested") } else { t!("goblin.send.success.sent") })
.font(FontId::new(34.0, fonts::bold()))
.color(t.accent_ink),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let total_w = ui.available_width();
ui.add_space(total_w / 2.0 - 60.0);
ui.label(
RichText::new(&self.amount)
.font(FontId::new(40.0, fonts::mono_semibold()))
.color(t.accent_ink),
);
ui.label(
RichText::new(w::TSU)
.font(FontId::new(20.0, fonts::medium()))
.color(t.accent_ink),
);
});
// Amount + ツ as one layout job so vertical_centered centers it exactly,
// independent of the number's width (a fixed offset drifts off-center).
let mut job = egui::text::LayoutJob::default();
job.append(
&self.amount,
0.0,
egui::text::TextFormat {
font_id: FontId::new(40.0, fonts::mono_semibold()),
color: t.accent_ink,
..Default::default()
},
);
job.append(
w::TSU,
0.0,
egui::text::TextFormat {
font_id: FontId::new(20.0, fonts::medium()),
color: t.accent_ink,
valign: Align::BOTTOM,
..Default::default()
},
);
ui.label(job);
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"{} {} · just now",
if self.request { "from" } else { "to" },
recipient.name
RichText::new(t!(
"goblin.send.success.subtitle",
"dir" => if self.request { t!("goblin.send.success.from") } else { t!("goblin.send.success.to") },
"who" => recipient.name
))
.font(FontId::new(15.0, fonts::regular()))
.color(t.accent_ink.gamma_multiply(0.7)),
@@ -1330,7 +1369,7 @@ impl SendFlow {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Done",
t!("goblin.send.success.done_btn"),
FontId::new(17.0, fonts::semibold()),
t.accent,
);
@@ -1352,7 +1391,7 @@ impl SendFlow {
ui.painter().text(
r2.center(),
egui::Align2::CENTER_CENTER,
"Receipt",
t!("goblin.send.success.receipt_btn"),
FontId::new(17.0, fonts::semibold()),
t.accent_ink,
);
+15 -12
View File
@@ -335,18 +335,21 @@ fn setup_i18n() {
}
} else {
let locale = sys_locale::get_locale().unwrap_or(String::from(AppConfig::DEFAULT_LOCALE));
let locale_str = if locale.contains("-") {
locale
.split("-")
.next()
.unwrap_or(AppConfig::DEFAULT_LOCALE)
} else {
locale.as_str()
};
// Set best possible locale.
if rust_i18n::available_locales!().contains(&locale_str) {
rust_i18n::set_locale(locale_str);
// sys_locale may hand back either `zh-CN` or `zh_CN`; normalize the
// separator so a region-specific locale can match its file name.
let normalized = locale.replace('_', "-");
let available = rust_i18n::available_locales!();
// Prefer an exact region match (e.g. `zh-CN`, the only CJK locale and one
// the bare-subtag fallback could never reach), then the language subtag
// (e.g. `de` from `de-DE`), else the default.
let primary = normalized
.split('-')
.next()
.unwrap_or(AppConfig::DEFAULT_LOCALE);
if available.contains(&normalized.as_str()) {
rust_i18n::set_locale(normalized.as_str());
} else if available.contains(&primary) {
rust_i18n::set_locale(primary);
} else {
rust_i18n::set_locale(AppConfig::DEFAULT_LOCALE);
}
+144 -1
View File
@@ -444,6 +444,46 @@ impl NostrService {
Ok(res.val.to_hex())
}
/// Dispatch a control DM that voids a pending request (a decline by the payer
/// or a cancel by the requester) to `receiver_hex`, referencing `slate_id`.
/// Same routing as a payment DM, but the message carries no slatepack.
pub async fn send_control_dm(
&self,
receiver_hex: &str,
slate_id: &str,
relay_hints: &[String],
) -> Result<String, String> {
let client = {
let r_client = self.client.read();
r_client.clone().ok_or("nostr client is not running")?
};
let receiver =
PublicKey::from_hex(receiver_hex).map_err(|e| format!("invalid receiver: {e}"))?;
let content = protocol::build_control_content();
let tags = protocol::build_control_tags(slate_id);
let mut urls = self.fetch_dm_relays(&client, &receiver).await;
for r in relay_hints {
if !urls.contains(r) {
urls.push(r.clone());
}
}
for r in self.relays() {
if !urls.contains(&r) {
urls.push(r);
}
}
let res = tokio::time::timeout(
SEND_TIMEOUT,
client.send_private_msg_to(urls, receiver, content, tags),
)
.await
.map_err(|_| "send timeout".to_string())?
.map_err(|e| format!("send failed: {e}"))?;
Ok(res.val.to_hex())
}
/// Fetch a contact's kind 10050 DM relay list from our relays.
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec<String> {
// Use cached relays first.
@@ -497,6 +537,51 @@ impl NostrService {
});
}
}
/// Best-effort: resolve a contact's published `@username`. Incoming messages
/// only carry the sender's key, so a fresh contact shows as a bare npub. This
/// fetches their kind-0 profile and, if it advertises a NIP-05 handle that
/// verifies back to their key, records it so the UI can show `@username`.
/// Spawns a worker; fail-open (any miss just leaves the npub). Skips contacts
/// that already carry a verified handle.
pub fn resolve_contact_identity(self: &Arc<Self>, sender_hex: &str) {
if let Some(c) = self.store.contact(sender_hex) {
if c.nip05.is_some() && c.nip05_verified_at.is_some() {
return;
}
}
let svc = self.clone();
let hex = sender_hex.to_string();
thread::spawn(move || {
let Some(profile) = svc.fetch_profile_blocking(&hex) else {
return;
};
let Some(nip05) = profile.nip05 else {
return;
};
let Some((name, domain)) = nip05.split_once('@') else {
return;
};
let Ok(pk) = PublicKey::from_hex(&hex) else {
return;
};
// Trust the handle only if it maps back to this key.
let verified = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()
.map(|rt| rt.block_on(crate::nostr::nip05::verify(&pk, name, domain)))
.unwrap_or(false);
if !verified {
return;
}
if let Some(mut c) = svc.store.contact(&hex) {
c.nip05 = Some(nip05.clone());
c.nip05_verified_at = Some(unix_time());
svc.store.save_contact(&c);
}
});
}
}
/// Main service loop: connect, publish identity, catch up, listen.
@@ -803,6 +888,52 @@ async fn reconcile(svc: &Arc<NostrService>, wallet: &Wallet) {
}
/// Full guarded pipeline for one incoming gift wrap event.
/// Apply a request-void control message. Two roles, distinguished by what we
/// hold for `slate_id`; in both the `sender` must match the stored counterparty,
/// so an attacker can't void a request they're not party to.
fn handle_request_void(svc: &Arc<NostrService>, wallet: &Wallet, slate_id: &str, sender: &str) {
// Role A — we are the payer and the requester withdrew. Drop the pending card.
let mut voided = false;
for req in svc.store.pending_requests() {
if req.slate_id == slate_id && req.npub == sender {
info!(
"nostr: incoming request {} withdrawn by requester",
req.rumor_id
);
svc.store
.update_request_status(&req.rumor_id, RequestStatus::Cancelled);
svc.has_new_requests.store(true, Ordering::Relaxed);
voided = true;
}
}
if voided {
return;
}
// Role B — we are the requester and the payer declined. Cancel our invoice tx
// and mark the meta cancelled so it leaves the pending state.
if let Some(meta) = svc.store.tx_meta(slate_id) {
if meta.direction == NostrTxDirection::RequestedByUs
&& matches!(
meta.status,
NostrSendStatus::Created | NostrSendStatus::AwaitingI2
) && meta.npub == sender
{
info!("nostr: outgoing request {} declined by payer", slate_id);
if let Some(tx_id) = wallet.get_data().and_then(|d| d.txs).and_then(|txs| {
txs.iter()
.find(|t| {
t.data.tx_slate_id.map(|u| u.to_string()).as_deref() == Some(slate_id)
})
.map(|t| t.data.id)
}) {
wallet.task(WalletTask::Cancel(tx_id));
}
svc.store
.update_tx_status(slate_id, NostrSendStatus::Cancelled);
}
}
}
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client, event: Event) {
// 0. Only gift wraps.
if event.kind != Kind::GiftWrap {
@@ -879,7 +1010,17 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
svc.store.mark_processed(&wrap_id);
return;
}
// 8. Extract the slatepack; non-payment DMs are ignored entirely.
// 8. Request-void control message (a decline by the payer or a cancel by the
// requester): it carries no slatepack, just an action tag naming a slate id.
// Handle it before slatepack extraction; the sender is bound to the stored
// counterparty inside, so a stranger can't void someone else's request.
if let Some(void_slate_id) = protocol::extract_control(&rumor.tags) {
handle_request_void(svc, wallet, &void_slate_id, &sender_hex);
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
return;
}
// 8b. Extract the slatepack; non-payment DMs are ignored entirely.
let Some(armor) = protocol::extract_slatepack(&rumor.content) else {
svc.store.mark_processed(&wrap_id);
svc.store.mark_processed(&rumor_id);
@@ -973,6 +1114,8 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
}
IngestDecision::SurfaceIncoming | IngestDecision::SurfaceRequest => {
svc.ensure_contact(&sender_hex);
// Resolve the requester's @username so the card isn't a bare npub.
svc.resolve_contact_identity(&sender_hex);
svc.store.save_request(&PaymentRequest {
ver: 1,
rumor_id: rumor_id.clone(),
+62
View File
@@ -35,6 +35,12 @@ pub const MAX_NOTE_CHARS: usize = 256;
pub const GOBLIN_TAG: &str = "goblin";
/// Protocol version value.
pub const PROTOCOL_VERSION: &str = "1";
/// Control-message tag name: carries `[action, slate_id]` for a request that is
/// being voided (a decline by the payer or a cancel by the requester).
pub const GOBLIN_ACTION_TAG: &str = "goblin-action";
/// The one control action: void an unpaid request. Decline and cancel are the
/// same wire message — "this request is off" — they only differ by who sends it.
pub const ACTION_VOID: &str = "void";
/// Human readable preamble other NIP-17 clients render.
pub const PREAMBLE: &str =
@@ -76,6 +82,44 @@ pub fn build_rumor_tags(note: Option<&str>) -> Vec<Tag> {
tags
}
/// Build the kind-14 rumor content for a request-void control message. Carries
/// NO slatepack — other NIP-17 clients render the human-readable line.
pub fn build_control_content() -> String {
"[Goblin] Payment request withdrawn — open in Goblin (https://goblin.st).".to_string()
}
/// Build rumor tags for a control message: protocol marker plus the action +
/// slate id the control refers to.
pub fn build_control_tags(slate_id: &str) -> Vec<Tag> {
vec![
Tag::custom(TagKind::custom(GOBLIN_TAG), [PROTOCOL_VERSION.to_string()]),
Tag::custom(
TagKind::custom(GOBLIN_ACTION_TAG),
[ACTION_VOID.to_string(), slate_id.to_string()],
),
]
}
/// Read a control action from rumor tags. Returns the referenced slate id when a
/// well-formed `goblin-action` void tag is present, else `None`. Classification
/// NEVER trusts this for payment processing — it only voids a pending request,
/// and the caller still binds the sender to the stored counterparty.
pub fn extract_control(tags: &Tags) -> Option<String> {
for tag in tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) == Some(GOBLIN_ACTION_TAG) {
let action = parts.get(1).map(|s| s.as_str());
let slate_id = parts.get(2).map(|s| s.to_string());
if action == Some(ACTION_VOID) {
if let Some(id) = slate_id.filter(|s| !s.is_empty()) {
return Some(id);
}
}
}
}
None
}
/// Extract exactly one slatepack armor block from rumor content.
/// More than one block, none at all, or an oversized block returns `None`.
pub fn extract_slatepack(content: &str) -> Option<String> {
@@ -195,4 +239,22 @@ mod tests {
assert!(c.starts_with(PREAMBLE));
assert!(extract_slatepack(&c).is_some());
}
#[test]
fn control_round_trips_slate_id() {
let tags = Tags::from_list(build_control_tags("abc-123"));
assert_eq!(extract_control(&tags), Some("abc-123".to_string()));
// A control message carries no slatepack.
assert!(extract_slatepack(&build_control_content()).is_none());
}
#[test]
fn control_absent_or_malformed_returns_none() {
// Ordinary payment tags have no action tag.
let tags = Tags::from_list(build_rumor_tags(Some("lunch")));
assert!(extract_control(&tags).is_none());
// Action present but empty slate id is rejected.
let bad = Tags::from_list(build_control_tags(""));
assert!(extract_control(&bad).is_none());
}
}
+2
View File
@@ -105,6 +105,8 @@ pub enum RequestStatus {
Approved,
Declined,
Expired,
/// Withdrawn by the requester (we received their cancel control message).
Cancelled,
}
/// An incoming Invoice1 payment request awaiting explicit user approval.
+8
View File
@@ -450,4 +450,12 @@ pub enum WalletTask {
/// Republish our kind-0 profile (e.g. after toggling the incoming-requests
/// preference) so the change propagates to relays immediately.
NostrRepublishProfile,
/// Decline an incoming payment request: mark it declined and send the
/// requester a void control message so their side clears too.
/// * request id (rumor event id hex)
NostrDeclineRequest(String),
/// Cancel a request WE sent: cancel the local invoice tx and send the payer a
/// void control message so the pending card disappears on their side.
/// * slate id (uuid string)
NostrCancelOutgoing(String),
}
+53
View File
@@ -2364,6 +2364,59 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
service.republish_identity().await;
}
}
WalletTask::NostrDeclineRequest(rumor_id) => {
let Some(service) = w.nostr_service() else {
return;
};
let Some(mut request) = service.store.request(rumor_id) else {
error!("nostr decline: request not found");
return;
};
// Mark declined locally (idempotent) so the card stays gone, then tell
// the requester. Requests are messages; payments are final.
request.status = crate::nostr::RequestStatus::Declined;
service.store.save_request(&request);
if let Err(e) = service
.send_control_dm(&request.npub, &request.slate_id, &[])
.await
{
error!("nostr decline: control dispatch failed: {e}");
}
}
WalletTask::NostrCancelOutgoing(slate_id) => {
let Some(service) = w.nostr_service() else {
return;
};
let Some(meta) = service.store.tx_meta(slate_id) else {
error!("nostr cancel: no metadata for slate {slate_id}");
return;
};
if meta.direction != crate::nostr::NostrTxDirection::RequestedByUs {
error!("nostr cancel: slate {slate_id} is not an outgoing request");
return;
}
// Cancel the underlying grin invoice tx (an issued invoice locks no
// outputs, but cancelling keeps the wallet ledger tidy).
if let Some(tx_id) = w.get_data().and_then(|d| d.txs).and_then(|txs| {
txs.iter()
.find(|t| {
t.data.tx_slate_id.map(|u| u.to_string()).as_deref()
== Some(slate_id.as_str())
})
.map(|t| t.data.id)
}) {
if let Err(e) = w.cancel(tx_id) {
error!("nostr cancel: wallet cancel failed: {e}");
}
}
service
.store
.update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled);
if let Err(e) = service.send_control_dm(&meta.npub, slate_id, &[]).await {
error!("nostr cancel: control dispatch failed: {e}");
}
sync_wallet_data(&w, false);
}
WalletTask::NostrResend(id) => {
let Some(service) = w.nostr_service() else {
return;