1
0
forked from GRIN/grim

onboarding + nostr: healthy default node, claim feedback, identity import, resolve payer name

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.
This commit is contained in:
2ro
2026-06-16 18:50:19 -04:00
parent 24abc7e7b3
commit d60e71d1e0
9 changed files with 381 additions and 51 deletions
+5
View File
@@ -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}"
+5
View File
@@ -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}"
+5
View File
@@ -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}"
+5
View File
@@ -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}"
+5
View File
@@ -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}"
+5
View File
@@ -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}"
+307 -14
View File
@@ -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<Wallet>,
/// 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<OnbImport>,
}
/// 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<std::sync::Mutex<Option<Result<String, String>>>>,
}
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() {
+38 -31
View File
@@ -1363,39 +1363,46 @@ async fn handle_wrap(svc: &Arc<NostrService>, 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.
+6 -6
View File
@@ -38,16 +38,16 @@ pub struct ExternalConnection {
pub available: Option<bool>,
}
/// 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",
];