Build 37 wrongly forced Grim's on-screen virtual keyboard on Android (.soft_keyboard())
after the emulator's broken IME misled me into thinking the native keyboard was dead.
That brought up a virtual keyboard AND double-typed. Reverted:
- Removed .soft_keyboard() from every field; they now use Grim's TextEdit defaults
(no_soft_keyboard = is_android()) → the user's native keyboard on Android, exactly
like Grim, no virtual keyboard, single input.
- Reverted edit.rs IMEAllowed/on_soft_input to Grim's exact behavior; kept only the
additive display builders (hint_text/text_color/body) the Goblin design needs.
- Manifest: removed windowSoftInputMode and the keyboard/keyboardHidden/navigation
configChanges flags I'd added (Grim has none) so input matches Grim; kept the
uiMode/density flags + isFinishing() guard for the dark-mode crash fix.
The app's input path now diffs from Grim only by the additive display builders.
(The Android emulator's IME is aborted regardless — a known emulator quirk — so the
native keyboard is validated on a real device, where Grim works.)
- Typing (critical): the Goblin UI used plain egui::TextEdit, which never
received input because Grim's native-keyboard path only feeds Grim's own
TextEdit widget. The native IME is also aborted by the windowing system on
this app. Fix: every Goblin field now uses Grim's TextEdit, opted into
Grim's own on-screen keyboard on Android via a new .soft_keyboard() builder
(no native-IME dependency). Additive TextEdit options (hint_text/text_color/
body/soft_keyboard) leave Grim's defaults and existing call sites unchanged.
- Install alongside Grim: FileProvider authority mw.gri.android.fileprovider →
st.goblin.wallet.fileprovider (Android rejects duplicate authorities).
- Status bar: app draws edge-to-edge, so dark icons vanished on the dark theme.
New theme-aware JNI call sets light/dark status-bar icons per theme.
- Edge padding: centered_column now keeps an 18dp side gutter on phones instead
of running content flush to the screen edge.
Validated on the API-36 emulator: typed into fields, padding gutter present.
The center Pay puck rendered ツ through the Noto SC fallback (Geist has no
katakana) — a stiff, geometric glyph. Embed a 1.2 KB subset of Gamja Flower
(OFL) containing only ツ and use it solely at that one widget; its ツ is the
cute winking-smiley shape. Every other ツ (balances, title, activity) is
untouched.
The default webtunnel list bundles wt.gri.mw with public bridges that rot;
bundling them means arti can fixate on a dead/zombie bridge (front up, tunnel
dead) and burn the whole 120s bootstrap timeout — observed live on Android as
'stuck at 15%, had to reset bootstrapping too many times' (48 attempts on a
zombie, 1 on the good bridge). Now the first attempt uses the maintained
default alone; the public bridges stay as fallback in pairs only if it's
unreachable. Verified on the API-36 emulator: Tor reaches 100% over wt.gri.mw,
zero attempts on the dead bridge.
- N-F1: commit the wrap/rumor/slate dedup markers immediately after the
durable receive/finalize, before the reply+sync tail, so a crash there
can't re-trigger the action on catch-up. (grin + decide() already
backstopped it; this closes the window cleanly.)
- N-F3: prune the processed-dedup store hourly in the heartbeat, not only
at startup — a long-lived session could otherwise grow it unbounded
under fresh-keypair spam since the 30-day TTL never re-applied.
- N-F2: kept Created/SendFailed in the finalize allow-set (removing them
would strand a real send whose S1 reached the peer before we persisted
AwaitingS2) and documented why it is not a forgery vector; added a test.
- Update replay_check e2e: a same-pubkey second register is now blocked by
the name-change cooldown (fires before the one-name rule); accept either.
Validated: 35 lib tests + live nip17_slatepack_roundtrip + replay_check green.
- SECURITY (High): drop sort_bridges_by_reachability / bridge_probe_addr. The
Build 30 probe did clearnet DNS + direct TCP to bridge endpoints outside Tor
on every startup, deanonymizing bridge users. The consensus-cache keep + 120s
timeout + pre-warm remain and are the real fix for slow first connect.
- SECURITY (Low): cap HTTP response bodies from the untrusted goblin.st server
at 2 MiB, streamed so a lying/absent Content-Length can't OOM the wallet.
- Accept any NIP-05 domain in the send flow (user@domain resolves and pays;
foreign handles display with their domain and hit the unverified-key gate)
- Share and scan nprofile (npub + relay hints) so a recipient is reachable
with no registry/indexer lookup; hints ride the DM send path
- Receive QR, copy buttons and settings row now emit nostr ID (nprofile)
- Sidebar identity chip truncates long handles on one line (was wrapping)
- Crisper small goblin mark via a 2x raster at chip sizes
- Map the server's name-change cooldown to friendly copy in claim/release
main.gri.mw has intermittent issues, so the default external ("instant") node
is now grincoin.org — both the onboarding first-connect and the head of the
external-node list. Adds the Goblin-run node https://main.us-ea.st as a default
option. (Relays already default to ours, nrelay.us-ea.st, plus relay.damus.io
and nos.lol — no change needed.)
"Keep it" used big_action_on_card (56px / 17px) while "Release it" used
big_action_on_card_ink (44px / 15px), so the two confirm buttons were
visibly different sizes. Both now render with big_action_on_card_ink at the
intended 44px (matching the layout's scope), differing only in text color.
Replace the inherited Grim README with one that reflects what Goblin actually
is: a private, Cash App-style GRIN wallet that pays @usernames via NIP-17
gift-wrapped DMs over Tor, with in-app nostr identity and the goblin.st
identity service. Adds the Goblin banner, a payment-flow diagram, correct
build instructions (binary is `goblin`), and keeps the Claude credit.
From the audit's deferred P2 list:
- Global gift-wrap decrypt ceiling (~120/min across all senders) so
fresh-keypair spam can't force unbounded NIP-44 decrypts. The per-sender
limit only applied after the decrypt revealed the sender; this caps total
decrypt work up front.
- NostrStore reads tolerate a poisoned lock (unwrap_or_else into_inner) so a
single panic can't cascade into taking down all nostr storage for the session.
- Avatar upload/delete reuse the service's keys directly instead of
round-tripping the secret through a plaintext nsec String.
34 lib tests green.
From a security audit of our own nostr/identity code (no P0/P1 found; these
close the P2 hardening gaps):
- NIP-05: only goblin.st identities skip the "pay an unverified key?" gate.
A third-party domain's well-known could point at any key, so those now route
through the same confirm gate as a bare npub.
- NIP-05: validate the domain as a bare hostname before building the
well-known URL — closes a path/host-smuggling (SSRF-over-Tor) vector.
- Avatars: decode server-fed bytes under explicit image Limits (<=1024 px,
8 MiB) so a hostile or breached avatar host can't exhaust memory on the
texture path.
34 lib tests green (incl. new hostname-rejection cases).
- README: credit Claude (Anthropic) for Goblin's development at the bottom,
in place of per-commit co-authorship.
- Onboarding: replace the contradictory "Pay people, not addresses" card with
"Send like a message" and reword the body in plain language (no more
"plumbing"/"ciphertext"). The identity step now says you can rotate your key
anytime to maintain your privacy.
- Home: center the balance hero so it lines up with the Pay amount and the
empty-state below it.
- Lower-left sidebar cards are now individual shortcuts: tapping the
identity chip opens identity settings, tapping the node card jumps
straight to the Node menu (was: both opened generic settings).
- Pay screen gains a scan-to-pay QR puck top-right that opens the camera
and prefills the recipient while keeping the typed amount (reuses the
Home scan + SendFlow::request_scan/prefill_amount path).
- Replace the USD-only "fiat preview" with a configurable "Pairing":
Off / USD / EUR / GBP / JPY / CNY / Bitcoin / Sats (default USD). price.rs
now fetches GRIN against any vs_currency (sats price vs btc, ×1e8) and
caches per code; a Settings → Pairing sub-page picks it; the Pay, send,
and balance previews all route through one pairing_preview() helper.
- Hide the Yellow theme from the picker (cycle is Dark ↔ Light now); the
ThemeKind::Yellow tokens stay defined — it's beta, not removed.
Verified live on :2 (Default wallet): node card → Node menu; identity chip
→ identity settings; QR puck renders top-right of Pay; Pairing → Sats shows
"≈ 1,912 sats" for 50ツ over a live BTC rate, resets to USD; theme cycles
Dark↔Light, never Yellow. 34 lib tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove the Node status chip from the wallet-list header; the integrated
node now lives in the cog's settings as a status + Enable/Autorun section,
with a "Node settings" button into the full stats/mining/tuning panel.
- Stop the desktop two-column rail from auto-docking the node beside the
settings screen, so the node has a single home in the cog at every width
(the full panel opens only on demand). Fixes the wide-window double-exposure.
- Prove NIP-17 payments transit the top public relays: parameterize the
nostr e2e roundtrip and add damus.io + nos.lol proofs (3/3 green).
- Add DEVELOPING.md documenting how we iterate and test (Xwayland :2 recipe,
Build N cadence, gui-sweep, e2e tests, live infra) — no secrets.
Verified live on :2: chip gone from the list; cog shows the node once
(single column, no left rail); "Node settings" opens the full panel on
demand. 34 lib tests green; nip17 roundtrip green over nrelay + damus + nos.lol.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recipient picker (send.rs) is now type-ahead instead of button-driven:
- as you type, instant local-contact matches + a debounced network
lookup surface as tappable cards (no more "Find recipient" button)
- a name or @handle is treated as a goblin username (nip05 resolve) and
shown as a tappable card — tap the right identity to select it
- a pasted npub/hex is verified over nostr: fetch its kind-0 profile and
badge the card "✓ on nostr"; a valid key with no profile shows "no
profile" and, when tapped, asks "Pay an unverified key?" before
continuing (owner choice — never hard-blocked)
NostrService::fetch_profile_blocking (client.rs) — one-shot kind-0 fetch
over the connected relay pool (nrelay + damus + nos.lol), so arbitrary
npubs resolve across the public network. data::search_contacts for the
local half.
Numpad (widgets.rs): centered, evenly-columned grid — the trailing
per-key space was drifting it off-center under the amount; hovered keys
now highlight in accent.
Onboarding identity step: added a plain-spoken line that you can swap in
a fresh key any time to unlink from your old identity.
34 lib tests green. Verified live on :2: numpad centers (92/198/300),
fiatjaf npub → "✓ on nostr", an unprofiled key → "no profile" → confirm
gate → Pay anyway → Review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The yellow theme's muted on-background tier (text_mute #6B6A63) rendered
the eyebrow labels (WALLET/PRIVACY/BALANCE/ACTIVITY…) and the "Moving
devices? Back up BOTH…" helper at only 3.85:1 on the #FFD60A background —
below the 4.5:1 AA floor (sweep P2). Darkened to #55534A (5.5:1,
measured), still the faintest of the three on-bg tiers so the hierarchy
holds. Dark/light themes unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wallet-list screen (wallets exist, none open) still wore GRIM's
node-first chrome: a yellow INTEGRATED NODE / WALLETS bar and a forced-
open left node column with ENABLE NODE / Autorun / stat tabs. That
fights the payments-first philosophy the open-wallet surface set — the
node is plumbing, chosen in onboarding and managed in settings, not a
console you stare at to reach your wallets.
Now the list is full-bleed dark like the rest of the goblin surface:
- the node panel no longer force-opens on this screen (content.rs);
it's demoted to a tappable status chip that opens the SAME panel,
every Enable/Autorun/mining/stat feature intact — just opt-in
- the yellow title bar is suppressed here (wallet_list_screen()), its
GEAR (app settings) and node access rehomed into a slim goblin header
(node chip left, gear right) above the GOBLIN logo + wallet rows + "+"
Verified desktop + mobile + panel open/close; unlock still lands on the
unchanged open-wallet surface. 34 lib tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The yellow theme exposed the egui scrollbar track as a stray dark line
down the Settings column (sweep P2). The goblin surface is touch-styled,
so its content scroll areas now hide the bar entirely (AlwaysHidden),
matching the rest of the app and removing the artifact on every theme.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Identity overhaul (server changes deployed to goblin.st separately):
Avatars
- profile pictures hosted on goblin.st, tied to the username: tap the
settings avatar → native image picker → 256px PNG uploaded over Tor
- letter avatars now use (background, ink) color pairs (8) keyed off the
npub, and render the first ALPHANUMERIC char — never the '@'
- custom pictures shown everywhere self/contacts appear: settings card,
home header, sidebar chip, peers strip, activity rows, send recipient
- AvatarTextures: disk cache (~/.goblin/cache/avatars) + background Tor
fetch + egui textures loaded on the UI thread; a network/Tor failure
is never cached as "no avatar" (would have stuck for 6h)
- nostr/avatar.rs mirrors the server's sniff→limits→orientation→crop→
256→re-encode-PNG pipeline so uploads are small and previews instant
Username lifecycle
- rotating the key now RELEASES the username (and deletes its avatar
server-side) instead of transferring; rotation aborts if release fails
- claim panel is one Claim button (checks then registers); registered
state shows "Registered <name>" + a Release action behind an
are-you-sure gate ("up for grabs the moment it's free")
- released names are immediately re-claimable (quarantine removed)
Other
- Tor::http_request_bytes: binary bodies + status code, for upload and
avatar download (string http_request kept as a wrapper)
- settings reordered Identity-first, then Wallet
- sidebar node card is 3 lines: status / block height / host
- profile card shows the full npub when it fits, else head…tail
34 lib tests green. Live-verified on goblin.st: upload→serve (image/png,
nosniff, immutable)→5/day limit (6th 429)→release purges avatar; a real
picture for @fartmuncher22 fetched over Tor and rendered across surfaces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check_availability returns Unknown when the Tor request dies, and the
claim panels collapsed everything but Available into 'Taken' — a free
name looked taken whenever a circuit flaked. The full enum now reaches
both panels: Unknown reads 'Couldn't check — connection hiccup. Try
again.' in neutral gray, and Reserved/Invalid/Quarantined get their own
copy. Tor::http_request also backs off up to ~15s across 5 attempts:
fresh circuits fail while arti refreshes its directory consensus over
the bridge, and 3 tries in 4.5s couldn't ride that out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A flaky external node read as 'Syncing…' forever — the card now goes
red with 'Can't reach node' on wallet sync errors (wallet.sync_error())
and recovers on the next good cycle. Validated against a dead node and
back on a live one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The numpad was platform-gated while the mobile shell is width-gated, so
narrow desktop windows showed neither numpad nor a visible input (sweep
P2). Settings rows clipping under the pinned profile card now meet a
hairline instead of slicing glyphs on an invisible edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Camera/QR (validated live with a v4l2loopback virtual camera):
- decode non-MJPEG frames on Linux (raw YUYV was undecodable: eternal
spinner), enumerate devices off the UI thread without unwrap, open
cameras by their real device index (list position broke whenever
/dev/video0 was absent), show "No camera found" after 5s
- scan-to-pay: QR button in the send recipient search row and the
mobile home header; in-surface camera panel feeding recipient
resolution; only text payloads accepted (seeds/slatepacks refused)
- receive QR was unscannable: full cells (0.5px gaps fragmented finder
patterns at 4.5px cells), always ink-on-white plate (inverted codes
fail many scanners), center mark 26% -> 19% (zbar chokes above);
rqrr+zbar both decode the live card now; unit tests cover the exact
widget geometry
First-run onboarding (replaces the stock empty state only; the wallet
list, add-wallet modal and GRIM creation flow stay for later wallets):
- intro -> node choice (Private integrated / Instant external with
URL) -> create or restore (wraps MnemonicSetup; word grid, paste,
SeedQR scan) -> optional @username claim with prominent skip
- fonts bind at creation context so frame one can use Geist families
Sweep fixes (22 confirmed findings, the simple ones):
- yellow theme: active sidebar nav was dark-on-dark (P1)
- window frame ring painted with theme bg: transparent clear color
rendered as a black band without a compositor (P2)
- import/rotate identity inputs get visible field wells (P2)
- receive buttons: verb labels + transient "Copied" feedback
- review hero card fills the column; fee row copy; pay-tab stale and
duplicated hints; unlock modal copy; node card subtitle truncation
- build number stays current (.git/logs/HEAD rerun trigger)
Backup restore: import accepts the export-time password and re-encrypts
under the current wallet password; cross-device restores work.
31 lib tests green. Mainnet-validated: both flows restored real
wallets; 0.1 grin sent B->A through NIP-17 over Tor with async
open/close ping-pong; tx 71fbfce4f591 posted on-chain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- Wide desktop (≥720px) now renders a left sidebar nav (Wallet/Activity/Send/
Receive/Settings) with a profile card instead of the bottom tab bar; narrow
screens keep the bottom tabs. Both drive the same Tab state and screens.
- Settings gains an Appearance group: tap to cycle theme (Dark/Light/Yellow,
re-applies egui visuals live) and density (Comfy/Regular/Compact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tap to cycle the incoming-payment accept policy (Anyone/Contacts/Ask) and to
toggle the fiat USD preview, completing the user-control story for settings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Send flow:
- "Sent" success now gates on the real dispatch result (NostrService send
phase Working/Sent/Failed) instead of send_creating, which cleared before
the DM left. Added a Failed stage with Try again / Close so a failed send
is no longer shown as success.
- NIP-05 handle resolution moved off the UI thread (was .join()-blocking the
render thread for the Tor timeout); now a worker thread writes into a polled
slot with an inline "Looking up…" spinner.
- Desktop amount entry no longer steals keystrokes from the Note field (guards
on the note TextEdit focus).
- Review screen now shows a network-fee line.
Requests / identity / archive:
- Approve is now double-tap safe (per-session approving set + disabled button),
closing a double-pay window.
- "Backup nostr key" copied the public npub; split into "Copy npub (public)"
and "Back up secret key (nsec)" which copies the real secret.
- Added inline username claim (availability check + NIP-98 register over Tor,
off-thread) for anonymous users; persists nip05 + republishes identity.
- Added archive Export / Wipe history controls.
- Receive "Slatepack" button now copies the grin1 address; "Scan" tab relabeled
"Receive" (no scanner was wired).
- Goblin view resets on wallet switch so a half-filled send can't leak across.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wallet:
- identity.json (NIP-49 ncryptsec) now written 0600 in a 0700 dir so a local
user can't grind the wallet password offline (+ regression test).
- Wallet password held as a ZeroingString through init_nostr so it's scrubbed
on drop instead of lingering in a plain String for the session.
- Replaced 4 .unwrap() on re-read tx_meta with graceful guards (archive wipe
mid-send could otherwise panic the nostr/task thread).
- Tor::http_request/post: bind the client once via let-else and propagate TLS
builder errors, fixing a TOCTOU unwrap panic on concurrent Tor restart.
goblin-nip05d server (redeployed to goblin.st, verified live):
- One-name-per-pubkey now enforced by a partial UNIQUE index (closes the
check-then-insert race); INSERT rows-affected==0 returns 409 not a false 201.
- NIP-98 replay protection: one-time auth event-id enforcement within the
freshness window; tightened forward skew to +5s.
- Rate-limited the unauthenticated GET endpoints; SQLite in WAL mode.
- Verified live: replay rejected, second name for a pubkey blocked.
Audit verdict: fund-safety invariants (never auto-pay Invoice1; S2/I2
finalization bound to counterparty npub) and Tor-from-day-one all hold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/nostr_e2e.rs (run with --ignored):
- nip17_slatepack_roundtrip: Alice gift-wraps a slatepack payment DM with a
subject to Bob over wss://nrelay.us-ea.st; Bob unwraps, the seal-author ==
sender invariant holds, the slatepack and subject extract intact.
- nip05_registration_roundtrip: registers a fresh name on goblin.st with a
real NIP-98 signature, resolves it back to the right pubkey, releases it.
Both pass against the live deployed infrastructure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New src/gui/views/goblin/ module rendered as the primary surface for an open
wallet: bottom tab bar (Wallet/Activity/Scan/Me), balance hero in ツ, Send/
Receive, recent peers, activity feed (wallet txs joined with nostr metadata
by slate id), pending payment requests with approve/decline, receive screen
with nostr-handle QR, settings/Me tab. Full send flow (recipient resolve via
npub/NIP-05 over Tor -> numpad amount -> review -> hold-to-send -> success)
dispatching WalletTask::NostrSend. Widgets library, fiat preview over Tor.
Fixed a font-binding panic: weight families are referenced only at widget
call sites, not in default text styles (set_fonts applies a pass later).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Infrastructure (P0): deployed nostr-rs-relay (wss://nrelay.us-ea.st) and the
goblin-nip05d NIP-05 service (goblin.st) on us-ea.st with TLS + DNS.
Brand & theme (P1): Goblin name/icon/data-dir (.goblin); three-theme token
system (light/dark/yellow) in gui/theme.rs with colors.rs remapped as a shim;
Geist + Geist Mono fonts; AppConfig theme/density/last_wallet_id.
Nostr subsystem (P2-P3): src/nostr/ with NIP-06 identity (seed-derived,
NIP-49 encrypted), per-wallet rkv archive, guarded ingest policy (never
auto-pays Invoice1; binds replies to the stored counterparty npub), NIP-17
send/receive pipeline, NIP-05 client. Relay traffic routed over the embedded
arti Tor client via a custom WebSocketTransport. Wired into Wallet lifecycle
and the task handler. 26 unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>