Build 8: stability fixes, Cash App-style shell, settings, key rotation

Stability (found via GUI testing):
- tor: create arti runtime on a clean thread; lazy TOR_STATE init panicked
  inside tokio contexts and poisoned the whole Tor/nostr stack
- store: open rkv SafeMode envs with capacity headroom; reopening at
  exactly DEFAULT_MAX_DBS crashed every wallet restart (DbsFull)
- goblin ui: centered_column hands children a full-height rect; ScrollAreas
  inside clipped everything below the first widget
- build: webtunnel client was silently embedded as 0 bytes without Go;
  warn at build, extract with create_dir_all + exec bit at runtime
- price: CoinGecko requires a User-Agent (403 otherwise); add retry-once
  backoff and a parse-failure diagnostic
- tor: retry http_request up to 3x on fresh isolated circuits

UX overhaul (per owner direction + Cash App references):
- floating icon-only 3-tab pill: Wallet / center accent ツ Pay puck /
  Activity (requests badge kept); Me opens via header avatar
- Pay tab: amount-first surface (numpad on mobile, typed on desktop) with
  Request and Pay; Pay carries the amount straight to Review
- Request flips Receive into "Requesting Nツ" state with a clear chip
- full-bleed goblin surface: GRIM title panel and network column hidden
  while a wallet is open; node status card lives in the sidebar above the
  profile chip; Lock wallet row added (Settings -> Wallet)
- goblin branding: titlebar, wallet-list logo + GOBLIN, new mark assets
  (white master, theme-tinted) in wordmark, QR center, wallet list
- build-based versioning: Build N = commits since the GRIM fork base,
  emitted by build.rs; About leads with it, Third party credits GRIM,
  grin node, nostr-sdk, arti, egui and the implemented NIPs

Accessibility & settings:
- surface_text{,_dim,_mute} tokens: yellow theme has dark cards on a
  bright bg; all on-surface text now readable in every theme (incl. QR)
- settings rows clickable across the whole row; profile card fills width;
  density option removed (comfy fixed)
- editable Node connections (integrated/external, add/remove) and Relays
  (add/remove + live service restart); NIPs explainer page with goblin.st
  context; third-party rows link to upstream projects
- standardized npub truncation (head 12 ... tail 6) shown in profile

Identity (owner decision: drop NIP-06 seed binding):
- random standalone nsec (Keys::generate); seed proves nothing about the
  identity and cannot resurrect it; legacy Derived identities still unlock
- key rotation: double warning (pending payments disrupted), typed RESET +
  wallet password, fresh random key, username moved atomically via the
  name server transfer endpoint; aborts cleanly if the move fails
- encrypted identity backup export (NIP-49 ncryptsec JSON, includes
  username + history) and import accepting nsec or backup JSON
- nip05d: POST /api/v1/transfer (NIP-98 by current owner, atomic
  owner-guarded swap, one-name-per-pubkey enforced) + SQL invariant tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-10 23:04:40 -04:00
parent 906fee9c71
commit 908df117e6
20 changed files with 2033 additions and 292 deletions
+33 -2
View File
@@ -2,9 +2,29 @@ use std::path::PathBuf;
use std::process::Command;
use std::{env, fs};
/// The GRIM commit Goblin forked from; builds count commits on top of it.
const GOBLIN_FORK_BASE: &str = "b51a46b";
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
// Goblin versioning is build-based: Build N = commits since the fork.
let build = Command::new("git")
.args([
"rev-list",
"--count",
&format!("{}..HEAD", GOBLIN_FORK_BASE),
])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "dev".to_string());
println!("cargo:rustc-env=GOBLIN_BUILD={}", build);
println!("cargo:rerun-if-changed=.git/HEAD");
// Setting up git hooks in the project: rustfmt and so on.
let git_hooks = format!(
"git config core.hooksPath {}",
@@ -82,14 +102,14 @@ fn main() {
Command::new("./scripts/webtunnel.bat")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.arg(&webtunnel_file)
.output()
} else {
Command::new("bash")
.arg("./scripts/webtunnel.sh")
.arg(go_os)
.arg(go_arch)
.arg(webtunnel_file)
.arg(&webtunnel_file)
.output()
};
if let Ok(out) = output {
@@ -97,5 +117,16 @@ fn main() {
panic!("webtunnel go build failed:\n{:?}", out);
}
}
// The build script exits 0 when Go is absent, leaving the placeholder
// empty — surface that loudly instead of shipping broken bridges.
let still_empty = fs::metadata(&webtunnel_file)
.map(|m| m.len() == 0)
.unwrap_or(true);
if still_empty {
println!(
"cargo:warning=webtunnel client was not built (is Go installed?) — \
Tor webtunnel bridges will not work at runtime"
);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
@@ -291,7 +291,7 @@ impl<Platform: PlatformCallbacks> App<Platform> {
}
// Paint the title.
let title_text = format!("Grim {}", crate::VERSION);
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
painter.text(
title_rect.center(),
egui::Align2::CENTER_CENTER,
+15
View File
@@ -54,6 +54,12 @@ pub struct ThemeTokens {
pub text: Color32,
pub text_dim: Color32,
pub text_mute: Color32,
/// Text on surface/surface2 fills. Matches `text` in light/dark, but the
/// yellow theme has dark surfaces on a bright bg, so on-surface text must
/// be light there while `text` stays dark for the bg.
pub surface_text: Color32,
pub surface_text_dim: Color32,
pub surface_text_mute: Color32,
pub line: Color32,
pub accent: Color32,
pub accent_dark: Color32,
@@ -86,6 +92,9 @@ pub const LIGHT: ThemeTokens = ThemeTokens {
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63),
surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B),
// rgba(14,14,12,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(1, 1, 1, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
@@ -106,6 +115,9 @@ pub const DARK: ThemeTokens = ThemeTokens {
text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(255,255,255,0.08) premultiplied.
line: Color32::from_rgba_premultiplied(20, 20, 20, 20),
accent: Color32::from_rgb(0xFF, 0xD6, 0x0A),
@@ -126,6 +138,9 @@ pub const YELLOW: ThemeTokens = ThemeTokens {
text: Color32::from_rgb(0x0E, 0x0E, 0x0C),
text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36),
text_mute: Color32::from_rgb(0x6B, 0x6A, 0x63),
surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7),
surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F),
surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58),
// rgba(14,14,12,0.18) premultiplied.
line: Color32::from_rgba_premultiplied(2, 2, 2, 46),
accent: Color32::from_rgb(0x0E, 0x0E, 0x0C),
+4 -1
View File
@@ -94,6 +94,9 @@ impl ContentContainer for Content {
if self.network.showing_settings() {
panel_width = ui.available_width();
}
// The open-wallet (Goblin) surface is full-bleed: node info lives in
// its sidebar, so the network column stays hidden while it shows.
let wallet_open = self.wallets.showing_wallet();
// Show network content.
egui::SidePanel::left("network_panel")
@@ -102,7 +105,7 @@ impl ContentContainer for Content {
.frame(egui::Frame {
..Default::default()
})
.show_animated_inside(ui, is_panel_open, |ui| {
.show_animated_inside(ui, is_panel_open && !wallet_open, |ui| {
self.network.ui(ui, cb);
});
+3 -2
View File
@@ -68,8 +68,9 @@ pub fn short_npub(hex: &str) -> String {
use nostr_sdk::{PublicKey, ToBech32};
if let Ok(pk) = PublicKey::from_hex(hex) {
if let Ok(npub) = pk.to_bech32() {
if npub.len() > 16 {
return format!("{}{}", &npub[..9], &npub[npub.len() - 4..]);
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
if npub.len() > 18 {
return format!("{}{}", &npub[..12], &npub[npub.len() - 6..]);
}
return npub;
}
+1392 -154
View File
File diff suppressed because it is too large Load Diff
+83 -69
View File
@@ -90,6 +90,13 @@ impl SendFlow {
self.stage = Stage::Amount;
}
/// Pre-fill the amount (Pay tab, amount-first): the flow starts at the
/// recipient picker and jumps straight to review once one is resolved.
pub fn prefill_amount(&mut self, amount: String) {
self.amount = amount;
self.stage = Stage::Recipient;
}
/// Render the flow. Returns true when the flow is finished (close it).
pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool {
let t = theme::tokens();
@@ -162,28 +169,35 @@ impl SendFlow {
return true;
}
// Search field.
// Search field: filled rounded box per the design.
let mut search = self.search.clone();
ui.horizontal(|ui| {
ui.label(
RichText::new(MAGNIFYING_GLASS)
.font(FontId::new(18.0, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(8.0);
let edit = egui::TextEdit::singleline(&mut search)
.hint_text("@handle, npub, or name")
.desired_width(ui.available_width())
.frame(false);
ui.add(edit);
egui::Frame {
fill: t.surface2,
corner_radius: eframe::epaint::CornerRadius::same(14),
inner_margin: egui::Margin::symmetric(14, 13),
..Default::default()
}
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(MAGNIFYING_GLASS)
.font(FontId::new(18.0, fonts::regular()))
.color(t.surface_text_mute),
);
ui.add_space(8.0);
let edit = egui::TextEdit::singleline(&mut search)
.hint_text("@handle, npub, or name")
.desired_width(ui.available_width())
.text_color(t.surface_text)
.frame(false);
ui.add(edit);
});
});
if search != self.search {
self.search = search;
self.error = None;
}
ui.add_space(6.0);
View::horizontal_line(ui, t.line);
ui.add_space(12.0);
ui.add_space(14.0);
// Poll the async resolution result (set by a worker thread).
self.poll_resolution(wallet);
@@ -316,7 +330,12 @@ impl SendFlow {
npub: hex.to_string(),
hue,
});
self.stage = Stage::Amount;
// Amount-first (Pay tab): the amount is already set, go straight
// to review; otherwise continue to amount entry.
let preset = amount_from_hr_string(&self.amount)
.map(|a| a > 0)
.unwrap_or(false);
self.stage = if preset { Stage::Review } else { Stage::Amount };
}
fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
@@ -327,17 +346,23 @@ impl SendFlow {
}
let recipient = self.recipient.clone().unwrap();
// Recipient chip.
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
w::avatar(ui, &recipient.name, 28.0, recipient.hue);
ui.add_space(8.0);
ui.label(
RichText::new(format!("To {}", recipient.name))
.font(FontId::new(14.0, fonts::semibold()))
.color(t.text),
);
});
// Recipient chip, centered per the design.
let name_label = format!("To {}", recipient.name);
let name_galley = ui.painter().layout_no_wrap(
name_label.clone(),
FontId::new(14.0, fonts::semibold()),
t.text,
);
let chip_w = 28.0 + 8.0 + name_galley.size().x;
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - chip_w) / 2.0).max(0.0));
w::avatar(ui, &recipient.name, 28.0, recipient.hue);
ui.add_space(8.0);
ui.label(
RichText::new(name_label)
.font(FontId::new(14.0, fonts::semibold()))
.color(t.text),
);
});
ui.add_space(20.0);
@@ -347,26 +372,26 @@ impl SendFlow {
} else {
self.amount.clone()
};
ui.vertical_centered(|ui| {
w::amount_text(ui, &display, 64.0);
if let Some(rate) = crate::http::grin_usd_rate() {
if let Ok(grin) = display.parse::<f64>() {
ui.add_space(6.0);
w::amount_text_centered(ui, &display, 64.0);
if let Some(rate) = crate::http::grin_usd_rate() {
if let Ok(grin) = display.parse::<f64>() {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(format!("≈ ${:.2}", grin * rate))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
}
});
}
});
}
ui.add_space(16.0);
// Quick chips.
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(ui, v, false).clicked() {
if w::chip_outline(ui, v).clicked() {
if v == "Max" {
let max = wallet
.get_data()
@@ -389,12 +414,13 @@ impl SendFlow {
ui.label(
RichText::new("Note")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
.color(t.surface_text_dim),
);
ui.add_space(8.0);
let edit = egui::TextEdit::singleline(&mut self.note)
.hint_text("Add a note…")
.desired_width(ui.available_width())
.text_color(t.surface_text)
.frame(false);
note_focused = ui.add(edit).has_focus();
});
@@ -407,27 +433,7 @@ impl SendFlow {
} else if !note_focused {
// Only consume keystrokes for the amount when the note field is
// not focused, so typing a note doesn't also edit the amount.
ui.input(|i| {
for ev in &i.events {
if let egui::Event::Text(txt) = ev {
for ch in txt.chars() {
if ch.is_ascii_digit() {
w::apply_key(&mut self.amount, &ch.to_string());
} else if ch == '.' {
w::apply_key(&mut self.amount, ".");
}
}
}
if let egui::Event::Key {
key: egui::Key::Backspace,
pressed: true,
..
} = ev
{
w::apply_key(&mut self.amount, "<");
}
}
});
w::amount_typed_input(ui, &mut self.amount);
}
ui.add_space(8.0);
@@ -452,19 +458,27 @@ impl SendFlow {
let amount = self.amount.clone();
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
w::avatar(ui, &recipient.name, 36.0, recipient.hue);
ui.add_space(8.0);
ui.label(
RichText::new(format!("You're sending {}", recipient.name))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(8.0);
let label = format!("You're sending {}", recipient.name);
let galley = ui.painter().layout_no_wrap(
label.clone(),
FontId::new(14.0, fonts::regular()),
t.text_dim,
);
let row_w = 36.0 + 8.0 + galley.size().x;
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - row_w) / 2.0).max(0.0));
w::avatar(ui, &recipient.name, 36.0, recipient.hue);
ui.add_space(8.0);
w::amount_text(ui, &amount, 48.0);
ui.label(
RichText::new(label)
.font(FontId::new(14.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(8.0);
w::amount_text_centered_ink(ui, &amount, 48.0, t.surface_text, t.surface_text_dim);
ui.add_space(8.0);
});
ui.add_space(16.0);
+181 -20
View File
@@ -47,14 +47,15 @@ pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
resp
}
/// Draw a balance/amount: big mono number + smaller ツ mark, tight.
/// Draw a balance/amount: big bold number + smaller ツ mark, tight.
/// Geist (sans) per the design; mono is reserved for kernel/block ids.
pub fn amount_text(ui: &mut Ui, value: &str, size: f32) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(value)
.font(FontId::new(size, fonts::mono_semibold()))
.font(FontId::new(size, fonts::bold()))
.color(t.text),
);
ui.add_space(1.0);
@@ -66,6 +67,46 @@ pub fn amount_text(ui: &mut Ui, value: &str, size: f32) {
});
}
/// Like [`amount_text`] but centered in the available width.
pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) {
let t = theme::tokens();
amount_text_centered_ink(ui, value, size, t.text, t.text_dim);
}
/// Centered amount with explicit inks, for drawing on card surfaces.
pub fn amount_text_centered_ink(
ui: &mut Ui,
value: &str,
size: f32,
num_ink: Color32,
mark_ink: Color32,
) {
let num =
ui.painter()
.layout_no_wrap(value.to_string(), FontId::new(size, fonts::bold()), num_ink);
let mark = ui.painter().layout_no_wrap(
TSU.to_string(),
FontId::new(size * 0.4, fonts::medium()),
mark_ink,
);
let total = num.size().x + 1.0 + mark.size().x;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(((ui.available_width() - total) / 2.0).max(0.0));
ui.label(
RichText::new(value)
.font(FontId::new(size, fonts::bold()))
.color(num_ink),
);
ui.add_space(1.0);
ui.label(
RichText::new(TSU)
.font(FontId::new(size * 0.4, fonts::medium()))
.color(mark_ink),
);
});
}
/// An uppercase letterspaced kicker label.
pub fn kicker(ui: &mut Ui, text: &str) {
let t = theme::tokens();
@@ -108,13 +149,37 @@ pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
resp
}
/// Secondary big action drawn on a card surface: same shape as
/// [`big_action`], but the label uses on-surface text so it stays readable
/// on the yellow theme's dark cards.
pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response {
let t = theme::tokens();
let desired = Vec2::new(ui.available_width(), 56.0);
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
ui.painter().rect(
rect,
CornerRadius::same(14),
Color32::TRANSPARENT,
Stroke::new(1.5, t.line),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
t.surface_text,
);
resp
}
/// A pill/chip; returns the click response. `active` paints it inverted.
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
let t = theme::tokens();
let galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::new(13.0, fonts::semibold()),
if active { t.bg } else { t.text },
if active { t.bg } else { t.surface_text },
);
let pad = Vec2::new(14.0, 8.0);
let size = galley.size() + pad * 2.0;
@@ -130,11 +195,75 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
ui.painter().galley(
rect.center() - galley.size() / 2.0,
galley,
if active { t.bg } else { t.text },
if active { t.bg } else { t.surface_text },
);
resp
}
/// An outline pill chip (transparent fill, line border) per the design's
/// amount quick-select row.
pub fn chip_outline(ui: &mut Ui, label: &str) -> Response {
let t = theme::tokens();
let galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::new(13.0, fonts::semibold()),
t.text,
);
let pad = Vec2::new(14.0, 8.0);
let size = galley.size() + pad * 2.0;
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
ui.painter().rect(
rect,
CornerRadius::same(255),
Color32::TRANSPARENT,
Stroke::new(1.0, t.line),
egui::StrokeKind::Inside,
);
ui.painter()
.galley(rect.center() - galley.size() / 2.0, galley, t.text);
resp
}
/// Paint a QR code for `text` in theme ink with the goblin mark centered,
/// per the design's receive card. Encoding a short URI is microseconds, so
/// this is done synchronously each frame; modules are plain painter rects.
pub fn qr_code(ui: &mut Ui, text: &str, size: f32) {
let t = theme::tokens();
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
// High error correction tolerates the center mark covering modules.
let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else {
return;
};
let n = qr.size();
let cell = size / n as f32;
let radius = (cell * 0.3).max(1.0) as u8;
for y in 0..n {
for x in 0..n {
if qr.get_module(x, y) {
let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell);
ui.painter().rect_filled(
egui::Rect::from_min_size(min, Vec2::splat(cell - 0.5)),
CornerRadius::same(radius),
t.surface_text,
);
}
}
}
// Goblin mark on a surface backing square in the center.
let backing = size * 0.26;
let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing));
ui.painter().rect_filled(
b_rect,
CornerRadius::same((backing * 0.18) as u8),
t.surface,
);
let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72));
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png"))
.tint(t.surface_text)
.fit_to_exact_size(m_rect.size())
.paint_at(ui, m_rect);
}
/// A balance hero block: kicker, big number + ツ, optional fiat line.
pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
let t = theme::tokens();
@@ -295,14 +424,15 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
send = resp_s.clicked();
ui.add_space(10.0);
let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
let r_fill = if resp_r.hovered() {
t.hover
} else {
t.surface2
};
ui.painter().rect(
rr,
CornerRadius::same(14),
if resp_r.hovered() {
t.hover
} else {
t.surface2
},
r_fill,
Stroke::NONE,
egui::StrokeKind::Inside,
);
@@ -311,7 +441,7 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
egui::Align2::CENTER_CENTER,
format!("{} Receive", crate::gui::icons::ARROW_DOWN),
FontId::new(16.0, fonts::semibold()),
t.text,
theme::ink_for(r_fill),
);
receive = resp_r.clicked();
});
@@ -360,6 +490,32 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
}
/// Apply a numpad key to the amount string with validation.
/// Apply typed keyboard events (digits, '.', backspace) to an amount string,
/// for desktop where the on-screen numpad is hidden.
pub fn amount_typed_input(ui: &Ui, amount: &mut String) {
ui.input(|i| {
for ev in &i.events {
if let egui::Event::Text(txt) = ev {
for ch in txt.chars() {
if ch.is_ascii_digit() {
apply_key(amount, &ch.to_string());
} else if ch == '.' {
apply_key(amount, ".");
}
}
}
if let egui::Event::Key {
key: egui::Key::Backspace,
pressed: true,
..
} = ev
{
apply_key(amount, "<");
}
}
});
}
pub fn apply_key(amount: &mut String, key: &str) {
match key {
"<" => {
@@ -396,19 +552,24 @@ pub fn fill_bg(ui: &Ui, color: Color32) {
}
/// Center a fixed-width column for narrow content on wide screens.
/// Hands the child the full remaining height: wrapping in `horizontal()`
/// would start the row a single line tall, so a `ScrollArea` inside would
/// clip everything below the first widget.
pub fn centered_column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
let avail = ui.available_width();
let w = width.min(avail);
let margin = ((avail - w) / 2.0).max(0.0);
let mut result = None;
ui.horizontal(|ui| {
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(w);
result = Some(add(ui));
});
});
result.unwrap()
let mut rect = ui.available_rect_before_wrap();
rect.min.x += margin;
rect.max.x = rect.min.x + w;
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(Layout::top_down(Align::Min)),
);
let result = add(&mut child);
ui.allocate_rect(child.min_rect(), Sense::hover());
result
}
/// Hold-to-send button: fills over `hold_secs`; returns true once on completion.
@@ -468,7 +629,7 @@ impl HoldToSend {
if self.progress > 0.5 {
t.accent_ink
} else {
t.text
theme::ink_for(t.surface2)
},
);
if self.progress >= 1.0 {
+7 -10
View File
@@ -26,7 +26,6 @@ use egui_extras::image::load_svg_bytes_with_size;
use lazy_static::lazy_static;
use std::sync::atomic::{AtomicI32, Ordering};
use crate::AppConfig;
use crate::gui::Colors;
use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE};
use crate::gui::views::types::LinePosition;
@@ -694,27 +693,25 @@ impl View {
/// Draw application logo image with name and version.
pub fn app_logo_name_version(ui: &mut egui::Ui) {
ui.add_space(-1.0);
let logo = if AppConfig::dark_theme().unwrap_or(false) {
egui::include_image!("../../../img/logo_light.png")
} else {
egui::include_image!("../../../img/logo.png")
};
// Goblin mark (white master, tinted for the current theme).
let logo = egui::include_image!("../../../img/goblin-logo2-256.png");
// Show application logo and name.
ui.scope(|ui| {
ui.set_opacity(0.9);
egui::Image::new(logo)
.fit_to_exact_size(egui::vec2(182.0, 182.0))
.tint(Colors::white_or_black(true))
.fit_to_exact_size(egui::vec2(150.0, 150.0))
.ui(ui);
});
ui.add_space(-11.0);
ui.add_space(8.0);
ui.label(
RichText::new("GRIM")
RichText::new("GOBLIN")
.size(24.0)
.color(Colors::white_or_black(true)),
);
ui.add_space(-2.0);
ui.label(
RichText::new(crate::VERSION)
RichText::new(format!("Build {}", crate::BUILD))
.size(16.0)
.color(Colors::title(false)),
);
+5 -2
View File
@@ -201,8 +201,11 @@ impl ContentContainer for WalletsContent {
|| self.wallets.list().is_empty()
|| (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel()));
// Show title panel.
self.title_ui(ui, dual_panel, cb);
// Show title panel, except over the full-bleed Goblin wallet surface
// (it has its own header/navigation; lock + node info live there).
if !showing_wallet {
self.title_ui(ui, dual_panel, cb);
}
egui::SidePanel::right("wallet_panel")
.resizable(false)
+24 -3
View File
@@ -29,8 +29,13 @@ lazy_static! {
static ref RATE: RwLock<Option<(f64, i64)>> = RwLock::new(None);
static ref FETCHING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static ref LAST_TRY: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
}
/// Minimum delay between fetch attempts, so a failing fetch (e.g. Tor still
/// bootstrapping) does not respawn a thread every frame.
const RETRY_SECS: i64 = 30;
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -58,9 +63,14 @@ pub fn grin_usd_rate() -> Option<f64> {
/// Spawn a background refresh over Tor (deduplicated).
fn trigger_refresh() {
use std::sync::atomic::Ordering;
let t = now();
if t - LAST_TRY.load(Ordering::SeqCst) < RETRY_SECS {
return;
}
if FETCHING.swap(true, Ordering::SeqCst) {
return;
}
LAST_TRY.store(t, Ordering::SeqCst);
std::thread::spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -80,7 +90,18 @@ fn trigger_refresh() {
async fn fetch_rate() -> Option<f64> {
let url =
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string();
let body = Tor::http_request("GET", url, None, vec![]).await?;
let doc: serde_json::Value = serde_json::from_str(&body).ok()?;
doc.get("grin")?.get("usd")?.as_f64()
// CoinGecko rejects requests without a User-Agent (403). A static,
// non-identifying UA is fine over Tor.
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
let body = Tor::http_request("GET", url, None, headers).await?;
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|doc| doc.get("grin")?.get("usd")?.as_f64());
if parsed.is_none() {
log::warn!(
"price: unexpected response from rate API (Tor exit blocked?): {}",
body.chars().take(120).collect::<String>()
);
}
parsed
}
+4
View File
@@ -42,8 +42,12 @@ mod settings;
mod tor;
mod wallet;
/// Upstream GRIM version the fork is based on (third-party credit).
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Goblin build number: commits on top of the GRIM base (see build.rs).
pub const BUILD: &str = env!("GOBLIN_BUILD");
/// Android platform entry point.
#[allow(dead_code)]
#[cfg(target_os = "android")]
+29 -1
View File
@@ -26,10 +26,14 @@ use std::path::PathBuf;
/// Where the keys came from.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum IdentitySource {
/// NIP-06 derivation from the wallet BIP-39 mnemonic.
/// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the
/// identity to the seed forever; superseded by `Random`).
Derived,
/// Imported nsec.
Imported,
/// Freshly generated random key, independent of the wallet seed: the
/// seed proves nothing about the identity and cannot resurrect it.
Random,
}
/// Identity file stored at `wallet_data/nostr/identity.json`.
@@ -47,6 +51,9 @@ pub struct NostrIdentity {
pub nip05: Option<String>,
/// User chose to stay anonymous (no NIP-05, no kind-0 metadata).
pub anonymous: bool,
/// Previous npubs from key rotations (newest last), for reference.
#[serde(default)]
pub prev_npubs: Vec<String>,
}
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
@@ -152,6 +159,13 @@ impl NostrIdentity {
Ok((identity, keys))
}
/// Create a brand-new random identity, independent of the wallet seed.
pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
let keys = Keys::generate();
let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?;
Ok((identity, keys))
}
/// Create an imported identity from an nsec string.
pub fn create_imported(
nsec: &str,
@@ -192,6 +206,7 @@ impl NostrIdentity {
npub,
nip05: None,
anonymous: true,
prev_npubs: Vec::new(),
})
}
@@ -222,6 +237,19 @@ impl NostrIdentity {
mod tests {
use super::*;
#[test]
fn random_identities_are_unlinked_and_unlock() {
let (a, ka) = NostrIdentity::create_random("pw-1").unwrap();
let (b, _) = NostrIdentity::create_random("pw-1").unwrap();
// Fresh entropy every time: no chain between identities.
assert_ne!(a.npub, b.npub);
assert_eq!(a.source, IdentitySource::Random);
// NIP-49 roundtrip with the right password; wrong one fails.
let unlocked = a.unlock("pw-1").unwrap();
assert_eq!(unlocked.public_key(), ka.public_key());
assert!(a.unlock("wrong").is_err());
}
// NIP-06 test vector: this mnemonic must derive this npub (account 0).
const NIP06_MNEMONIC: &str =
"leader monkey parrot ring guide accident before fence cannon height naive bean";
+42
View File
@@ -187,6 +187,48 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
}
}
/// Atomically move an owned name to a new pubkey (key rotation).
/// Signed by the OLD key; the server enforces ownership and the
/// one-name-per-pubkey rule for the target key.
pub async fn transfer(
server: &str,
name: &str,
old_keys: &Keys,
new_pubkey_hex: &str,
) -> Result<(), String> {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/transfer", server);
let body = serde_json::json!({
"name": name.to_lowercase(),
"new_pubkey": new_pubkey_hex,
})
.to_string();
let Some(auth) = nip98_auth(old_keys, &url, "POST", Some(body.as_bytes())) else {
return Err("auth event build failed".to_string());
};
let headers = vec![
("Authorization".to_string(), auth),
("Content-Type".to_string(), "application/json".to_string()),
];
let Some(resp) = Tor::http_request("POST", url, Some(body), headers).await else {
return Err("network unavailable".to_string());
};
if resp.contains("\"transferred\":true") {
return Ok(());
}
let err = serde_json::from_str::<Value>(&resp)
.ok()
.and_then(|d| d.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| {
if resp.trim().is_empty() {
"name server does not support transfer yet".to_string()
} else {
format!("unexpected response: {}", resp)
}
});
Err(err)
}
/// Release a registered name (NIP-98 authed by the owner).
pub async fn unregister(server: &str, name: &str, keys: &Keys) -> bool {
let server = server.trim_end_matches('/');
+6 -1
View File
@@ -49,8 +49,13 @@ impl NostrStore {
pub fn new(dir: PathBuf) -> Self {
let _ = fs::create_dir_all(&dir);
let mut manager = Manager::<SafeModeEnvironment>::singleton().write().unwrap();
// Open with headroom above the 5 stores below: rkv's SafeMode checks
// capacity before existence, so reopening an env that already holds
// `DEFAULT_MAX_DBS` (5) named dbs fails with DbsFull.
let created_arc = manager
.get_or_create(dir.as_path(), Rkv::new::<SafeMode>)
.get_or_create(dir.as_path(), |p: &std::path::Path| {
Rkv::with_capacity::<SafeMode>(p, 16)
})
.unwrap();
let env = created_arc.clone();
let k = created_arc.read().unwrap();
+58 -5
View File
@@ -90,15 +90,47 @@ pub struct Tor {
impl Default for Tor {
fn default() -> Self {
// Extract webtunnel bridge binary.
if !fs::exists(TorConfig::webtunnel_path()).unwrap_or(true) {
// Extract the bundled webtunnel bridge client when missing or empty
// (an empty file means the app was built without Go — see build.rs).
let wt_path = TorConfig::webtunnel_path();
let wt_missing = fs::metadata(&wt_path).map(|m| m.len() == 0).unwrap_or(true);
if wt_missing {
let webtunnel = include_bytes!(concat!(env!("OUT_DIR"), "/tor/webtunnel"));
if !webtunnel.is_empty() {
fs::write(TorConfig::webtunnel_path(), webtunnel).unwrap_or_default();
if webtunnel.is_empty() {
error!(
"Tor: bundled webtunnel client is empty (built without Go?), \
webtunnel bridges will not work"
);
} else {
if let Some(parent) = std::path::Path::new(&wt_path).parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(&wt_path, webtunnel) {
Ok(_) => {
// The pluggable transport must be executable.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
fs::set_permissions(&wt_path, fs::Permissions::from_mode(0o755));
}
}
Err(e) => error!("Tor: webtunnel client extraction failed: {}", e),
}
}
}
// Create the arti runtime on a clean thread: creation calls block_on,
// which panics (and poisons this lazy static) when the first TOR_STATE
// access happens on a thread already inside a tokio runtime context.
let runtime = std::thread::Builder::new()
.name("tor-runtime-init".to_string())
.spawn(TokioNativeTlsRuntime::create)
.expect("failed to spawn tor runtime init thread")
.join()
.expect("tor runtime init thread panicked")
.unwrap();
Self {
runtime: TokioNativeTlsRuntime::create().unwrap(),
runtime,
client_config: Arc::new(RwLock::new(None)),
client_launching: Arc::new(AtomicBool::new(false)),
run: Arc::new(RwLock::new(BTreeMap::new())),
@@ -368,11 +400,32 @@ impl Tor {
/// Perform an HTTP request over the embedded Tor client with optional
/// headers, returning the response body. Used for NIP-05 and price APIs.
/// Retries with a fresh isolated circuit: single-circuit connect failures
/// right after bootstrap are common and transient.
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
for attempt in 0..3 {
if attempt > 0 {
tokio::time::sleep(Duration::from_millis(1500)).await;
}
let resp =
Self::http_request_once(method, url.clone(), body.clone(), headers.clone()).await;
if resp.is_some() {
return resp;
}
}
None
}
async fn http_request_once(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
// Bind once to avoid a TOCTOU panic if Tor restarts mid-request.
let Some((client_cfg, _)) = Self::client_config() else {
+5 -1
View File
@@ -30,8 +30,12 @@ impl TxHeightStore {
/// Create new transaction height storage from provided directory.
pub fn new(dir: PathBuf) -> Self {
let mut manager = Manager::<SafeModeEnvironment>::singleton().write().unwrap();
// Headroom above the stores below; see NostrStore::new on rkv SafeMode
// rejecting reopens of an env already at capacity.
let created_arc = manager
.get_or_create(dir.as_path(), Rkv::new::<SafeMode>)
.get_or_create(dir.as_path(), |p: &std::path::Path| {
Rkv::with_capacity::<SafeMode>(p, 16)
})
.unwrap();
let env = created_arc.clone();
let k = created_arc.read().unwrap();
+141 -20
View File
@@ -426,31 +426,25 @@ impl Wallet {
return;
}
let nostr_dir = config.get_nostr_path();
// Load existing identity or derive one from the wallet seed (NIP-06).
// Load the existing identity or generate a fresh RANDOM one. The key
// is deliberately independent of the wallet seed: the seed must never
// double as identity evidence, and a rotation must not be derivable
// from it. The nsec backup is therefore the identity backup.
let identity = match NostrIdentity::load(&nostr_dir) {
Some(identity) => identity,
None => {
let phrase = match self.get_recovery(password.to_string()) {
Ok(p) => p,
Err(e) => {
error!("nostr: recovery phrase unavailable: {:?}", e);
return;
}
};
match NostrIdentity::create_derived(&phrase, password, 0) {
Ok((identity, _)) => {
if let Err(e) = identity.save(&nostr_dir) {
error!("nostr: identity save failed: {e}");
return;
}
identity
}
Err(e) => {
error!("nostr: identity derivation failed: {e}");
None => match NostrIdentity::create_random(password) {
Ok((identity, _)) => {
if let Err(e) = identity.save(&nostr_dir) {
error!("nostr: identity save failed: {e}");
return;
}
identity
}
}
Err(e) => {
error!("nostr: identity creation failed: {e}");
return;
}
},
};
let keys = match identity.unlock(password) {
Ok(keys) => keys,
@@ -472,6 +466,133 @@ impl Wallet {
r_nostr.clone()
}
/// Rotate the nostr identity to a brand-new RANDOM key (no derivation
/// chain: a future seed compromise cannot reach it, and the old key
/// shares nothing with it), atomically moving the registered username
/// (if any) to the new key via the name server. Blocking (Tor HTTP):
/// call from a worker thread. Returns the new bech32 npub.
pub fn rotate_nostr_identity(&self, password: String) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
// Snapshot the old identity and prove the password by unlocking it
// (NIP-49 decryption fails on a wrong password).
let old = svc.identity.read().clone();
let old_keys = old
.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
// Generate the replacement identity.
let (mut new_identity, new_keys) = NostrIdentity::create_random(&password)
.map_err(|e| format!("key generation failed: {e}"))?;
// Move the username first; abort the rotation if that fails so the
// user never ends up keyless-but-named or named-but-keyless.
if let Some(nip05) = old.nip05.clone() {
let name = nip05.split('@').next().unwrap_or_default().to_string();
let server = { svc.config.read().nip05_server() };
let new_hex = new_keys.public_key().to_hex();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
crate::nostr::nip05::transfer(&server, &name, &old_keys, &new_hex).await
})
.map_err(|e| format!("Couldn't move @{name}: {e}"))?;
new_identity.nip05 = Some(nip05);
new_identity.anonymous = false;
}
new_identity.prev_npubs = {
let mut v = old.prev_npubs.clone();
v.push(old.npub.clone());
v
};
// Persist, then swap the running service for one bound to the new key.
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
new_identity
.save(&nostr_dir)
.map_err(|e| format!("identity save failed: {e}"))?;
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let wallet_dir = PathBuf::from(config.get_data_path());
let nostr_config = NostrConfig::load(wallet_dir);
let store = NostrStore::new(config.get_nostr_db_path());
let new_npub = new_identity.npub.clone();
let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
}
new_svc.start(self.clone());
info!("nostr: identity rotated to {}", new_npub);
Ok(new_npub)
}
/// Replace the nostr identity with an imported key — either a bare nsec
/// or an exported identity-backup JSON (which restores the username and
/// rotation history too). The current identity is overwritten; its npub
/// is kept in `prev_npubs` for reference. Blocking-safe (no network).
pub fn import_nostr_identity(&self, input: String, password: String) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
// Prove the password against the current identity before replacing.
let old = svc.identity.read().clone();
old.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
let input = input.trim();
let (mut new_identity, new_keys) = if input.starts_with('{') {
// Identity backup JSON: the ncryptsec inside stays as exported;
// it must decrypt with the provided (= export-time) password.
let ident: NostrIdentity =
serde_json::from_str(input).map_err(|_| "Invalid identity backup".to_string())?;
let keys = ident.unlock(&password).map_err(|_| {
"Backup password mismatch (use the wallet password from when \
it was exported)"
.to_string()
})?;
(ident, keys)
} else {
NostrIdentity::create_imported(input, &password)
.map_err(|_| "Invalid nsec".to_string())?
};
if new_identity.npub != old.npub && !new_identity.prev_npubs.contains(&old.npub) {
new_identity.prev_npubs.push(old.npub.clone());
}
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
new_identity
.save(&nostr_dir)
.map_err(|e| format!("identity save failed: {e}"))?;
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let wallet_dir = PathBuf::from(config.get_data_path());
let nostr_config = NostrConfig::load(wallet_dir);
let store = NostrStore::new(config.get_nostr_db_path());
let new_npub = new_identity.npub.clone();
let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
}
new_svc.start(self.clone());
info!("nostr: identity replaced by import: {}", new_npub);
Ok(new_npub)
}
/// Get keychain mask [`SecretKey`].
pub fn keychain_mask(&self) -> Option<SecretKey> {
let r_key = self.keychain_mask.read();