From d60e71d1e0896cb164a6bba58d8482b02009dc6a Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:50:19 -0400 Subject: [PATCH] onboarding + nostr: healthy default node, claim feedback, identity import, resolve payer name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four field-reported issues from a fresh install + a friend payment: - Default node was grincoin.org, whose foreign API was returning "rpc call failed" — onboarding sync died with an un-retryable error. Lead the node list with the verified-healthy api.grin.money (external.rs) and use it as the onboarding default (was grincoin.org); grincoin.org stays in the list. - Claiming a username gave no feedback and the identity card kept showing the npub. The card now shows the @name + a seal check once claimed, and a clear "name is yours" success card replaces the claim form before Open wallet. - A returning user who restores a seed gets a fresh random nostr key, so their old @name couldn't come back. Offer "Import it" in the identity step: paste an nsec or pick a .backup file (reuses the wallet password just set) to keep the existing key + username. - The requester side of a request never resolved the payer's @username — the FinalizePost ingest arm skipped ensure_contact/resolve_contact_identity, so a completed request showed a bare npub for the payer. Resolve on finalize like every other ingest path. i18n: claimed_title/claimed_blurb + import_existing/import_title/import_blurb across all six locales; drift test green. --- locales/de.yml | 5 + locales/en.yml | 5 + locales/fr.yml | 5 + locales/ru.yml | 5 + locales/tr.yml | 5 + locales/zh-CN.yml | 5 + src/gui/views/goblin/onboarding.rs | 321 +++++++++++++++++++++++++++-- src/nostr/client.rs | 69 ++++--- src/wallet/connections/external.rs | 12 +- 9 files changed, 381 insertions(+), 51 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index c46f849..5cb9d93 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -719,8 +719,13 @@ goblin: claim_username: "Benutzernamen sichern" available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern." youre: "Du bist %{name}" + claimed_title: "%{name} gehört dir" + claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet." open_wallet: "Mein Wallet öffnen" skip_for_now: "Vorerst überspringen" + import_existing: "Schon eine Goblin-Identität? Importieren" + import_title: "Identität importieren" + import_blurb: "Füge deinen nsec ein oder wähle eine .backup-Datei, um deinen vorhandenen Schlüssel und Benutzernamen zu behalten statt diesen neuen." errors: cant_open: "Wallet konnte nicht geöffnet werden: %{err}" cant_create: "Wallet konnte nicht erstellt werden: %{err}" diff --git a/locales/en.yml b/locales/en.yml index 9f67986..125092b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -719,8 +719,13 @@ goblin: claim_username: "Claim username" available_when_connected: "Available once the mixnet connects — or skip and claim later." youre: "You're %{name}" + claimed_title: "%{name} is yours" + claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet." open_wallet: "Open my wallet" skip_for_now: "Skip for now" + import_existing: "Already have a Goblin identity? Import it" + import_title: "Import your identity" + import_blurb: "Paste your nsec or pick a .backup file to keep your existing key and username instead of this new one." errors: cant_open: "Couldn't open the wallet: %{err}" cant_create: "Couldn't create the wallet: %{err}" diff --git a/locales/fr.yml b/locales/fr.yml index 13c1fba..8ab19a4 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -719,8 +719,13 @@ goblin: claim_username: "Réserver le nom d'utilisateur" available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard." youre: "Vous êtes %{name}" + claimed_title: "%{name} est à vous" + claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille." open_wallet: "Ouvrir mon portefeuille" skip_for_now: "Passer pour l'instant" + import_existing: "Vous avez déjà une identité Goblin ? Importez-la" + import_title: "Importer votre identité" + import_blurb: "Collez votre nsec ou choisissez un fichier .backup pour conserver votre clé et votre nom existants au lieu de ce nouveau." errors: cant_open: "Impossible d'ouvrir le portefeuille : %{err}" cant_create: "Impossible de créer le portefeuille : %{err}" diff --git a/locales/ru.yml b/locales/ru.yml index 308498a..3fe83fb 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -719,8 +719,13 @@ goblin: claim_username: "Занять имя" available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже." youre: "Вы %{name}" + claimed_title: "%{name} теперь ваше" + claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк." open_wallet: "Открыть кошелёк" skip_for_now: "Пропустить пока" + import_existing: "Уже есть личность Goblin? Импортируйте её" + import_title: "Импорт личности" + import_blurb: "Вставьте свой nsec или выберите файл .backup, чтобы сохранить существующий ключ и имя вместо нового." errors: cant_open: "Не удалось открыть кошелёк: %{err}" cant_create: "Не удалось создать кошелёк: %{err}" diff --git a/locales/tr.yml b/locales/tr.yml index 4cdeea4..d240925 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -719,8 +719,13 @@ goblin: claim_username: "Kullanıcı adı al" available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al." youre: "Sen %{name}'sin" + claimed_title: "%{name} artık senin" + claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç." open_wallet: "Cüzdanımı aç" skip_for_now: "Şimdilik atla" + import_existing: "Zaten bir Goblin kimliğin var mı? İçe aktar" + import_title: "Kimliğini içe aktar" + import_blurb: "Bu yeni anahtar yerine mevcut anahtarını ve kullanıcı adını korumak için nsec'ini yapıştır veya bir .backup dosyası seç." errors: cant_open: "Cüzdan açılamadı: %{err}" cant_create: "Cüzdan oluşturulamadı: %{err}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 72e298b..aee107f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -719,8 +719,13 @@ goblin: claim_username: "注册用户名" available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。" youre: "你是 %{name}" + claimed_title: "%{name} 已归你所有" + claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。" open_wallet: "打开我的钱包" skip_for_now: "暂时跳过" + import_existing: "已有 Goblin 身份?导入它" + import_title: "导入你的身份" + import_blurb: "粘贴你的 nsec 或选择一个 .backup 文件,保留你现有的密钥和用户名,而不是这个新的。" errors: cant_open: "无法打开钱包:%{err}" cant_create: "无法创建钱包:%{err}" diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index eafa3fa..5216614 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -28,6 +28,7 @@ use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; use crate::gui::views::wallets::creation::MnemonicSetup; use crate::gui::views::{CameraScanContent, Content, Modal, TextEdit, View}; use crate::node::Node; +use crate::nostr::NostrIdentity; use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; @@ -70,6 +71,30 @@ pub struct OnboardingContent { wallet: Option, /// Optional username claim state (same machinery as Settings). claim: ClaimState, + /// Optional "import an existing identity" sub-flow, opened from the identity + /// step so a returning user can keep their old npub + username instead of the + /// freshly-generated random key. + import: Option, +} + +/// Onboarding identity-import state. Reuses the wallet password the user just +/// set, so it only needs the backup file / nsec (and the backup's own password +/// when restoring a sealed `.backup`). +#[derive(Default)] +struct OnbImport { + /// 0 = form, 1 = working, 2 = error. + stage: u8, + /// Pasted nsec or the read-in contents of a `.backup` / identity JSON file. + nsec: String, + /// Password the backup was sealed under (blank for a bare nsec, or when it + /// matches this wallet's password). + backup_password: String, + /// Last import error, shown on stage 2. + error: String, + /// A native file pick is in flight (Android resolves the path asynchronously). + picking: bool, + /// Worker result: Ok(new npub) or Err(message). + result: std::sync::Arc>>>, } impl Default for OnboardingContent { @@ -79,7 +104,7 @@ impl Default for OnboardingContent { // Default to the Instant path (connect to a public node) so a new // user is online immediately, with no chain-sync wait. integrated: false, - ext_url: "https://grincoin.org".to_string(), + ext_url: "https://api.grin.money".to_string(), restore: false, name: "Main wallet".to_string(), pass: String::new(), @@ -89,6 +114,7 @@ impl Default for OnboardingContent { scan_modal: None, wallet: None, claim: ClaimState::default(), + import: None, } } } @@ -705,6 +731,13 @@ impl OnboardingContent { .as_ref() .map(|s| (s.npub(), s.is_connected())) .unwrap_or((String::new(), false)); + // The claimed @name (bare), if any — so the identity card shows the name + // instead of the npub once a username is registered. + let claimed_name = service + .as_ref() + .and_then(|s| s.identity.read().nip05.clone()) + .and_then(|n| n.split('@').next().map(|s| s.to_string())) + .filter(|n| !n.is_empty()); w::card(ui, |ui| { ui.set_min_width(ui.available_width()); @@ -719,18 +752,36 @@ impl OnboardingContent { } ui.add_space(10.0); ui.vertical(|ui| { - let short = if npub.len() > 20 { - format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) - } else if npub.is_empty() { - t!("goblin.onboarding.identity.key_being_made").to_string() + // Once claimed, show the @name (with a check) instead of the npub + // so the user can SEE the username applied. + if let Some(name) = &claimed_name { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.label( + RichText::new(name) + .font(FontId::new(16.0, fonts::bold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(14.0, fonts::regular())) + .color(t.pos), + ); + }); } else { - npub.clone() - }; - ui.label( - RichText::new(short) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); + let short = if npub.len() > 20 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else if npub.is_empty() { + t!("goblin.onboarding.identity.key_being_made").to_string() + } else { + npub.clone() + }; + ui.label( + RichText::new(short) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + } ui.label( RichText::new(if connected { t!("goblin.onboarding.identity.connected_nym") @@ -792,7 +843,10 @@ impl OnboardingContent { .nostr_service() .map(|s| s.identity.read().nip05.is_some()) .unwrap_or(false); - if !registered { + if self.import.is_some() { + // Returning user is swapping the random key for their existing identity. + self.import_ui(ui, &wallet, cb); + } else if !registered { w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.label( @@ -870,9 +924,55 @@ impl OnboardingContent { } } }); + ui.add_space(10.0); + // Returning user? Let them restore their existing identity (nsec or a + // .backup file) instead of claiming a fresh name on the random key. + let import_resp = ui + .add( + egui::Label::new( + RichText::new(t!("goblin.onboarding.identity.import_existing")) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.accent), + ) + .sense(Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if import_resp.clicked() { + self.import = Some(OnbImport::default()); + } ui.add_space(16.0); } else { - ui.add_space(2.0); + // Claimed: show a clear success confirmation so the user knows the + // username stuck before they tap through to the wallet. + let claimed = claimed_name.clone().unwrap_or_default(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(22.0, fonts::regular())) + .color(t.pos), + ); + ui.vertical(|ui| { + ui.label( + RichText::new(t!( + "goblin.onboarding.identity.claimed_title", + name => &claimed + )) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.claimed_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + }); + ui.add_space(16.0); } if !connected { @@ -891,6 +991,199 @@ impl OnboardingContent { None } + /// Onboarding identity-import sub-flow: paste an nsec or pick a `.backup` + /// file to swap the freshly-generated random key for the user's existing + /// identity (keeping their npub and any claimed username). Reuses the wallet + /// password the user just set; a sealed `.backup` may carry its own password. + fn import_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + // Poll the worker first, WITHOUT holding a borrow across the reset below. + if self.import.as_ref().map(|i| i.stage) == Some(1) { + let res = self.import.as_ref().unwrap().result.lock().unwrap().take(); + if let Some(res) = res { + match res { + // Identity replaced: drop the sub-flow; the identity card and the + // claim/success state re-render from the new service next frame. + Ok(_) => { + self.import = None; + return; + } + Err(e) => { + let imp = self.import.as_mut().unwrap(); + imp.error = e; + imp.stage = 2; + } + } + } + } + let wallet_pass = self.pass.clone(); + let imp = self.import.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match imp.stage { + 1 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.settings.importing")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 2 => { + ui.label( + RichText::new(t!("goblin.settings.import_failed")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.onboarding.identity.import_blurb")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + // Native ".backup file" picker. Desktop returns the path now; + // Android resolves it asynchronously (poll picked_file()). + if imp.picking { + if let Some(path) = cb.picked_file() { + imp.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) + .clicked() + { + imp.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(contents) => imp.nsec = contents.trim().to_string(), + Err(_) => { + imp.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + // Empty string = Android async pick in flight. + Some(_) => imp.picking = true, + None => {} + } + } + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_nsec")) + .focus(false) + .hint_text(t!("goblin.settings.import_nsec_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.nsec, cb); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("onb_import_bpw")) + .focus(false) + .hint_text(t!("goblin.settings.backup_password_hint")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut imp.backup_password, cb); + }); + if !imp.error.is_empty() { + ui.add_space(6.0); + ui.label( + RichText::new(&imp.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + let pasted = imp.nsec.trim(); + // Only an nsec paste or a sealed .backup file — nothing else. + let armed = + pasted.starts_with("nsec1") || NostrIdentity::is_encrypted_backup(pasted); + 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, &t!("goblin.settings.cancel")) + .clicked() + { + close = true; + } + }, + ); + 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| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, &t!("goblin.settings.import_btn"), false) + .clicked() + { + imp.stage = 1; + let slot = imp.result.clone(); + let nsec = std::mem::take(&mut imp.nsec); + let bpw = std::mem::take(&mut imp.backup_password); + let bpw = if bpw.is_empty() { None } else { Some(bpw) }; + let wallet = wallet.clone(); + let pass = wallet_pass.clone(); + std::thread::spawn(move || { + let res = wallet.import_nostr_identity(nsec, pass, bpw); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + } + }); + if close { + self.import = None; + } + } + /// Recovery-phrase QR scan modal content. fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) { if let Some(content) = self.scan_modal.as_mut() { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index ef300c2..7abe4b3 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -1363,39 +1363,46 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, svc.store.mark_processed(&rumor_id); svc.store.mark_processed(&slate_marker); } - IngestDecision::FinalizePost => match wallet.nostr_finalize_post(&slate) { - Ok(true) => { - svc.store - .update_tx_status(&slate.id.to_string(), NostrSendStatus::Finalized); - // Finalize+post committed; mark dedup before the sync tail so a - // crash can't re-finalize on catch-up (grin rejects a second - // finalize and the meta is now Finalized, which decide() drops — - // this just avoids the redundant attempt). - svc.store.mark_processed(&wrap_id); - svc.store.mark_processed(&rumor_id); - svc.store.mark_processed(&slate_marker); - if let Some(mut contact) = svc.store.contact(&sender_hex) { - contact.last_paid_at = Some(unix_time()); - svc.store.save_contact(&contact); + IngestDecision::FinalizePost => { + // The payer's reply is our first contact with their key on this side of + // a request we sent — make sure they're a known contact and resolve their + // @username so the completed request shows their name, not a bare npub. + svc.ensure_contact(&sender_hex); + svc.resolve_contact_identity(&sender_hex); + match wallet.nostr_finalize_post(&slate) { + Ok(true) => { + svc.store + .update_tx_status(&slate.id.to_string(), NostrSendStatus::Finalized); + // Finalize+post committed; mark dedup before the sync tail so a + // crash can't re-finalize on catch-up (grin rejects a second + // finalize and the meta is now Finalized, which decide() drops — + // this just avoids the redundant attempt). + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + if let Some(mut contact) = svc.store.contact(&sender_hex) { + contact.last_paid_at = Some(unix_time()); + svc.store.save_contact(&contact); + } + wallet.sync(); + } + Ok(false) => { + // The send was cancelled out-of-band (the meta usually already + // reflects this and decide() drops the S2 before we get here; this + // covers a tx-list cancel that left the meta untouched). Reconcile + // the status and treat the reply as handled — never retry/re-post. + svc.store + .update_tx_status(&slate.id.to_string(), NostrSendStatus::Cancelled); + svc.store.mark_processed(&wrap_id); + svc.store.mark_processed(&rumor_id); + svc.store.mark_processed(&slate_marker); + info!("nostr: skipped finalize of cancelled slate {}", slate.id); + } + Err(e) => { + error!("nostr: finalize failed for slate {}: {:?}", slate.id, e); } - wallet.sync(); } - Ok(false) => { - // The send was cancelled out-of-band (the meta usually already - // reflects this and decide() drops the S2 before we get here; this - // covers a tx-list cancel that left the meta untouched). Reconcile - // the status and treat the reply as handled — never retry/re-post. - svc.store - .update_tx_status(&slate.id.to_string(), NostrSendStatus::Cancelled); - svc.store.mark_processed(&wrap_id); - svc.store.mark_processed(&rumor_id); - svc.store.mark_processed(&slate_marker); - info!("nostr: skipped finalize of cancelled slate {}", slate.id); - } - Err(e) => { - error!("nostr: finalize failed for slate {}: {:?}", slate.id, e); - } - }, + } IngestDecision::Drop(reason) => { info!("nostr: dropped slate {}: {}", slate.id, reason); // A dropped slate is a permanent decision — don't re-evaluate it. diff --git a/src/wallet/connections/external.rs b/src/wallet/connections/external.rs index d3fc797..2b59279 100644 --- a/src/wallet/connections/external.rs +++ b/src/wallet/connections/external.rs @@ -38,16 +38,16 @@ pub struct ExternalConnection { pub available: Option, } -/// Default external node URLs for main network. grincoin.org leads (main.gri.mw -/// has intermittent issues); main.us-ea.st is the Goblin-run node. The rest are -/// independent public nodes (healthy on grin.fail) so a single operator going -/// down never strands the wallet. +/// Default external node URLs for main network. api.grin.money leads (verified +/// healthy; grincoin.org's node was returning "rpc call failed"); main.us-ea.st +/// is the Goblin-run node. The rest are independent public nodes so a single +/// operator going down never strands the wallet. const DEFAULT_MAIN_URLS: [&'static str; 6] = [ - "https://grincoin.org", + "https://api.grin.money", "https://main.us-ea.st", + "https://grincoin.org", "https://main.gri.mw", "https://mainnet.grinffindor.org", - "https://api.grin.money", "https://main.grin.raubritter.org", ];