The wallet routed its ENTIRE relay.floonet.dev session (own identity, recipient
lookups, profile, catch-up, subscribe, publish) through the scoped Nym exit,
saturating its metered free-tier bandwidth so a payment gift-wrap arrived
minutes late or dropped — while nostr-sdk falsely reported "sent" (it returns on
the local mixnet-stream write, not a relay OK). Root cause was contention: ~97%
of the exit's relay bytes were non-payment overhead.
Split the nostr service into two clients:
- money client (Nym scoped exit): kind-1059 slatepacks + gift-wrap
subscribe/catch-up + recipient resolution — NIP-05 name lookup (already
mixnet via the HTTP tunnel) and the kind-10050 DM-relay lookup (moved here;
it's private payment-target resolution, and cheap). Relays kept, not dropped.
- general client (clearnet): own identity (0/10002/10050), discovery, the fat
kind-0 profile/avatar, general subs, and catch-up of non-1059.
Plus confirm-before-sent: a payment publish is not reported "sent" until a real
relay OK read-back confirms it — a slow/failed exit now surfaces as a retryable
error instead of silent money loss.
Runtime-verified: a normal session puts 0 bytes on the scoped exit (all
clearnet); a kind-1059 slatepack rides the exit and lands on relay.floonet.dev
(exit read-back + independent clearnet oracle). Exit non-payment overhead
dropped from ~50 KiB in / 164 KiB out per session to ~0.
Adds E2E test harnesses (wallet::e2e::funded_e2e_pay,
examples/nostr_split_measure, an exit publish repro in streamexit tests).
Symptom: after 'Save & reconnect' of the relay list, the home/onboarding UI sat on 'Connecting relays…' for ~30s even though the relays had physically reconnected over the exit in ~2-4s.
Cause: on restart the service clears connected=false, then the UI flag was only restored AFTER publish_identity (serial, untimed per-event sends) AND a catch-up fetch_events_from bounded by FETCH_TIMEOUT=30s. One relay slow to EOSE pinned is_connected() false for the whole window while the connection was already usable. A separate FAST probe task already detects first-relay-Connected at 250ms poll (~2-4s) and reports relay-live to nymproc, but it did not touch the UI flag.
Fix: in that fast probe, when relays first report Connected (same point that calls report_relay_live), also set svc.connected=true. The indicator now tracks the real ~2-4s relay-up signal; publish_identity + the catch-up fetch continue in the background. Tradeoff (documented in code): a relay drop between the probe store(true) and the 2s status loop taking over wouldn't flip the flag for up to ~30s until the post-catch-up re-check re-syncs to reality — the same-order staleness as the old pessimistic gap, just optimistic; the transport watchdog still tracks real exit health independently.
Hardening: publish_identity's per-event send_event_to was untimed, so a stalled relay delayed the catch-up fetch and the kind:1059 subscription that follow it (real incoming-message latency). Each publish is now wrapped in tokio::time::timeout(SEND_TIMEOUT), mirroring dispatch_dm; on timeout it warns and continues to the next event, never aborting the sequence.
Audit: all readers of is_connected() were reviewed for the relaxed invariant (flag can now be true before the giftwrap subscription is established). gui/goblin/mod.rs and gui/goblin/onboarding.rs use it for display + repaint scheduling and to enable the claim-username button — claiming needs relays connected (which the flag now genuinely means), not the incoming kind:1059 subscription. wallet/e2e.rs uses it as a test precondition with downstream waits of 900s/2400s and relays replay stored gift wraps on subscribe, so it still converges. No reader treats is_connected() as 'safe to receive now', so no separate ui_connected flag is needed.
Fire a separate high-importance system notification when an incoming
payment request (Invoice1 -> SurfaceRequest) is ingested over nostr,
mirroring the existing received-payment notification (id=2). Fail-open on
a missing JNI handle; fires once per not-yet-seen slate. No-op off Android.
Also add examples/tunnel_measure.rs, a dev harness for measuring the Nym
read tunnel (cold connect + warm per-fetch latency over the real transport).
The in-process smolmix tunnel was far slower than the old SOCKS5 model.
Fixes:
- smolmix TCP buffers 8KB -> 256KB and burst 1 -> 64: bulk throughput
ceiling rises ~32x (was capping relay backfill and JSON reads at a few
KB/s).
- Read tunnel runs a high-traffic mixnet profile (cover traffic off, higher
send rate, fewer reply SURBs) for lower latency; per-hop mix delays kept.
The scoped-exit money-path client is separate and unchanged.
- HTTP over the mixnet now reuses connections (keep-alive pool) instead of a
fresh TCP+TLS+HTTP handshake per request - fixes slow price and username
reads.
- DNS prewarm no longer skips on cold start and serves stale-while-revalidate
for known hosts, so a dial never blocks on DoT/DoH.
Money path (streamexit.rs, transport.rs) byte-for-byte unchanged.
Profile/username/NIP-05 reads were ~10s and the tunnel stalled 20s on a
dead gateway. Fixes:
- Profile/accepts/dm-relay fetches stream scoped to their dial set and
return on the first matching event instead of waiting for every relay
(or the full timeout) - the ~10s nprofile search.
- HTTP over the mixnet is tunnel-first, scoped-exit only as fallback when
the tunnel is not up (NIP-11/price/name lookups are public data).
- Name re-verify interval 78s -> 6h (was a debug leftover churning reads).
- Discovery relay NIP-11 probes run in parallel, not sequentially.
- Tunnel build timeout split from the exit dial cap: build 20s -> 10s
(env GOBLIN_NYM_BUILD_TIMEOUT) so a dead gateway is abandoned fast; the
exit money-path dial stays 20s.
- Cold start brings the tunnel up first, then prewarms the exit once after
publish (grant sequencing preserved).
- NIP-05 search bounded to 15s instead of hanging up to ~90s.
Money path (transport.rs, streamexit.rs) byte-for-byte unchanged.
Money path:
- Scoped, unbonded Nym exit for the money-path relay: the wallet dials a
relay operator's co-located exit over a MixnetStream (src/nym/streamexit.rs)
which pipes to its one relay; hostname-validated TLS end to end, no public
DNS. Anchor + fallback (never pin-only): any exit failure degrades to the
smolmix tunnel. relay.goblin.st's exit address is pinned in the relay pool
(src/nostr/pool.rs) and the maintainer gist so it bootstraps offline.
- STREAM_SETTLE bridges the open-before-accept gap so the first TLS byte is
not dropped into a stalled handshake.
- Verified end to end: two wallets complete a real gift-wrapped Grin payment
through relay.goblin.st over the exit, finalized + posted on mainnet
(src/wallet/e2e.rs, ignored live test).
Encryption:
- Adopt NIP-44 v3 for the NIP-17 gift-wrap path (G4): src/nostr/wrapv3.rs,
nip44 path dep; v3<->v3 and v3->v2 interop.
Also: mix-DNS (src/nym/dns.rs), full localization pass, GUI polish,
avatar-ring example, Android icon/script updates, GRIM deviation notes,
xrelay + connect-timing tests.
The 78s sweep fired regardless of whether anyone was looking, spending mixnet
round-trips in the background. Gate it on a frame heartbeat: the GUI stamps
crate::mark_frame() each draw (with a light ~2s repaint cadence so it stays
fresh while visible); when the app is backgrounded eframe stops drawing and the
stamp goes stale, so crate::app_foreground() reads false and the sweep skips.
The skip deliberately does NOT advance last_name_sweep, so the first tick after
the app returns to the foreground runs the sweep immediately — catching up on
resume rather than waiting out another full interval. Heartbeat lives at the
crate root so nostr reads it without depending on the gui module.
- The macOS bundle still shipped the old GRIM paw AppIcon.icns. Regenerated it
from Goblin-Logo-Gradient-SMALLER.png (the yellow-gradient goblin mark) as a
proper multi-resolution PNG-icns (16–512), so Finder/Dock show the Goblin icon.
- Declining (or cancelling) a request never re-resolved the counterparty, so
their @name could drop to a bare npub just because the request didn't go
through. handle_wrap now re-resolves the counterparty after a void — cheap,
authoritative via the by-pubkey reverse lookup, and a no-op for anonymous keys.
The requester still saw a bare npub for the payer because resolving a name
depended on fetching the peer's kind-0 off a relay (a nostr REQ with tight
timeouts over the private transport) before it could verify the NIP-05. When
that fetch doesn't land, resolution never starts — even though the name server
knows the name perfectly well.
resolve_contact_identity now asks the home authority directly:
GET /api/v1/by-pubkey/{hex} → the active @name for that key, in one HTTP
round-trip, with no profile fetch. That answer is authoritative, so the name is
set verified immediately. The kind-0 + verify path stays as a fallback for
FOREIGN authorities (which the home server can't speak for) and is still what
CLEARS a released/reassigned name. New nip05::name_by_pubkey helper.
Pairs with the goblin-nip05d by-pubkey endpoint. Verified by me only as far as
compile + unit/i18n tests; the live two-party resolution is the owner's call.
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.
- Configurable name authority (Settings → Identity → Name authority): bare
names resolve there, own-domain names show bare, foreign verified names show
'name · domain' with a check — no '@' anywhere. Lets bob@otherdomain pay
alice@goblin.st. Home domain derived from the configured server.
- Note entry is now a modal that floats above the soft keyboard (dimmed
backdrop) instead of an inline editor the keyboard covered.
- Backup export SAVES to a chosen location (Android CREATE_DOCUMENT / desktop
save dialog) instead of opening the share sheet.
- Onboarding status-bar icons are legible again (white on the dark surface,
not black); identity step is less wordy and drops the '@' prefix; claiming a
name during onboarding now republishes kind 0 so it's visible immediately.
- App-open name re-verify sweep (persisted, runs if >78s since last).
- Advanced 'Manage node connection' opens GRIM's native Connections UI.
- Manual slatepack paste: removed the QR icon. Pay screen: bolder, bigger ツ.
- Localized new strings across 6 locales.
Cached names were verified once and never re-checked, so a contact who
released or changed their username kept showing the stale name forever.
Re-validate names on a 78s sweep (capped per tick to bound mixnet lookups):
- nip05::check — tri-state Verified/Mismatch/Unreachable, so we only clear on
a definitive server answer (released, or reassigned to a different key),
never on a network blip.
- resolve_contact_identity now re-checks names older than the freshness window
and clears nip05 + nip05_verified_at on Mismatch (a user petname is kept);
display falls back to the npub automatically.
- A periodic sweep in run_service re-verifies the stalest due contacts.
Tests for the tri-state parsing and the clear-keeps-petname logic.
Profiles never loaded when scanning a bare npub (username/avatar stayed
blank) even though the relay stores and serves the kind-0 fine. Two causes:
fetch_profile_blocking ran on a throwaway current-thread runtime that can't
drive the relay connections (which live on the service runtime, behind the
custom Nym mixnet transport), and it only dialed nprofile hints, never the
user's own default relays. Run the fetch on the service runtime via a stored
Handle, and always dial the default relay set (incl relay.goblin.st).
A Goblin payment locks the sender's outputs until the recipient replies (S2)
and we finalize+post. If the recipient never connects to nostr, the funds stay
locked until the 24h auto-expiry. This adds a manual Cancel that reclaims them
on demand (after a 10-min grace, or immediately if the send never reached a
relay), marks the payment Cancelled, and best-effort voids it to the recipient.
- WalletTask::NostrCancelSend: authoritative tx lookup; refuses if already
finalized/confirmed (race); marks meta Cancelled BEFORE cancelling the grin
tx; serialized with nostr_finalize_post via a per-service lock so a cancel and
a concurrent S2 finalize can't both commit.
- nostr_finalize_post returns Ok(false) (skip, no retry/re-post) when the tx is
cancelled or the meta is Cancelled — covers the tx-list cancel path too.
- decide() already drops a late S2 on a Cancelled meta (new unit tests assert
it); recipient-side void marks a received payment Cancelled for display WITHOUT
deleting the output (a malicious sender could void-then-post otherwise).
- Void-before-S1 ordering handled via a (slate,sender)-bound marker.
- Receipt: tap-twice 'Cancel payment' with caveat + outcome notice; honest
'Waiting for X to receive…' label; first-class Cancelled status. 6 locales.
- cancel_grace_secs config (default 600).
- Drop the displayed @ prefix everywhere (identity picker, slatepacks page, all copy); @ stays internal for lookups/avatars.
- Settings: move wallet management to the foot — Switch wallet (deselect, stays unlocked), Lock wallet, and a new Advanced page mirroring GRIM's recovery tools (repair, restore-from-seed, reveal recovery phrase, delete).
- Restore the wallet-list title header (reachable settings gear, Pay-screen accent yellow).
- Transport: a transient receive/finalize failure no longer marks the gift wrap processed, so an incoming payment is retried on catch-up instead of being silently lost; finalize-post is now retry-safe (re-posts an already-finalized slate).
- Guard a latent panic on a short sender key in ensure_contact.
- Add more healthy public grin nodes (mainnet + testnet) for redundancy.
- Default first-run onboarding to the Instant connection (public node), shown first.
- Tidy leftover Tor remnants left from the fork.
Resolve a counterparty's @username on every interaction, not just incoming
requests — receives, sends and requests all kick off a verify-and-cache, plus a
one-time backfill on wallet open — so activity and the recent strip show names,
not bare npubs. Usernames now render WITHOUT the @ (kept internally for avatar
lookup); the recent row ellipsizes past 8 chars and centers the name under the
avatar, while activity shows the full name.
Pay screen: the numpad now sits above the note so the soft keyboard can't cover
it (and tapping the pad dismisses the note's keyboard), and a no-op key — a
second dot, a 0 on a leading zero, the 9-decimal cap — fires a short error
haptic instead of doing nothing silently.
The balance hero only showed amount_currently_spendable, so a wallet whose
funds were still confirming read 0 while GRIM showed the real total — the source
of the "Goblin says 0" confusion. It now shows the TOTAL (matching GRIM) with an
"X available - Y confirming" breakdown when some isn't spendable yet.
A send or approve that hits NotEnoughFunds (coins from a recent payment still
confirming, ~10 min) now says exactly that instead of a blank "Couldn't send",
and the Approve button no longer stays greyed forever — it un-greys on failure
with the reason shown, so the user can retry once funds clear. The relay-dial
cap for cross-relay delivery drops 12s to 6s so the first send isn't sluggish.
A send/request to a counterparty on relays we don't already hold failed with
"relay not found": NIP-17 publishes to the RECIPIENT's relays, but send_*_to
rejects any relay not in the pool. connect_relays() now adds and dials the
target's relays (from their kind-10050 or an nprofile/QR hint) before sending,
so the gift wrap actually reaches their inbox. Same fix lets profile/@username
lookup find a kind-0 that lives only on the target's own relays — fetch_profile
takes relay hints and dials them first. Sidebar handle font now scales with
length so short @names are legible (was a fixed, too-small 11px).
Settings now says "Manual transaction" and the privacy row reads "Messages &
lookups" opening a new Network privacy page that tells the truth: messages,
names, price and avatars ride the Nym mixnet; the grin node connects directly.
README and lander updated to match.
Requests are messages, payments are final: declining a request now sends the
requester a void control message (NIP-17), a requester can cancel a request they
sent (cancels the local invoice and notifies the payer), and incoming requests
resolve the sender's verified @username instead of a bare npub. The Requested
amount on the success screen is centered. New NostrDecline/NostrCancel tasks and
a goblin-action control message carry it, bound to the stored counterparty.
Localization: every Goblin-screen string moved to t!() keys (370 keys) and
translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test.
System-locale auto-detect now matches region locales like zh-CN.
README: drop the removed nym-socks5-client sidecar story (the SDK is linked
in-process now), add the in-process build steps + the manual-slatepack feature.
Comments: replace stale "sidecar" wording with the in-process SOCKS5 proxy,
drop leftover Tor references (Goblin routes over Nym), and trim the chattiest
working-notes to terse rationale.
Nym "Connecting..." for ~1 minute — root cause fixed. open() spawned the
wallet sync thread BEFORE init_nostr() created the nostr service, so the
sync loop's first pass at service.start() found no service and skipped
it; the service only started on the next sync cycle, a full SYNC_DELAY
(60s) later. Measured on the dev box: the gap between "identity ready"
and "starting service" was 62s, while the relay itself connects over the
mixnet in ~2s. Initialising nostr before start_sync collapses that gap
to ~1ms (verified 62s -> 1ms). The mixnet sidecar already warms up at app
launch, so the relay connect was the only thing waiting — and it waited
on loop timing, not on Nym. Added a "first relay Connected ~Nms" log line
so the timing is observable from logs going forward.
Pay tab is now painted in the yellow theme (Cash App-style brand surface)
regardless of the active theme, via a scoped theme override held across
the central panel so the fill and every widget pick up the yellow tokens
together.
Gradient avatar now renders for anonymous npubs on the onboarding
identity card and the mobile home header — both hardcoded a flat "N"
letter tile while Settings already showed the gradient. The header falls
back to the short npub so avatar_any takes the gradient branch.
Two fixes from live testing:
1) Stuck on "Connecting…": the nostr service dialed relays without waiting for
the bundled Nym sidecar to be up. On a cold start a fast wallet-open beat the
mixnet bootstrap, every relay failed, and nostr-sdk backed off — so the wallet
sat on "Connecting…" long after the mixnet was ready. Now we wait for the
sidecar SOCKS5 port before connecting (instant once it is warm). Verified:
service start -> relay catch-up in ~5s; the flag (Build 55) reflects it within
2s.
2) On-screen keyboard on desktop: upstream Grim pops its own virtual keyboard on
desktop (no_soft_keyboard = is_android()), which looked wrong in the wallet
flows and competed with physical typing (intermittent dropped/duplicated
chars). Goblin now uses native input on every platform — Android IME via JNI,
physical keyboard on desktop — by defaulting no_soft_keyboard to true. Verified
on desktop: no on-screen keyboard, reliable typing.
The nostr service only refreshed its connected flag inside the select!s
sleep(30s) branch, which restarted on every incoming notification — so the
flag could lag the real relay state by 30s+ (or, under steady event flow,
never update), leaving the UI stuck on "Connecting…" even though a relay
handshake over the Nym mixnet completes in ~2s (measured). Set the flag right
after the startup connect, and poll it on an independent 2s interval; the 30s
heartbeat work (persist last-seen, TTL prune) stays on its own cadence.
Pending transactions that never complete now auto-cancel/expire after a
configurable window (NostrConfig::expiry_secs, default 24h; lower it in
nostr.toml to test). A sync-loop sweep (NostrService::expire_stale):
- cancels stale outgoing sends + invoices we paid via GRIM's cancel_tx
(WalletTask::Cancel), releasing the outputs they had locked;
- annotates incoming payments / invoices we issued as Cancelled only
(a late on-chain confirmation still wins);
- marks pending incoming requests Expired.
The activity feed and receipt now render "canceled" (this also fixes a
latent bug where a manually-cancelled tx still showed "pending").
Receipt de-duplication: the To/From name rows are shown only when the
counterparty has a real identity (petname or verified NIP-05); a bare
npub now appears once, in the "nostr" row, instead of twice.
38 lib tests pass (2 new for the expiry bucket logic).
Route every relay and HTTP request (nostr relays, NIP-05, price) through
a local nym-socks5-client sidecar on 127.0.0.1:1080, so all traffic
egresses via the 5-hop Nym mixnet and nothing touches the clear net.
- Add src/nym/: SOCKS5 HTTP client (reqwest socks5h), NymWebSocketTransport
for the nostr relay pool (tokio-socks dial + TLS/ws handshake over the
mixnet), and a sidecar launcher that reuses or spawns nym-socks5-client.
- Swap the nostr-sdk transport off ArtiWebSocketTransport; route nip05.rs
and price.rs off Tor; revert the clearnet username-lookup shortcut.
- Remove the embedded arti Tor client wholesale: the onion-service
listener and send-to-onion path in the wallet, the legacy transport
GUI tab, the Tor settings page, src/tor/, the webtunnel pluggable
transport (Go build + submodule), and all arti crates from Cargo.toml.
The Grin node connection is unchanged (chain data, no payment metadata,
and never used Tor). The network requester the sidecar routes through is
configured via GOBLIN_NYM_PROVIDER / NETWORK_REQUESTER at deploy time.
Rich transaction receipt screen (tap an activity row, or the new Receipt button
on a send's success screen): counterparty, time, note, amount, and a
Transaction-details card joining GRIM's local metadata with the nostr
npub/username — status (Complete vs Pending N/min-conf), To/From, network fee,
Mimblewimble, and the slate id. A local archive, like GRIM.
Group the Activity feed into Pending (unconfirmed) + per-day sections.
Contact profile screen (tap a peer or the receipt counterparty): who they are,
the history between you, a Pay shortcut, and Block — a nostr-level mute that
drops their incoming payments/requests (Contact.blocked, checked in ingest).
Refine success/denied copy ("to/from" by direction; "ask them to send you grin
instead"). Make the profile avatar display-only — no custom-picture upload.
Wire GRIM's receiver-initiated invoice (Invoice1) over nostr so the Pay-tab
"Request" button actually asks someone for money: pick a contact, issue an
Invoice1, DM it; they get the existing approve-to-pay card. New
WalletTask::NostrRequest mirrors NostrSend (issue_invoice + RequestedByUs /
AwaitingI2, reusing send_payment_dm); the send flow gains a request mode
(Request from -> Confirm request -> Send request -> Requested) with no balance
guard, since requesting isn't spending.
Incoming requests are now opt-out (Settings -> Requests, on by default): when
off, an incoming Invoice1 is dropped and the preference is advertised in the
kind-0 profile (goblin_accepts_requests) so a requester sees "Could not
request" before sending. Adds a Cash App-style toggle switch widget.
Also enlarge the center Pay puck in the floating nav, and make the macOS
release build recur: release.yml now triggers on release publish (macOS only,
since Linux/Windows/Android/AppImage are built locally).
- 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.
- 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
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.
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>
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>
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>