1
0
forked from GRIN/grim

59 Commits

Author SHA1 Message Date
2ro c0cdeb6d6b goblin: back-hint polish — switcher wording, quiet-toast look (Build 148)
The first-back pill now reads "Press back again to switch wallets"
(re-translated in all six locales) and is styled as a native quiet toast: a
solid soft pill with no border, small regular dim text — no accent colors,
nothing loud. No haptic on the back press (owner's call). Behavior unchanged.
2026-07-06 00:45:54 -04:00
2ro e9f3bd453b goblin: double-back at Home returns to the wallet switcher (Build 148)
At the wallet Home (the top level, where back was silently swallowed), the
first Android back now shows a brief non-blocking pill — "Press back again to
leave this wallet" — and a second back within two seconds DESELECTS the wallet
to the switcher. The wallet stays UNLOCKED (select(None)-style deselection, no
close, no logout), so returning is one tap and its nostr service keeps running
untouched. Quitting the app lives only at the switcher, via GRIM's existing
native exit-confirmation on back (unchanged). In-wallet sub-screens keep
normal back navigation, no hint. The second back never quits directly.

The hint is a bottom-anchored fading pill above the tab bar (goblin view),
armed by the host (WalletsContent::back_exit_at) in the swallowed-no-op
branch; nothing else about back handling changes. New goblin.home.back_again
string in all six locales; drift green.
2026-07-06 00:34:47 -04:00
2ro 9b1cec2063 goblin: min-confirmations setting + integrated node moved to Advanced (148)
MIN CONFIRMATIONS (GRIM parity): the wallet group in Settings gains a
"Minimum confirmations" row just below the Node row, showing the current value
(default 10) and opening a numeric edit modal ported from GRIM's
min_conf_modal_ui (same prompt/error strings, Cancel/Save, CenterTop). The
fork already carried the full backend — WalletConfig::min_confirmations with
MIN_CONFIRMATIONS_DEFAULT=10 and Wallet::update_min_confirmations — and the
value already feeds the spendable/send logic (retrieve_summary_info, send and
receive params) plus the GUI confirmation counts via
data.info.minimum_confirmations, so this only exposes the existing knob.

INTEGRATED NODE -> ADVANCED: the integrated-node tabs (info, metrics, mining
with stratum, node settings) leave main Settings — Goblin is the lighter
client. Their ONE home is now Settings -> Advanced, as a second action on the
"Run your own node" card (live sync status in the label when the node runs),
with back returning to Advanced. The duplicate entry on the node-connection
page is removed too, per the single-home rule; all functionality is kept.
2026-07-06 00:07:21 -04:00
2ro c3d6c31aca goblin: one-toggle add-identity sheet, kill the dead Generate button (Build 148)
The add sheet's "Generate new" was a mode toggle that set import=false — the
mode the sheet already opens in — so it read as a completely dead button, and
the four-button layout (Generate new / Import / Cancel / Add) mixed two silent
mode toggles with two actions.

The sheet now DEFAULTS to generate mode with a one-line note saying a fresh
anonymous key will be created; the standalone "Generate new" button is gone.
One clear toggle sits above the action row and always names the OTHER mode:
"Import an existing identity instead" switches to import mode (revealing the
.backup picker and nsec paste inputs), where it becomes "Create a new key
instead" to switch back. The action row stays exactly Cancel + Add — Add
creates a fresh identity in generate mode and imports in import mode — keeping
the 147 modal-lock, uniform-button and CenterTop conventions. The old
generate/import toggle labels are removed from all six locales and the new
generate_note / import_instead / generate_instead strings added; drift green.
2026-07-05 23:52:59 -04:00
2ro 3237174c2e goblin: modal-lock the identity manage sheet, uniform paired buttons (Build 147)
MANAGE SHEET IS A TRUE MODAL: the pencil now opens the per-identity management
sheet through the GRIM Modal system (like the password modal), which dims and
locks the identity list behind it — switching and row taps are disabled until
it closes, killing the bug where the active identity could be switched while a
manage sheet was open for a different one. The modal is titled with the
identity it manages (display precedence: private tag, else bare name, else
truncated npub). The step-1 delete confirmation moved into a modal too (same
standard: a sheet acting on a specific item locks its background), also titled
with the identity, chaining into the wallet-password modal as before.

UNIFORM PAIRED BUTTONS: every paired action row in the identity sheets now
renders one consistent widget per row. In modals both halves are the GRIM
modal button (Cancel/Save; Cancel/Delete with Delete carrying only the red
color at the same size and shape). The in-page add panel's Cancel/Add row —
which mixed big_action_on_card with the 56px big_action inside 44px halves —
now uses big_action_on_card_ink for both, matching the Generate/Import toggle
(the reference pattern); the disabled Add state dims its ink. Audited the rest
of the new identity UI: the toggle row was already uniform and the remaining
buttons are full-width singles.
2026-07-05 23:07:50 -04:00
2ro 0c7a1404fa goblin: fix Settings scroll extent so the page reaches its foot (Android)
Root cause found by diffing against upstream GRIM, which does not have the
bug: GRIM gives every ScrollArea a distinct id_salt (e.g. "wallet_list_scroll",
"wallet_tab_content_scroll"); our goblin surface created fifteen unsalted
ScrollArea::vertical() — Settings Main and every settings sub-page render
theirs at the same position in the same parent Ui, so they all resolved to the
SAME egui scroll-state id. Scroll offset and remembered content extents
collided across pages, clamping the Settings list's maximum scroll to another
(shorter) page's extent: the page stopped scrolling before its real end and the
bottom items — including the wallet-management foot (Switch wallet / Lock /
Advanced), i.e. the way out — were unreachable on the phone. This one root
cause covers both user reports ("Settings bottom cut off" and "can't exit").

Every goblin ScrollArea now carries a unique id_salt, matching GRIM's
convention, so each page keeps its own scroll state and traverses to its true
bottom. Back-press behavior is deliberately untouched.
2026-07-05 22:07:12 -04:00
2ro d80218a13c goblin: plain-language identities blurb in all six locales
The top-of-page identities copy now says what identities ARE in end-user
terms — the names you get paid under, all paying into this one wallet — and
what you can do (add, switch, remove). No protocol jargon.
2026-07-05 21:59:17 -04:00
2ro 472e2f0915 goblin: private identity tags + per-row management sheet (Build 146)
PRIVATE TAG: a local, app-only name the user gives an identity. Stored as
NostrIdentity.private_tag in its 0600 identity file (serde-default; it also
rides inside the NIP-44-sealed .backup envelope, which serializes the whole
struct, and is restored on backup import). NEVER published — kind-0 metadata is
built only from the claimed NIP-05 name, untouched. Display precedence
everywhere an identity is shown (switcher rows, delete confirm, transaction-
detail attribution): private tag, else claimed name (bare, no @), else
truncated npub (HeldIdentitySummary::display).

ROW UX: identity rows show the label with a checkmark on the active one; TAP =
instant switch (unchanged). A small pencil affordance per row opens a
management sheet: Rename (set/clear the private tag; saves via
Wallet::rename_nostr_identity, which persists the file and updates the running
service in place — no password needed, the ncryptsec is untouched) on top, and
Delete at the bottom, visually separated and destructive-styled, feeding the
existing double gate (danger card, then the wallet-password modal). Delete only
renders while more than one identity is held. New strings in all six locales.
2026-07-05 21:58:44 -04:00
2ro 2592eadc9f goblin: delete identity (double-gated) and unified import (Build 146)
DELETE: each held identity row gains a trash affordance. Step 1 is a danger
card naming the identity, stating the removal is PERMANENT and prominently
reminding the user to back it up first (unrecoverable without its nsec or
.backup); step 2 is the existing wallet-password modal, which executes
Wallet::delete_nostr_identity. Delete drops the entry from the held-identity
index, removes its on-disk encrypted file, and rebuilds the service without it,
so its pubkey leaves the multi-pubkey gift-wrap subscription and its key leaves
the unlocked in-memory set. The shared balance and all other identities are
untouched. Edge cases: the LAST identity can never be deleted (the trash only
renders with more than one held); deleting the ACTIVE identity promotes a
survivor to active first; deleting the legacy primary (identity.json) promotes
a survivor INTO identity.json so init_nostr's fallback and an older build's
rollback anchor still resolve, never leaving a hole that would mint a fresh key.

IMPORT: the add panel's "Import nsec" is now just "Import" and offers both a
.backup file picker and a paste-an-nsec field. The .backup path reuses the same
from_encrypted_backup + re-encrypt-under-this-wallet's-password flow as the
existing identity import (name and history restored), through the same NIP-49
encrypted store; the pasted-nsec path is unchanged. New strings in all six
locales; drift green.
2026-07-05 21:50:42 -04:00
2ro 2acfd46784 goblin: identity copy for the listen-all model; drop relay-linking framing
Rewrites the identity blurb to say all identities receive into the one balance
at once (switching only chooses which you present and send as), and removes the
privacy note's claim that switching or a shared balance lets a relay link your
identities — the wallet's Tor transport is the answer to that, and the framing
is not something we assert. Drops the now-unused sync-flow strings (syncing,
caught_up, paid_while_away, need_password) across all six locales; drift green.
2026-07-05 20:37:49 -04:00
2ro 51d1675ad6 goblin: unlock-all / listen-all identities with instant switch (Build 145)
Reworks the multi-identity model. When the wallet is unlocked, EVERY held
identity's nsec is decrypted into memory (unlock_all_identities), and the
service listens for gift wraps addressed to ALL of them at once, each redeeming
into the one shared balance. Switching is now an instant, purely-local change of
which identity is presented and used for sending — no password, no service
teardown, no catch-up.

How receives stay deduped across all identities: a SINGLE gift-wrap subscription
with a multi-pubkey filter (OR over #p) and a SINGLE sequential notification
handler. Each wrap is p-tagged to exactly one identity, so it arrives once and is
processed once through the unchanged processed-set — dedup is exactly as safe as
the single-identity path, with no concurrent wrap processing. handle_wrap opens
each wrap by trying each held key until one succeeds; that key is the recipient
identity, recorded as the tx's recipient_pubkey. publish_identity now advertises
every held identity's DM-relay list (and profile, if named), each signed with its
own key, on the shared relay set.

NostrService holds all identities (recv) plus the active keys/identity (swapped
in place on switch); the switch-sync signals (is_switch_syncing/switch_received
and the "Syncing/Caught up/You were paid while away" flow) are removed — there is
nothing to catch up. The password modal remains only for add/import; the Build
144 import button and modal-position fixes are kept. init/add/import/rotate
rebuild the service with all identities; a plain switch does not.

Per-identity Tor-circuit isolation is deferred: all identities share the wallet
relay set for now.
2026-07-05 20:37:49 -04:00
2ro 5d500540c8 goblin: identity add-panel button + password-modal placement (Build 144)
- Import/Generate toggle: constrain each button to half the panel width, like the
  Cancel/Add row. big_action_on_card_ink sizes to ui.available_width(), so in the
  toggle's horizontal the first button ate the whole width and the Import button
  overflowed the card. Now two equal-width in-panel buttons that match the panel's
  other actions.
- Password modal: open it at ModalPosition::CenterTop (the project convention for
  text-input modals, e.g. the send-note and wallet-settings modals) instead of the
  default vertical center, so on mobile it sits above the soft keyboard.
2026-07-05 19:31:28 -04:00
2ro 6399222908 goblin: identity switcher UX fixes for Build 143
- Password entry is now a MODAL popup mirroring the wallet-open password modal
  (Modal + explanation + masked field + wrong-password line + Cancel/Continue),
  used as the unlock step for BOTH switching and adding. The inline page-top
  password field is gone; tapping a held identity opens the modal to unlock and
  switch, and Add opens it to encrypt+store. A correct password is verified
  synchronously so a wrong one stays in the modal.
- Add no longer force-switches into the new identity ("Add and switch" -> "Add");
  it is added to the held set and the user activates it later via tap -> modal.
  This also removes the switch-triggered publish from the create path, so
  creating an identity only generates a fresh anonymous key and stores it
  encrypted (no name lookup/claim).
- Fixes the import-nsec flow. Root cause: the required password lived in a page-
  top field disconnected from the Add card, so the confirm button's armed gate
  stayed disabled unless that separate field was filled — import (and generate)
  read as inert. Moving the password into the modal removes the disconnected
  gate; the underlying add_nostr_identity(Some(nsec)) path was already sound
  (covered by the new regression test).
- Usernames render WITHOUT a leading "@" (project convention), in the switcher
  and the transaction-detail identity row; unnamed identities show a truncated
  npub. New add_confirm/pass_prompt strings in all six locales (drift green).
2026-07-05 18:36:28 -04:00
2ro 9e97d532e7 goblin: identity backend fixes for Build 143
- No "Primary" (or any placeholder) label: label_for now yields the claimed
  name's local part or EMPTY, never a placeholder word. The UI derives its
  display from the name or a truncated npub, so an unnamed identity is shown by
  its npub, never "Primary".
- Add Wallet::verify_nostr_password so the identity password modal can reject a
  wrong password up front (in-memory NIP-49 unlock of the active identity),
  matching the wallet-open modal, before spawning an add/switch worker.
- Regression test: importing a bare nsec adds it as a held identity via the same
  NIP-49 encrypted store, keyed by its own pubkey, openable under the wallet
  password, with the active pointer unchanged (add never switches).
2026-07-05 18:36:28 -04:00
2ro 6d0dde523f goblin: enable the activity-row identity badge (approved cue)
Turn the per-identity activity cue on (SHOW_ROW_IDENTITY_CUE = true) and render
it as the owner-approved corner badge. The row's main avatar stays the
COUNTERPARTY; overlaid on its bottom-right corner is a small ~14px badge filled
with the USER's OWN identity gradient for that transaction (from
ActivityItem.owner_pubkey via the exact identicon gradient math, falling back to
the primary identity for pre-feature rows), so a glance clusters which of your
identities each payment used. Shown only when the wallet holds more than one
identity, never on system (mining) rows.

The badge is a true smooth rotated linear gradient (an egui mesh reproducing the
avatar's SVG gradient, not a flat chip) at 0.9 fill opacity, with a 1px
theme-aware hairline ring (near-black on light, near-white on dark) so it stays
legible on both pure-white and pure-black and against the avatar it sits on.
identity_dot is now the single gradient renderer, shared by the row badge and
the transaction-detail legend.
2026-07-05 16:46:59 -04:00
2ro e56d9d05e2 goblin: per-identity gradient legend on the transaction detail
The transaction detail's Identity row now carries a small two-tone gradient dot
in that identity's OWN gradient, drawn with the exact identicon math
(gradient_rgb8, refactored out of gradient_params so the dot matches the
identity's avatar everywhere). The dot is theme-aware and deliberately quiet: a
faint fill plus a faint ring that stays legible on both light and dark
backgrounds without becoming a bright chip.

A per-row cue in the activity list is intentionally OFF (SHOW_ROW_IDENTITY_CUE
= false): on a row the avatar is the counterparty, so a gradient dot there is
both redundant with that avatar and does not tell the user which of their OWN
identities was used. The plumbing (the seam const, a per-frame identity context,
ActivityItem.owner_pubkey recording the tx's owning identity) is kept so a
proper own-identity cue can be added later in one place, but it renders nothing
now. The switcher page and profile card already show the active identity via its
own gradient avatar and name.
2026-07-05 16:29:21 -04:00
2ro 69f647a009 goblin: show the paid/used identity on the transaction detail view
The transaction receipt now has an Identity row telling the user which of the
wallet's held nostr identities was active when that payment was received or
sent. It uses the identity recorded on the transaction (TxNostrMeta's
recipient_pubkey), not a reconstruction, and falls back to the primary
identity for pre-feature rows. It shows the NIP-05 name when the identity has
one (any domain), otherwise a truncated npub, and is always shown on the
detail view (no clutter there whether the wallet holds one identity or many).
The activity list rows are unchanged. New goblin.receipt.identity string added
in all six locales.
2026-07-05 16:15:42 -04:00
2ro 8a382aa308 goblin: identity switcher strings in all six locales
New goblin.identities.* keys for the switcher page, present identically in
en, de, fr, ru, tr and zh-CN so the drift guard stays green.
2026-07-05 16:11:32 -04:00
2ro 63d0cb7dee goblin: identity switcher UI (top-right swap glyph + its own page)
A swap glyph (phosphor arrows-left-right) sits top-right of the Settings
profile row and opens a dedicated identity switcher page. The page lists the
held identities with the active one checked, tap a non-active one to switch,
and adds a new identity by generating a fresh nsec or importing an existing
one, then switches to it. One shared wallet-password field unlocks a target
on switch and encrypts a new identity on add; it is cleared from memory when
the page closes. A syncing then "caught up" / "you were paid while away"
banner reflects the switch catch-up (the count honors hide-amounts). Android
back and the back button return to Settings.
2026-07-05 16:11:31 -04:00
2ro 2e2d96016e goblin: switch, catch-up and shared-balance receive across identities
The active identity drives the single live gift-wrap subscription and every
send/display, and every identity redeems into the ONE shared grin balance.
Switching reuses the rotate/import stop-wait-start machinery: it verifies the
wallet password by unlocking the target BEFORE any teardown (a wrong password
never strands the wallet with no running identity), gates on no in-flight
send, tears down the service and stands a fresh one on the target key against
the SAME shared store (so processed-dedup carries across and nothing double
redeems), and moves only the active pointer (identity.json is never
overwritten). The catch-up now looks back from when THIS identity last
listened, so a payment that arrived while it was dormant is fetched and
redeemed on switch-in; the service surfaces a syncing state and a count of
payments redeemed during that catch-up for a "you were paid while away" cue.

TxNostrMeta gains recipient_pubkey (serde-default empty = primary) to tag
which front door a payment used, for per-identity activity and a later
accounting split. Wallet gains nostr_identities / add_nostr_identity /
switch_nostr_identity, init_nostr adopts the held-identity index with legacy
migration, and a wallet-password change now re-encrypts every held identity
through the same NIP-49 path. Only the active nsec is ever decrypted in
memory; the rest stay encrypted at rest.
2026-07-05 16:11:18 -04:00
2ro 26da66710e goblin: held nostr identity set (storage + migration)
One wallet, one grin seed / one balance, but a SET of nostr identities
(nsecs), exactly one active at a time. New identities module owns the
held-identity index (identities.json): which identities the wallet holds,
their order, and which is active. It stores no secrets; each held identity
is its own NIP-49 ncryptsec on disk, exactly like the single identity today
(identity.rs create/unlock/backup reused unchanged, plus save_at/load_at for
the per-identity files and pubkey_hex for the index key).

Migration is fund-safe and needs no key regen: a pre-feature wallet has only
identity.json, which the index adopts in place as the single active identity
number one; the legacy file is never overwritten, so an older build still
opens the wallet on it (clean rollback). The store gains a per-identity
last_active_at so a switch back to a dormant identity can catch up from when
it last listened, not merely from the wallet-wide last connection.

Unit tests cover migration, add/switch/cap/dedupe, active resolution across a
reload, corrupt-index fallback, reencrypt-all, and the catch-up-since rule.
2026-07-05 16:11:02 -04:00
2ro 90c294bf26 goblin: point changelog third link to docs.goblin.st and rebrand startup log
Repoint the changelog modal's third button to https://docs.goblin.st
(the docs/reference destination; GitHub button already covers source)
and swap its code-brackets glyph for the BOOK icon so it reads as a
docs link. Rebrand the startup version-announcement log line from
"Grim" to "Goblin".
2026-07-05 14:07:55 -04:00
2ro 3a69b72d9d goblin: fix update changelog modal branding and links
Rebrand the inherited GRIM update/changelog modal for Goblin. The
modal title now reads "Goblin <version>" instead of "Grim <version>",
and the three footer links point to Goblin destinations: GitHub
(2ro/goblin), the Telegram family channel, and goblin.st in place of
the old code.gri.mw source mirror.
2026-07-05 14:03:51 -04:00
2ro bdedcba498 goblin: appearance settings additions for Build 140
Three settings-surface additions:

- Hide amounts: a toggle (same switch widget as the incoming-requests
  toggle) under the amount-privacy rows, persisted in the app config
  (serde-default off so old configs load). When on, the received-payment
  notification masks the numeric grin with dots.

- Language: a picker under Appearance beside the theme row. Lists the six
  shipped locales in their own names; tapping one switches and persists the
  active locale.

- Update available: a badge pinned to the right of the settings profile
  panel, shown only when the release check has found a newer build (reuses
  the existing AppConfig::app_update state); tapping it opens the release
  download page.

New user-facing strings added to all six locales.
2026-07-05 13:11:23 -04:00
2ro 037e727756 goblin: bolder frameless home QR matching the pay header
Drop the tacky filled circle behind the home scan-to-pay QR. Render the
glyph larger and in the theme's foreground ink (white on the dark home
background), vertically centered next to the profile avatar, mirroring the
size and treatment the Pay header already uses. The scan action and target
are unchanged.
2026-07-05 13:11:08 -04:00
2ro ce024443ac goblin: refine the activity row and drop list seconds
Rebuild the activity row so the message pins left and truncates while the
signed amount pins right with the date/time right-aligned directly beneath
it. List timestamps lose the seconds (HH:MM); the tap-in detail view keeps
the full second-precision time. A row with no timestamp (a canceled tx)
draws no right-side time slot at all and folds its status word into the
left message instead. Shared note/time derivation lives in one helper.
2026-07-05 13:10:43 -04:00
2ro 1141f97b22 goblin: date the home news panel (ISO 8601) and guard title overflow
Show the article date first in the Home news card, formatted YYYY-MM-DD in
UTC (never a US M/D/Y, day only, no time). The date is sourced from the
NIP-23 published_at tag when present, else the 30023 event created_at; a new
optional published_at field on NewsItem threads it through the store and the
news handler.

Guard the title against clipping two ways: a hard NEWS_TITLE_MAX_CHARS cap
(48) that ellipsizes an over-long title predictably, plus measured
shrink-to-fit that steps the semibold font from 16pt down to a 12pt floor so
the title stays on one line within the card width on a 390px phone, backed by
truncate for the pathological narrow case.
2026-07-05 06:29:21 -04:00
2ro e036a9692a goblin: fix activity row amount overlap and truncation target
The recent-activity rows had two layout faults visible on the 390-wide
home surface.

The transaction amount collided with the subtitle text. Root cause was
layout, not position: the title/subtitle column used truncate() against
the full available width, which included the space the right-aligned
amount needed, so the text stretched under the amount and the amount was
pushed on top of it. Reserve the amount as its own right-hand column
first, then bound the text column to the width that is left. Centered
against the two-line stack, the amount now lands between the title and
subtitle lines with clear space above and below. Row height goes 60 to
64 for breathing room.

The wrong subtitle field was being clipped. The subtitle was one
"note · date/time" string truncated at the end, so the date and time
(the part the row exists to show) lost its seconds first. Split the
subtitle: the date/time tail is pinned right and never clipped, and the
note takes the remaining width and gets the ellipsis.

No new user-facing strings. Picker rows reuse the same widget with an
empty tail, so their npub/tag subtitle still truncates as before.
2026-07-05 06:20:12 -04:00
2ro ef58f260e8 harden(nostr): cap relay ws frames and zeroize conversation keys
Two self-contained hardening measures:

- WebSocket frame cap: the Tor relay transport dialed every relay with
  tungstenite's default 64 MiB message / 16 MiB frame ceiling. Pass a
  WebSocketConfig capping both to 4 MiB via client_async_tls_with_config, so
  a hostile or buggy relay can't stream a giant frame into wallet memory. The
  pool only requires max_message_length >= 128 KiB and the wallet's own events
  are far smaller, so 4 MiB keeps ample headroom.

- Secret zeroization: the raw NIP-44 v3 ECDH conversation keys in wrapv3.rs
  are now Zeroizing<[u8; 32]> so they are scrubbed from memory on drop instead
  of lingering. zeroize (already a transitive dep) is pulled in directly.

Adds a ws-config test asserting the caps sit below the tungstenite defaults
and above the pool minimum.
2026-07-05 06:08:25 -04:00
2ro 210c4ab662 fix(send): show the proof-notify target on the review screen
With proof mode on, a notify= npub on the scanned URI makes the wallet
gift-wrap the full signed payment proof (buyer sender address + kernel) to
that key at finalize, but the review screen never showed the target, a
hidden watcher the payer never saw or consented to.

The proof row now renders a second line, Proof shared with: npub1abc...xyz,
whenever a notify target is present, so the recipient is seen and chosen: if
you scanned it, you asked for it. Display only; nothing about what gets sent
changes. Adds the goblin.send.row_proof_shared key in all six locales.
2026-07-05 06:08:25 -04:00
2ro c0b622f694 fix(payuri): reject non-ASCII and oversized scanned amounts
A crafted pay-URI amount with a multibyte UTF-8 char (e.g. amount=0.EEEE)
reached grin_core's amount_from_hr_string, which slices the fractional tail
at a fixed byte index and panics on a non-char-boundary. The scan/deep-link
thread has no catch_unwind, so a single scanned QR or opened link could crash
the wallet. validate_amount now rejects any non-ASCII amount up front (Grin
amounts are only [0-9.]), degrading to manual entry.

Also cap the whole-GRIN part at 1e9 before the grin parse, below the u64
overflow point of grins * GRIN_BASE. Without this a giant amount wraps in
release to a small atomic value that is what actually gets dispatched while
the review screen still shows the giant figure.

Adds regression tests for both (multibyte to None no panic; over-cap to None).
2026-07-05 06:08:25 -04:00
2ro 42d70e1a5e Goblin: generic browser User-Agent for Tor HTTP, drop duplicate UA
Replace the "goblin-wallet" User-Agent on every Tor-carried request with a
common desktop-browser string so the wallet's traffic is not trivially
classifiable as Goblin at the destination. The default lives in one const in
the Tor client and is applied once per request; price.rs no longer passes its
own User-Agent (which sent the header twice). Also fix a stale nip05 doc
comment ("Nym mixnet" -> "Tor"). No transport change: the update check stays
on its existing clearnet path for GRIM parity.
2026-07-05 05:55:32 -04:00
2ro e9f0c3f0e2 Goblin: publish the "payment sent" receipt at dispatch, not finalize
Closes the buyer's double-send window. When a Nostr-rail send carries
order context, the plain kind-17 status=sent receipt used to publish at
finalize alongside the encrypted proof. With an offline merchant,
finalize can be hours away, so the buyer stared at a scannable QR for a
payment already made: maximum double-send temptation.

The receipt now publishes at S1 dispatch, the same moment the payment
envelope is accepted by a relay and the wallet UI flips to "sent",
through the same crash-safe reconcile queue. The event shape is
unchanged (same tags, kind, content); only the timing moves earlier, so
magick's status=sent matcher is unaffected.

The encrypted PROOF delivery stays at finalize (the proof does not exist
before then). Idempotency: a new receipt_sent flag on TxNostrMeta gates
the receipt to exactly one publish per tx, so finalize never duplicates
it. deliver_proof no longer touches the receipt; the reconcile pass gets
an independent receipt-retry path for the crash/offline case.

Decision logic is factored into pure predicates (receipt_due_at_dispatch,
receipt_retry_due, proof_delivery_due) used by the live paths and covered
by three tests: dispatch-publish decision, no-duplicate-at-finalize, and
proof-still-at-finalize. Full lib + i18n drift green.
2026-07-05 05:53:00 -04:00
2ro a437aad2f8 README: language-aware news, open-to-pay links, proofs on request
Build 138 surface: the Home news panel now renders in the wallet's language
with an English fallback; goblin:/nostr: pay links and scanned checkout QRs open
straight to a prefilled review screen; and payments can include a native Grin
payment proof when the request asks for one, off by default and shown on review.
2026-07-05 05:31:43 -04:00
2ro d5d1212a44 Goblin: fetch fiat rate live on view, kill the 48h price cache
The fiat subline now fetches the CURRENT rate over Tor only while the
balance is actually on screen, and never paints a stale number as if it
were live. No disk cache, no background timer: an idle or payment-
listening wallet never polls (battery).

- price.rs: in-session rate held for a 3-minute freshness window; viewing
  a stale/missing rate kicks a live refetch. New RateState (Fresh/Loading/
  Unavailable) replaces the Option<f64>; failures are tracked so the line
  says "unavailable" instead of spinning or showing an old value. Removed
  the disk seed and the persisted-rate write.
- config.rs: dropped the dead last_rate/last_rate_vs/last_rate_at fields
  and their read/write paths. Old configs still load (serde ignores the
  unknown keys); the fields are simply not written back.
- lib.rs: dropped the cold-start seed_from_disk call.
- GUI: balance hero fiat line renders a subtle "≈ …" while loading and a
  localized "rate unavailable" on failure, requesting a bounded repaint
  while a fetch is pending so the rate pops in on-screen. Pay/send amount
  preview shows nothing until a fresh rate lands.
- New goblin.home.fiat_unavailable string across all six locales.
- Tests: freshness-window bounds, is_fresh boundary, classify state
  machine (fresh/loading/unavailable), and config compat for the removed
  fields.
2026-07-05 04:38:24 -04:00
2ro ab2ac7c3ac Goblin: fix macOS build, import objc sel_impl for the deep-link bridge
objc 0.2's sel!/msg_send! macros expand to sel_impl!, which has to be in
scope. The cfg(target_os = macos) apple-events module never compiles on
Linux, so local cargo check missed it and only macOS CI caught it.
2026-07-05 04:01:28 -04:00
2ro 3db6375459 Goblin: apply node selection to the running wallet, no silent zero
Selecting a node in Settings > Wallet > Node moved the checkmark and
persisted the choice, but the live session never switched: the node
client is baked into the wallet instance at open time and was only
rebuilt on the next unlock, so a running wallet kept polling the old
(often dead) node. Owners onboarded on an older build still carry the
retired grinnode.live default in connections.toml, which now 502s, so
the wallet showed a stuck zero balance with no way to recover short of
a force-restart.

GRIM's own connection-settings UI reconnects on change (close + reopen);
the Goblin node picker only called update_connection, so this was a
Goblin regression, not inherited upstream.

- Wallet::reconnect_node swaps the live node client's URL + secret in
  place via w2n_client (no close/reopen, so no password re-entry),
  updates the runtime connection so the UI reflects the switch at once,
  clears the stale sync error, and wakes the sync thread to refresh
  against the new node. Wired into all three picker actions.
- node_url_secret factored out of create_node_client so open and live
  reconnect always resolve the same node for a given config.
- balance_hero no longer renders a bare 0 during a node outage: a pure
  balance_subline state machine picks updating / can't-reach-node /
  last-known-balance, so an unreachable node reads honestly instead of
  looking like an emptied wallet. New goblin.home.balance_stale string
  in all six locales (drift green).
- Unit tests for the balance state machine.
2026-07-05 03:38:08 -04:00
2ro d5ae136cf1 Goblin: route macOS goblin: link clicks to the send-review screen
The goblin: scheme was registered everywhere last night, but macOS is the one
platform that delivers a scheme click as a Carbon/Apple Event (kAEGetURL), not on
argv and not through any path winit or eframe surface, so on a Mac the click went
nowhere.

Install a kAEGetURL handler straight on the shared NSAppleEventManager at startup
(macOS only), pull the URL string out of the event, and feed it to on_data, the
exact same entry the desktop argv path uses. The per-frame Goblin router then lands
the pay URI on the prefilled review screen, identical to a scanned checkout QR or a
Linux argv link. This covers both a cold launch (event queued at start-up) and a
warm click (app already running).

The bridge is entirely cfg(target_os = "macos"): zero bytes on Linux, Windows and
Android (Linux binary size byte-for-byte unchanged). It talks to the Objective-C
runtime through the classic objc crate, which is already in the macOS build graph
via nokhwa/cocoa/metal/wgpu, so it adds no new dependency tree, only its own small
handler class. It registers its own Apple Event handler rather than touching the
NSApplicationDelegate winit owns, so nothing winit does is clobbered.
2026-07-05 03:14:45 -04:00
2ro a7c2443f3b Goblin: proof-on-request payments (wallet side of the frozen contract)
Implements the Goblin-wallet half of the proof-on-request architecture
(magick payment infra spec section 4/5): the buyer's wallet threads a
native Grin payment proof per transaction, off by default and on only
when the scanned pay URI asks for it, and auto-delivers the proof to the
market and watcher on finalize. Person-to-person sends are unchanged.

W1 pay-URI parser (payuri.rs): PayUri gains proof/order/notify, each
fail-closed (a bad value drops to None and never blocks the payment).
proof is a grin1/tgrin1 slatepack-address shape check, order is an opaque
control-stripped routing key capped at 64 bytes, notify is an npub shape
check. Unknown-param forward-compat and the magick interop tests still
hold. 12 new parser tests.

W2 proof threading: WalletTask::NostrSend carries proof/order/notify; the
handler re-parses proof authoritatively into a SlatepackAddress and calls
w.send(a, Some(addr)) instead of the hard-coded None, setting
payment_proof_recipient_address. The order handle, watcher npub, and
amount persist in TxNostrMeta (new serde-default fields) before dispatch,
so a crash between send and finalize loses nothing.

W3 review indicator: the send-review screen shows a small "proof included"
row when proof mode is active. New t!() keys row_proof / row_proof_val in
all six locales (drift test green).

W4 proof delivery on finalize: when a finalized SEND was sent in proof
mode, the wallet publishes the two contract events: a plain unencrypted
kind-17 "payment sent" receipt to the app relays (payment-request tag =
order handle; proof and kernel deliberately omitted to avoid leaking the
buyer's sender address; flips the page to "detected", never "paid"), and
a NIP-59 gift-wrapped kind-17 rumor to the notify npub carrying the full
proof JSON plus a kernel-excess tag. Both are enrolled in the existing
crash-safe reconcile pass and retried until a relay accepts them.
wrapv3 gains wrap_kind() to gift-wrap an arbitrary rumor kind.

Matching is by the payment-request (invoice number) tag alone; the wallet
never learns magick's internal orderId. No merchant-side auto-receipt is
built: the rewritten spec has the buyer publish on finalize instead.
2026-07-05 03:01:04 -04:00
2ro ae0f36d287 Goblin: goblin: payment deep link opens straight to send review
Web "Open in Goblin" pay buttons fired generic nostr: URIs, which the
OS routed to whatever social client claims nostr:. Register a dedicated
goblin: scheme and route it (plus nostr: pay URIs) to the prefilled
send-review screen, the same destination as scanning a checkout QR.

Parser (payuri): accept goblin: alongside nostr: (same payload, either
scheme); add is_pay_uri() to tell a payment link from a slatepack.

Runtime: an argv/intent/socket payload that is a pay URI is stashed and
opened by the Goblin surface via SendFlow::from_deeplink, which shares
the exact apply_scan path the camera uses. Desktop cold + warm start
(argv + single-instance socket) and Android cold + warm (intent-filter)
covered.

Registration: Linux .desktop x-scheme-handler/goblin, Android goblin
intent-filter, macOS CFBundleURLTypes, Windows WiX HKCR URL protocol.
2026-07-05 01:53:18 -04:00
2ro 9f019edfeb Goblin: fix desktop clipboard (copy/paste lost the selection)
On desktop the Paste button in the wallet-restore seed entry did nothing, and
the mirror copy on the new-wallet creation screen was equally broken: a copied
recovery phrase never made it to the clipboard.

Root cause (inherited from GRIM, not a Goblin regression — the two clipboard
impls are byte-identical): copy_string_to_buffer / get_string_from_buffer each
created a fresh `arboard::Clipboard` and dropped it the instant the function
returned. On Linux (X11 AND Wayland) the clipboard selection is owned by the
live Clipboard instance, so dropping it immediately released ownership and the
copied text vanished before it could be pasted. Reproduced with arboard 3.6.1
on a Wayland session: fresh-instance-per-call read back empty; one persistent
instance across set+get read back correctly.

Fix: keep ONE long-lived `arboard::Clipboard` on the Desktop platform (behind a
Mutex, since the trait methods take &self and arboard's take &mut self), so our
process stays the durable selection owner. Also stop unwrap()-panicking on a
clipboard backend error — a failed copy/paste now logs instead of crashing.

Every clipboard button in the app routes through this one platform pair
(onboarding paste/copy, GRIM creation paste/copy, proof, slatepack message
entry, tx/receipt copy, receive copy, npub/nsec/backup copy, the shared
TextEdit paste), so this single change repairs the whole class at once.

Verified end-to-end: an #[ignore]d clipboard_roundtrip test drives the real
Desktop impl on the live session clipboard and asserts a 24-word phrase
survives copy -> paste (run with --ignored). It passes on Wayland now and would
fail under the old pattern.
2026-07-05 00:41:33 -04:00
2ro 093c5014ef Goblin: GoblinPay QR scans straight to review
When a scanned QR is recognized as a full GoblinPay payment payload (a
recipient key that decodes locally to a concrete pubkey AND a validated
amount), skip the recipient discovery/search step and open the review screen
prefilled with recipient, amount and memo. The merchant checkout QR already
carries everything needed to pay, so re-walking the recipient search was
redundant. The review screen still confirms the send unchanged (the user
holds-to-send); nothing auto-sends.

recognize_scan classifies the payload:
- Review: nprofile/npub/64-hex recipient + amount -> straight to review. A
  cheap LOCAL contact lookup supplies a friendly name; otherwise the short
  npub (no network resolution on this path). nprofile relay hints carry
  through as delivery targets.
- Search: a name/@handle (needs NIP-05 resolution), an amount-less key, or
  non-nostr text -> the pre-existing search-box path, with any amount/memo
  still prefilled.

This covers every scanner entry into the send flow (search-field scan icon
and the header scan-to-pay screen), so it works from anywhere the payment
scanner is reachable.

Unit tests on the pure recognize_scan / decode_recipient_key:
GoblinPay-uri-with-amount -> Review; key-without-amount -> Search;
name+amount -> Search; non-nostr -> Search; key decode variants.
2026-07-05 00:34:59 -04:00
2ro 65d8d0f7bd Goblin: language-aware news panel
Show the newest news article whose language matches the wallet's active
locale, falling back to the newest English article when none exists.

Language detection per article, in priority order:
1. An event language tag: NIP-32-style ["l","<code>","ISO-639-1"] or the
   bare ["l","<code>"] / ["lang","<code>"] (code is the tag's 2nd element),
   extracted in handle_news and persisted on NewsItem.lang.
2. A trailing "[xx]" marker on the title (case-insensitive, xx = ISO 639-1
   two letters), e.g. "2026-07-05 Welcome to Goblin [de]".
3. No marker anywhere = English.

Selection happens at the data layer (news_latest): the store stays
language-agnostic (still newest-per-d-tag, cap 8, edit-in-place). Among all
cached articles, filter by detected language, take the newest by created_at;
the "[xx]" marker is stripped from the displayed title. The app locale is
folded to its ISO 639-1 primary for matching (zh-CN -> zh).

The GOBLIN_FAKE_NEWS debug hook now injects an English + German sample so the
panel selection can be screenshotted per locale.

Unit tests: tag detection (both shapes), title-suffix detect + strip,
no-marker-means-English, zh-CN folding, locale match + English fallback,
newest-within-language-slice.
2026-07-05 00:34:47 -04:00
2ro d4dcbb115f i18n: translate lagging fallback strings and localize settings username hint 2026-07-04 23:13:41 -04:00
2ro 0bf87a6cff README: drop stale onion claim (Tor-exit only since 134), style sweep
Every relay, money path included, is reached over a Tor exit to its
clearnet host; the pinned-onion wording was Build <134. Em dashes
normalized per house style.
2026-07-04 22:21:33 -04:00
2ro a57121959a README: Build 136 news panel on Home
Latest article from the official Goblin news key (kind 30023) between
Send/Receive and Recent Activity; hidden when empty; latest-only with
edits-in-place.
2026-07-04 22:20:21 -04:00
2ro ba8e81ef5f Goblin Build 136 - home news panel, faster send confirmation
Home now shows the latest post from the Goblin news key (kind 30023) in
a card with tappable links; the panel stays hidden until a post is seen.
Desktop Home widens to use the available space. Send confirms faster.
2026-07-04 20:07:13 -04:00
butler e492703a0c docs: mixnet references updated to Tor (build 134) 2026-07-04 19:36:03 -04:00
2ro 89791793ed Goblin: dedupe balance sync indicator
Remove the Build 135 balance-hero "Updating…" line. During a routine
sync it duplicated the header node card's "Syncing" status, so a funded
wallet showed two updating indicators at once. Keep the established
"Balance updating…" line (zero-balance / first-sync case) and the header
status as the single sync indicators. Drop the now-orphan
goblin.home.updating key from all six locales (i18n drift green).
2026-07-04 12:10:52 -04:00
2ro 278a946980 chore: gitignore local release artifacts 2026-07-04 11:39:29 -04:00
2ro e8d71afc7e Goblin Build 135 - Tor credit, balance updating indicator, Nym sweep
- Third-party credits: replace the "Nym mixnet" row with "Tor (arti)" (0.43,
  linking the arti repo), matching the other credit rows' hardcoded style. No
  locale strings existed for it (credit labels are hardcoded, not localized).
- Balance: show a quiet "Updating…" line under the balance while the node is
  still warming, reusing the existing wallet.syncing() signal and the
  balance-updating line's muted style; it clears once the node is synced. New
  t!() key goblin.home.updating added to all six locales (i18n drift green).
- Nym sweep: Cargo.toml package description "Nym mixnet" -> "Tor". The only
  other user-facing Nym reference was the credit above. Remaining references
  are the deliberately-dormant `nym` feature/module and internal code comments,
  left as-is; README and all locale values were already Tor.
2026-07-04 06:15:44 -04:00
2ro 1f36631777 Goblin: retire Nym from the public toolchains + drop internal test harnesses
- CI (release.yml/build.yml): remove fetch-nym + AWS_LC_SYS_PREBUILT_NASM;
  the default cargo build --release is Tor/arti only now (nip44 fetch kept).
- Cargo.toml: nym-sdk is optional behind the `nym` feature so the default
  build never pulls it.
- Delete the dead Nym-era probes (xrelay_smoke, connect_timing,
  tunnel_measure) that referenced the removed Nym API and broke `cargo test`.
- Untrack the internal E2E harnesses (e2e.rs, nostr_e2e.rs) via .gitignore;
  gate `mod e2e` behind the `e2e-internal` feature so clones still compile.
- Refresh stale Nym comments in build.rs and the build scripts.
2026-07-04 05:59:56 -04:00
2ro 1e55ef5dfb Goblin Build 134 - Tor delivery fixes (drop the flapping onion)
build133 shipped the Tor transport but two delivery bugs remained; this
fixes both so payments resolve through confirmations again.

- Drop the pinned relay .onion. It was a single fragile hidden-service hop
  shared by every wallet; under load it flapped (WebSocket 1006), and when
  it dropped mid-handshake a payment stalled after the first gift-wrap --
  you'd get the incoming alert but the money never confirmed. Every relay is
  now reached over the Tor exit to its clearnet host: your IP still stays
  hidden from the relay, with no fragile onion hop to wedge. The has-onion
  gate that rejected an onion-less relay list is removed, and the pinned
  candidate pool carries no onion/exit fields.

- Tor-friendly relay set. Defaults/fallbacks had included relays that refuse
  Tor-exit connections (damus, nos.lol); wallets that fell back to them
  couldn't send at all. Defaults and the pinned pool are now
  relay.floonet.dev, relay.0xchat.com, offchain.pub.

- confirm-before-sent no longer false-fails across a relay reconnect (a
  confirmed-received wrap is treated as sent-pending instead of re-dispatched,
  which had duplicated wraps and hung the spinner); a v3 gift-wrap unwrap
  failure no longer silently drops the message.

Validated over Tor with two funded wallets on two independent mainnet nodes:
connect in seconds, send to finalize in 8s.
2026-07-04 05:33:10 -04:00
2ro 03c1770892 README: update transport description from Nym mixnet to Tor
The README still described the old Nym-mixnet transport, including a
reference to src/nym/sidecar.rs which no longer exists post-switch.
Update the intro, feature list, payment-travel diagram, and build
instructions to describe the current embedded-Tor (arti) transport:
in-process, dialed at the relay's pinned .onion, no sidecar, no sibling
checkout needed for the default build.
2026-07-04 03:51:21 -04:00
2ro 3c32474f75 docs(internal): Tor transport redesign plan + payment-feel report + forum post 2026-07-04 03:35:33 -04:00
2ro 30c0ed9a12 Goblin Build 133 - Tor transport (replaces the Nym mixnet)
The wallet's private transport moves from the Nym mixnet to embedded Tor
(arti, copied from GRIM's engine): it dials the relay's pinned .onion, so
the relay never learns your IP, while the relay + NIP-59 gift-wrap hide the
rest - content, sender, and (via a relay-side randomized release) timing.
The Grin node stays on the clear internet as before.

Why leave the mixnet: the Nym free-tier bandwidth this depended on is being
removed upstream (the grant expires at UTC midnight; the paid path requires
holding NYM tokens), so a payments wallet can't stand on it. Tor is
unmetered, embedded in-process on mobile, faster where users wait, and
lighter on the battery.

Preserved intact: the confirm-before-sent guard, relay-gated readiness, and
the lazy warm-on-activity node polling. src/nym/ is feature-gated off (arti
and nym-sdk can't share one binary); full removal is a follow-up.
2026-07-04 03:35:29 -04:00
2ro 22bf3359f5 nostr: revert the money-path split — all nostr on the mixnet (as prior builds), keep confirm-before-sent 2026-07-03 22:50:01 -04:00
2ro 53e18f06c7 nostr: money-path split — slatepacks over the mixnet exit, everything else clearnet
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).
2026-07-03 22:30:25 -04:00
2ro c78d7b0e60 nym: fast fresh-gate probe budget — dead exits condemned in 10s, not ~32s
Measured (30 cold trials at the gateway-race commit): the worst cold-start
outliers (51s, 72s) were dominated by DEAD-EXIT condemnation cost, not the
gateway. A fresh tunnel on a blackholing exit burned probe_fresh's doubled
patient budget (2 calls x 2 rounds x 8s ~= 32s) before reselecting.

Split the liveness probe into two budgets over a shared probe_with_budget:
- probe() (established tunnels: watchdog keepalive + condemnation checks):
  UNCHANGED - 8s x 2 rounds, worst 16s, same patience as before.
- probe_fresh() (gating a just-built tunnel before publish): 5s x 2 rounds
  x 1 call = 10s worst. 5s is 4.2x the measured worst healthy probe
  (successful probes: min 465ms / median 774ms / max 1197ms across 15
  trials), so the build130 false-condemn regression stays far away.

DEAD-EXIT declaration drops from ~36s measured to ~13-17s total; the warn
now logs the isolated probe_ms. Also adds the gateway-race observability the
verification flagged (race START / WON-by-draw + ms / loser reaped-connected
vs dropped-pending / survivor-after-error), so the race is visible in
[timing] logs instead of inferred statistically.

Verify on the next emulator pass: DEAD EXIT probe_ms <= ~10s, zero false
DEAD EXIT across ~15 cold trials, established keepalive/condemn cadence
unchanged.
2026-07-03 15:55:37 -04:00
64 changed files with 9613 additions and 6999 deletions
-23
View File
@@ -1,23 +0,0 @@
name: Fetch patched nym SDK
description: >
Clone the patched nym workspace from our own mirror
(git.us-ea.st/GRIN/nym, branch `goblin` = upstream nymtech/nym @ b6eb391 +
Goblin's Android webpki-roots patch) into ../nym, so the
`nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves.
Self-hosted: no upstream-GitHub fetch and no patch-apply step.
runs:
using: composite
steps:
- name: Clone patched nym
shell: bash
run: |
set -euo pipefail
DEST="$(dirname "$GITHUB_WORKSPACE")/nym"
if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then
echo "nym already present at $DEST"
exit 0
fi
rm -rf "$DEST"
git clone --branch goblin --depth 1 https://git.us-ea.st/GRIN/nym.git "$DEST"
echo "nym cloned from GRIN/nym@goblin -> $DEST"
-10
View File
@@ -1,12 +1,6 @@
name: Build
on: [push, pull_request]
# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native
# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't
# need NASM installed; harmless on Linux/macOS.
env:
AWS_LC_SYS_PREBUILT_NASM: 1
jobs:
linux:
name: Linux Build
@@ -15,8 +9,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
# nym-sdk is a path dep on ../nym; materialize it before building.
- uses: ./.github/actions/fetch-nym
# nip44 is a path dep on ../nip44; materialize it before building.
- uses: ./.github/actions/fetch-nip44
- name: Release build
@@ -29,7 +21,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nym
- uses: ./.github/actions/fetch-nip44
- name: Release build
run: cargo build --release
@@ -41,7 +32,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: ./.github/actions/fetch-nym
- uses: ./.github/actions/fetch-nip44
- name: Release build
run: cargo build --release
-5
View File
@@ -25,8 +25,6 @@ permissions:
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
# aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM.
AWS_LC_SYS_PREBUILT_NASM: 1
jobs:
linux:
@@ -39,7 +37,6 @@ jobs:
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- uses: ./.github/actions/fetch-nip44
- name: Build
shell: bash
@@ -65,7 +62,6 @@ jobs:
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- uses: ./.github/actions/fetch-nip44
- name: Build
shell: bash
@@ -113,7 +109,6 @@ jobs:
with:
ref: ${{ inputs.tag || github.event.release.tag_name }}
submodules: recursive
- uses: ./.github/actions/fetch-nym
- uses: ./.github/actions/fetch-nip44
- name: Build both architectures
run: |
+7
View File
@@ -7,6 +7,9 @@ android/keystore
android/keystore.asc
android/keystore.properties
android/*.apk
android/*.apk.sha256
android/*.AppImage
android/*.AppImage.sha256
android/*sha256sum.txt
/.idea
.DS_Store
@@ -29,3 +32,7 @@ screenshots/
.foreign_api_secret
grin-wallet.log
grin-wallet.toml
# Internal E2E harnesses — reference funded wallets / live relays; kept on disk,
# untracked (mod e2e is gated behind the `e2e-internal` feature)
src/wallet/e2e.rs
tests/nostr_e2e.rs
Generated
+2199 -4486
View File
File diff suppressed because it is too large Load Diff
+52 -15
View File
@@ -2,7 +2,7 @@
name = "grim"
version = "0.3.6"
authors = ["Ardocrat <ardocrat@gri.mw>"]
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you."
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and Tor handled for you."
license = "Apache-2.0"
repository = "https://code.gri.mw/GUI/grim"
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
@@ -31,6 +31,20 @@ lto = true
codegen-units = 1
panic = "abort"
[features]
## Default build uses the Tor transport only. The `nym` feature gates the dormant
## mixnet path (src/nym/). Cargo resolves OPTIONAL deps into the graph too, so
## nym-sdk cannot merely be `optional` — it links a different libsqlite3-sys than
## arti (a native-lib `links` conflict Cargo rejects at resolution). The nym
## path-deps are therefore commented out below; the module code is retained on
## disk but building `--features nym` requires restoring them (and drops arti —
## the two transports cannot coexist in one binary, which is why Tor replaced Nym).
default = []
nym = []
## Compiles the internal E2E harness (src/wallet/e2e.rs); off by default and the
## file is untracked, so a fresh clone builds without it.
e2e-internal = []
[dependencies]
log = "0.4.27"
@@ -108,6 +122,9 @@ nip44 = "0.3.0"
## Only to construct the key types the `nip44` crate takes: nostr-sdk pins
## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs.
secp256k1 = "0.31"
## Scrub the NIP-44 conversation-key buffers (raw ECDH secret) from memory on
## drop in wrapv3.rs. Already a transitive dep; pulled in directly here.
zeroize = "1"
async-wsocket = "0.13"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
@@ -124,18 +141,34 @@ rustls = { version = "0.23", features = ["ring"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
webpki-roots = "1"
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary).
## Tor — embedded arti (the DIALING half only: connect OUT to the relay's .onion,
## and to clearnet HTTP hosts through a Tor exit). Copied from our sister wallet
## GRIM's proven, shipping engine. Two choices inherited VERBATIM from GRIM: arti
## 0.43 across the family, and the native-tls Tor runtime (TokioNativeTlsRuntime),
## NOT rustls — this deliberately sidesteps the rustls/ring crypto-provider
## conflict fought during the Nym era (our relay/HTTP rustls still uses ring, see
## lib.rs; arti's own TLS is native-tls and never touches the rustls provider).
## `static` vendors openssl (self-contained Android/cross builds, as GRIM ships);
## `onion-service-client` enables dialing .onion. We drop GRIM's `pt-client`
## (bridges) and `onion-service-service` (hosting) — Goblin only dials.
arti-client = { version = "0.43.0", features = ["static", "onion-service-client"] }
tor-rtcompat = { version = "0.43.0", features = ["static"] }
## Nym mixnet — DORMANT since the Tor transport swap. The mixnet path (src/nym/)
## is retained on disk but its deps are COMMENTED OUT, because arti's `tor-dirmgr`
## needs libsqlite3-sys 0.34 (rusqlite 0.36) while nym-sdk's credential-storage
## needs libsqlite3-sys 0.30 (sqlx) and BOTH link the native `sqlite3` library —
## Cargo forbids two packages linking the same native lib, and it rejects this at
## RESOLUTION even for optional/unused deps. The two transports therefore cannot
## coexist in one binary (exactly why Tor replaced Nym). To build the old path,
## restore these three deps and build `--features nym` (which then drops arti).
## Full deletion is a later phase; for now the code stays on disk for reference.
## Path deps into the local nym checkout, PINNED at rev
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local
## commit "http-api-client: preconfigured webpki roots on Android". Do not
## float the checkout past that rev without re-verifying the Android build.
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit —
## the single-network-requester SPOF is structurally gone (plan G14).
smolmix = { path = "../nym/smolmix/core" }
## mix-dns wire codec. Already in the dependency graph via nym-http-api-client
## (Cargo.lock), so we reuse it instead of vendoring a DNS encode/parse.
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
## webpki roots on Android").
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
# smolmix = { path = "../nym/smolmix/core" }
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
## NIP-98 payload hashing
sha2 = "0.10.8"
@@ -152,6 +185,11 @@ nokhwa = { version = "0.10.10", default-features = false, features = ["input-msm
[target.'cfg(target_os = "macos")'.dependencies]
nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] }
## Objective-C runtime shim for the macOS `goblin:` deep-link bridge (src/lib.rs,
## mac_deeplink). Already in the macOS build graph via nokhwa/cocoa/metal/wgpu, so
## this pulls no new crate — it just lets us name it directly. Not compiled on any
## other platform.
objc = "0.2"
[target.'cfg(not(target_os = "android"))'.dependencies]
env_logger = "0.11.3"
@@ -197,9 +235,8 @@ base64 = "0.22"
sha2 = "0.10"
hex = "0.4"
serde_yaml = "0.9"
## G14 transport-validation harness (tests/xrelay_smoke.rs): re-expose deps that
## already live in the main graph so the smolmix transport can be exercised and
## its tunnel/mix-dns logs captured. No new compiles — same versions unify.
## Re-expose deps that already live in the main graph so the E2E harness can be
## exercised and its logs captured. No new compiles — same versions unify.
log = "0.4.27"
env_logger = "0.11.3"
rustls = { version = "0.23", features = ["ring"] }
+16 -14
View File
@@ -4,27 +4,30 @@
# Goblin
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) - confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through the [Nym mixnet](https://nym.com)**. Relays only ever see ciphertext never the amount, the sender, or the recipient — and the mixnet hides who is talking to whom at the network layer.
Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through [Tor](https://www.torproject.org)**. Relays only ever see ciphertext - never the amount, the sender, or the recipient. Tor hides your IP from the relay; the relay and encryption hide the rest - content, sender, timing.
Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top.
## What it does
- **Send to people** pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
- **Manual slatepacks too** when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
- **In-app identity** a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
- **Private by construction** — GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through the [Nym mixnet](https://nym.com), so who-pays-whom never touches the clear net. The GRIN node connection — block sync and broadcasting your transaction — is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
- **Configurable amount pairing** — show balances against a world currency, Bitcoin, or sats (rates fetched over the mixnet), or turn the preview off.
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
- **Send to people** - pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over Tor and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
- **Manual slatepacks too** - when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
- **Open-to-pay links** - a `goblin:` or `nostr:` pay link, or a scanned checkout QR, opens the wallet straight to a prefilled review screen (recipient, amount and note filled in, ready to hold-to-send) on desktop, macOS and Android.
- **Proofs on request** - payments can include a native Grin payment proof when the payment request asks for one, off by default, shown on the review screen. An ordinary person-to-person send carries none.
- **In-app identity** - a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
- **Private by construction** - GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through [Tor](https://www.torproject.org), so who-pays-whom never touches the clear net. The GRIN node connection - block sync and broadcasting your transaction - is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
- **Configurable amount pairing** - show balances against a world currency, Bitcoin, or sats (rates fetched over Tor), or turn the preview off.
- **News on Home** - the latest post from the official Goblin news key (a [kind 30023](https://nostrbook.dev/kinds/30023) long-form article) appears on the Home screen in your wallet's language, falling back to English; it stays hidden when there is nothing to show, and only ever shows the newest article.
- **Cross-platform** - Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
## How a payment travels
```
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
Nym mixnet (5-hop)
Tor
┌─────────────┴─────────────┐
your relays recipient's DM relays (kind 10050)
@@ -33,7 +36,7 @@ Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN nod
recipient ◀──unwrap, verify seal author, apply slatepack
```
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)).
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)). Tor hides your IP from the relay; the relay and the encryption above hide the rest - content, sender, timing.
Both parties only need one relay in common. The default set is the Goblin relay plus large public relays (`relay.damus.io`, `nos.lol`), and the set is editable in **Settings → Relays**.
@@ -41,16 +44,15 @@ Both parties only need one relay in common. The default set is the Goblin relay
### Desktop (Linux / macOS / Windows)
Goblin links the [Nym mixnet](https://nym.com) SDK **in-process** — the wallet is a single self-contained binary, no sidecar. The SDK builds from a sibling `../nym` checkout (a pinned nym tree with a small Android TLS patch):
Goblin links [Tor](https://www.torproject.org) **in-process** via [arti](https://gitlab.torproject.org/tpo/core/arti) - the wallet is a single self-contained binary, no sidecar, nothing separate to install:
```
git clone --branch goblin https://git.us-ea.st/GRIN/nym ../nym
git submodule update --init --recursive
cargo build --release
./target/release/goblin
```
Goblin's identity and payment traffic nostr relays, NIP-05 lookups and price fetches — is routed over the mixnet through a network requester (the default is baked into `NETWORK_REQUESTER` in `src/nym/sidecar.rs`); the SDK's SOCKS5 listener is run in-process on `127.0.0.1:1080`. If something is already listening there, Goblin reuses it. The GRIN node connection (block sync and transaction broadcast) is **not** mixed — it connects directly, as it carries only public chain data that isn't linked to your wallet.
Goblin's identity and payment traffic (nostr relays, NIP-05 lookups and price fetches) rides Tor: every relay, the money-path relay included, is reached over a Tor exit to its ordinary clearnet host. The GRIN node connection (block sync and transaction broadcast) is **not** routed through Tor: it connects directly, as it carries only public chain data that isn't linked to your wallet.
### Android
@@ -64,7 +66,7 @@ Install the Android SDK / NDK, then from the repo root:
## Identity service (`goblin-nip05d`)
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable and fully anonymous without it. Avatars aren't stored or served clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred - on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable - and fully anonymous - without it. Avatars aren't stored or served - clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
## License
+10
View File
@@ -59,6 +59,16 @@
<data android:pathPattern=".*\\.slatepack" />
</intent-filter>
<!-- Goblin payment deep link: web "Open in Goblin" buttons and
goblin: QR/links open the wallet straight to the send-review
screen. BROWSABLE so a browser/click can resolve it. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="goblin" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="grim" />
</activity>
@@ -235,6 +235,16 @@ public class MainActivity extends GameActivity {
String action = intent.getAction();
// Check if file was open with the application.
if (action != null && action.equals(Intent.ACTION_VIEW)) {
Uri data = intent.getData();
String scheme = data != null ? data.getScheme() : null;
// Goblin payment deep link (goblin: / nostr:): pass the URI text
// straight to native code, which routes it to the send-review flow.
// These are NOT files, so they must skip the file-descriptor path.
if (scheme != null && (scheme.equalsIgnoreCase("goblin")
|| scheme.equalsIgnoreCase("nostr"))) {
onData(data.toString());
return;
}
Intent i = getIntent();
i.setData(intent.getData());
setIntent(i);
+3 -3
View File
@@ -56,9 +56,9 @@ fn main() {
.expect("failed to execute git config for hooks");
}
// Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar
// subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There
// is nothing transport-related to build or embed here.
// Goblin's private transport is Tor via embedded arti (see src/tor/), linked
// in-process — no sidecar subprocess and no bundled/embedded helper binary.
// There is nothing transport-related to build or embed here.
// Embed the Goblin icon into goblin.exe so Explorer, the taskbar and Alt-Tab
// show it even for the bare exe (the .msi shortcuts already carry it). No-op
+238
View File
@@ -0,0 +1,238 @@
# Goblin Privacy Transport Redesign
**Status:** Decided — implementation plan · **Date:** 2026-07-04 · **Author:** Architecture
**Scope:** Replace the Nym-mixnet transport that carries Goblin's Nostr traffic with embedded Tor, GRIM-style, and rebuild the one privacy property Tor lacks (send/receive timing unlinkability) on our own relay.
**Hard constraints (owner):** No clearnet money path · No NYM tokens · No rented/metered privacy bandwidth · In-process only (no sidecar daemon, no system VPN/TUN — must work on Android and iOS).
---
## The decision (read this first)
**Goblin is dropping the Nym mixnet and returning to Tor, embedded in-process, exactly the way our sister wallet GRIM already ships it.** The wallet keeps everything else it does today — payments are still gift-wrapped Nostr messages that our own relay stores and forwards, so a recipient can be paid while offline. The only thing that changes is the private pipe *underneath* the relay connection: instead of tunnelling to the relay across the Nym mixnet, the wallet dials the relay's **pinned `.onion` address over embedded arti** (Tor written in Rust).
We do not invent our own Tor engine. We **copy GRIM's** — a proven ~1,300-line engine in four files (`grim/src/tor/`) that already runs in production on desktop and Android. And because a mixnet's real gift was hiding the *timing* link between a sender uploading a payment and a recipient downloading it — something Tor alone does not do — **we rebuild that timing privacy on our own relay**: it holds each incoming gift-wrap and releases it to the recipient after a short randomized (Poisson) delay. Same fuzzing a mixnet performs, done by the one server we fully control, unmetered, and unable to collapse the way rented mixnet bandwidth did.
This trades exactly one thing: a real distributed mixnet resists a *global passive adversary* who can watch the whole internet at once. A single relay plus Tor does not. That adversary is out of scope for a low-value payments wallet, and Tor never claimed to stop it either. In return we get a transport that is mature, free, unmetered, has a huge anonymity set, is lighter on the battery, and — measured where the user actually waits — **faster**.
---
## Why we are leaving the mixnet: confirmed root cause
This is not a transient outage we can wait out. It is **removal by design**, traced to Nym's own source:
- A credential-less Nym client is granted a hard-coded `FREE_TESTNET_BANDWIDTH_VALUE = 64 GiB`, and that grant **expires at the next UTC-midnight rollover** (`ecash_today()`).
- An entry gateway only honours that free grant while `enforce_zk_nyms = false`.
- Public entry gateways are flipping `enforce_zk_nyms = true` **network-wide** as Nym rolls out paid, NYM-token-gated "zk-nym ticketbook" bandwidth (≈ 225 NYM for ~25 GB).
So the free tier the wallet floated on is **testnet scaffolding Nym is actively deleting**, and its failure recurs *on a schedule*. It went dark on us more than once. The only supported replacement — paid ticketbooks — **requires holding NYM tokens** (an owner red line) and offers no sats/Lightning purchase path. A payments wallet cannot stand on a foundation that disappears at midnight and can only be rented back with a specific speculative token. The mixnet, as we were able to consume it, is a dead end.
Tor has none of these properties. It is free, unmetered, has no token, no bonding, no grant to expire, and the largest anonymity set of any deployed privacy network. It runs in-process on a phone, and GRIM has already proven the whole embedded path.
---
## Threat model: what we are actually protecting
We reason about leakage at six levels, sender → relay → receiver:
| Level | Leak | Status under the Tor plan |
|---|---|---|
| **L1 network** | sender/receiver IP as seen by the relay and on-path observers | **Covered by Tor** — an onion connection has no exit node, so the relay and every hop see a Tor address, never the phone's IP. |
| **L2 timing** | correlation of send-time with receive-time (the mixnet's core property) | **Rebuilt on our relay** — Poisson release delay (see below), plus NIP-59 timestamp backdating already in the wallet. |
| **L3 volume** | message-size correlation | NIP-44 v2 padding blunts; gift-wraps are already near-uniform at payments volume. |
| **L4 relay-visible** | recipient p-tags, kinds, subscription filters, connection cadence | NIP-59 gift-wrap blunts; unchanged by the transport swap. |
| **L5 content** | message plaintext | **Already solved** — NIP-44 v2 inside NIP-59 gift-wrap (kind 1059). |
| **L6 long-term intersection** | repeated-pattern deanonymization | NIP-59 blunts; unchanged. |
Our **realistic adversary** is the relay operator, ISPs, near-endpoint observers, and chain-analysts — **not a global passive adversary (GPA)**. GPA-resistance is explicitly out of scope for a low-value payments wallet; Tor itself states plainly that it "does not defend against" an attacker who can watch **both ends** of a circuit. We inherit that boundary knowingly and, for the adversary that actually matters, we cover every level.
---
## The architecture
The whole change is contained because Goblin already isolates its transport behind one trait. Payments still ride the relay-mediated, store-and-forward model unchanged. Four things move:
**1. The relay hosts an onion service.** Plain, mature **system Tor** runs on the relay's own server (`torrc`: `HiddenServiceDir` + `HiddenServicePort 443 127.0.0.1:443`) and forwards the onion straight to the relay's existing secure-websocket port. This is battle-tested C-Tor doing the hosting half — the strongest, most-audited part of the Tor codebase — and it replaces the custom `floonet-mixexit` byte-pipe binary entirely.
**2. The wallet dials that onion over embedded arti.** Goblin only needs the **dialing half** of Tor — connect *out* to the relay's onion. It never hosts a service (that is what makes it simpler than GRIM, which hosts an onion to *receive*). `arti-client`'s `TorClient::connect()` returns a `DataStream` that implements `AsyncRead + AsyncWrite` — a drop-in for the byte source the wallet feeds to its websocket layer today.
**3. We copy GRIM's Tor engine rather than write our own.** GRIM's `src/tor/` is ~1,300 lines across four files (`config.rs`, `mod.rs`, `tor.rs`, `types.rs`) and is already in production. Two technical choices we inherit verbatim because GRIM already paid for them:
- **arti 0.43 across the whole arti family** (`arti-client`, `tor-rtcompat`, `tor-config`, `tor-hsservice`, `tor-hsrproxy`, `tor-keymgr`, `tor-llcrypto`, `tor-hscrypto` — all `0.43.0`).
- **The native-tls Tor runtime** (`TokioNativeTlsRuntime`), **not rustls**. This deliberately sidesteps the rustls crypto-provider (ring) conflict we fought all through the Nym era. We take GRIM's known-good TLS path and never re-open that wound.
**4. The two code seams that carry private traffic switch to Tor; the money node does not.** Exactly two paths leave the device with anything sensitive on them: the **relay websocket**, and the **one HTTP helper** that carries all the small lookups (names at `goblin.st`, relay hints, the pinned-pool refresh, price, avatars). Both re-route through arti. The **Grin blockchain node stays on the clear internet exactly as today, unchanged** — it never sees who is paying whom, only opaque transaction data, and Tor-wrapping it would buy nothing but latency.
**The pin lives where the old Nym exit pin lived.** Our relay-pool gist already carries a per-relay field for exactly this, and its parser is deliberately tolerant of unknown fields (no `deny_unknown_fields`, `version` stays `1`). So we add an `onion` address next to each relay entry and **older builds simply ignore it** — no flag day, no schema break. The plumbing that already resolves the co-located Nym `exit` for a relay URL generalizes one-for-one to resolve its `onion`.
---
## Recovering timing privacy: Poisson on the relay
Here is the one property we cannot get from Tor for free, and how we rebuild it.
A mixnet's genuine value was **timing unlinkability**: even someone who can see traffic near both ends cannot match "this sender uploaded at 10:01:03" to "that recipient downloaded at 10:01:04," because the mixnet deliberately shuffles and delays messages so the two events do not line up. Tor is low-latency by design and does *not* do this — a payment flows through as fast as the circuit allows.
We rebuild it in the one place we fully own: **our relay holds each incoming gift-wrap and releases it to the recipient after a randomized, exponentially-distributed (Poisson) delay.** This is the same fuzzing a mixnet performs, collapsed onto a single hop we operate ourselves — unmetered, always on, and immune to the rented-bandwidth failure that just killed Nym for us.
**The elegant part: this costs the user nothing they can see.** The sender's on-screen "Sent" clears the moment the relay **confirms it holds the message** — not when the recipient receives it. Delivery to the recipient is already asynchronous and invisible (they might be offline for hours). The Poisson delay lands *entirely inside that already-invisible gap.* We buy back the mixnet's timing privacy at **zero visible latency cost**. And it stacks on top of a fuzz the wallet already applies: NIP-59 backdates every gift-wrap's timestamp by a random offset of up to two days, so even the timestamps on the wire are decorrelated from real send time.
**The honest trade-off, stated plainly:** a real distributed mixnet spreads its mixing across many independent nodes, which is what lets it resist a global passive adversary watching the entire network at once. Our single relay plus Tor does not — a party who could simultaneously observe our relay *and* the recipient's Tor guard *and* correlate the Poisson-delayed release could, in principle, still link the two ends. That is the global-adversary threat, it is out of scope for a Grin payments wallet, and Tor never defended against it either. For the adversary who actually exists — the relay operator, an ISP, a network snoop, a chain-analyst — relay-side Poisson closes the timing gap. We give up one theoretical guarantee and gain reliability, speed, and battery.
---
## What we must preserve (load-bearing, transport-agnostic)
Three things in today's wallet are not Nym-specific and must survive the swap intact.
**1. The "connection is genuinely live and carrying traffic" readiness signal.** The UI carefully refuses to show "Connected" until a relay is *actually subscribed and carrying traffic on the current tunnel*, not merely until the pipe opened (`transport_ready()` / `warm_up()` / relay-gated generation counter in `src/nym/nymproc.rs`). This is what prevents a reassuring-but-false "Connected" over a tunnel that cannot yet deliver. **Keep the mechanism; re-point it at Tor** — readiness becomes "arti has bootstrapped, the onion circuit is up, and a required relay is subscribed on it."
**2. The read-back confirm on sending (keep verbatim).** The wallet does **not** report a payment "Sent" on a transport-write success. It performs a genuine read-back: after publishing, it polls the target relays for the event id until one confirms it actually **holds the gift-wrap**, or a timeout is hit — in which case it surfaces failure so the caller retries instead of silently dropping money (`src/nostr/client.rs`, the SILENT-LOSS GUARD loop). This is pure money-safety and is completely transport-agnostic. **Keep the logic exactly as written.** Only the *comments* that say "over the scoped Nym exit / mixnet transport" need their wording generalized to "the transport"; the guard itself does not change a line, and it is in fact the very mechanism that makes relay-side Poisson safe (the sender is told "Sent" precisely when the relay confirms it holds the message).
**3. User-facing copy that says "Nym" / "Mixnet."** The strings need rewording to Tor across all six locales (`locales/en.yml` plus `tr`, `fr`, `de`, `ru`, `zh-CN`): `connected_nym` ("Connected over Nym" → "Connected over Tor"), `connecting_nym`, `nym_ready`, `mixnet_routing`, `network_value` ("MW + Nym mixnet + nostr"), `privacy_value` ("Mimblewimble + Nym"), `over_mixnet`, `rates_note`, `send_like_message_body`, `row_delivery_val`, `row_privacy_val`, and the onboarding `intro` that describes "a five-hop network." These become plain, honest Tor language — see the forum post at the end for the tone.
---
## Mobile reality (told straight)
**Android is solved.** GRIM already ships embedded Tor on Android and the recipe is copyable. It comes down to two things: **build Tor into the app's native library** (arti compiles into the same `.so` the rest of the Rust already lives in — no separate process, no sidecar), and **set a few environment variables before the Rust runtime starts.** The critical one is `ARTI_FS_DISABLE_PERMISSION_CHECKS=true` (GRIM sets it in `MainActivity.java` before loading native code): arti's `fs-mistrust` layer normally refuses to start if its state directory has "too-open" Unix permissions, and Android's app-sandbox filesystem always trips that check — so without this flag Tor simply never boots on a phone. This is a known, small, copy-paste fix, not a research problem.
**iOS is green-field and needs its own spike.** The arti library should compile for iOS — nothing about it is Android-specific — but nobody on our side has shipped it there yet, so we treat it as unproven until we have. Two things to flag going in: (a) we run **without pluggable-transport bridges** on iOS, because the platform won't let an app spawn the helper processes those need — plain Tor only, which is fine for our reach-the-relay use case; and (b) the same `fs-mistrust` and state-directory questions need answering against iOS's sandbox. **Action: an iOS Tor spike is a named prerequisite before we claim iOS support.** Android does not wait on it.
---
## Integration surface (code anchors)
The transport is isolated behind one trait, so this is a contained swap, not a rewrite.
| Seam | Location today | Change |
|---|---|---|
| Transport trait | `nostr-relay-pool` `WebSocketTransport::connect(url, mode, timeout) -> (WebSocketSink, WebSocketStream)`; impl `NymWebSocketTransport` at **`src/nym/transport.rs:47`** | New arti-backed impl in a new `src/tor/`, engine copied from GRIM. |
| Byte source | Nym `open_stream` at **`src/nym/streamexit.rs`** (returns `AsyncRead + AsyncWrite`) | Swap for arti `TorClient::connect(<onion>:443)``DataStream`. Keep the rest of the path. |
| TLS + ws wrap | `client_async_tls(url, stream)` at **`src/nym/transport.rs:126` / `:158`** (SNI = relay host) | **Unchanged.** |
| Injection point | `Client::builder().websocket_transport(...)` in **`src/nostr/client.rs`** | Inject `TorWebSocketTransport`. |
| HTTP chokepoint | `http_request_bytes()` **`src/nym/mod.rs:71`** / `http_request()` **`src/nym/mod.rs:111`** — carries NIP-05 / name authority (`src/nostr/nip05.rs`), NIP-11 hints, pool gist, price, avatars | Re-route through arti: Tor→onion for the `goblin.st` name authority (pin a second onion); Tor→clearnet for the gist + price + avatars. **Grin node stays clearnet as today.** |
| Pin plumbing (forward-safe) | `PoolRelay` at **`src/nostr/pool.rs:86`**; `exit: Option<String>` at **`:105`**; serde tolerant (no `deny_unknown_fields`); `exit_for()` **`:157`**, `exit_for_host()` **`:170`**, `has_exit()` **`:187`** | Add an `onion` field beside `exit` in the gist **without breaking old builds**; keep `version:1`. `exit_for*`/`has_exit` generalize to `onion_for*`/`has_onion`. |
| Readiness lifecycle (**preserve**) | `warm_up()` **`src/nym/nymproc.rs:107`**, `transport_ready()` relay-gated readiness **`:127``:135`** | Re-point at Tor: bootstrapped + onion up + relay subscribed. Keep the gating semantics. |
| Confirm-before-sent (**preserve verbatim**) | SILENT-LOSS GUARD read-back loop, doc at **`src/nostr/client.rs:63`** | Unchanged logic. Only reword the Nym-specific comments to "the transport." |
| Footprint retired | `src/nym/*` = **2,842 lines** (`nymproc` 1073, `dns` 662, `streamexit` 465, `mod` 411, `transport` 231) + `nym-sdk`/`smolmix` path-deps + `floonet-mixexit` | Deleted once Tor is proven. Net code likely **shrinks** — most of `nymproc` exists only to fight Nym flakiness (gateway race / probe / condemn). |
The live pool already ships the floonet relay inline (`pool.rs:69` pins `wss://relay.floonet.dev` with its Nym `exit`). We add its `onion` alongside, in the same gist, and roll forward.
---
## What the wallet will feel like on Tor
An honest, moment-by-moment read on whether each everyday interaction gets faster, stays the same, or gets slower than it was on the mixnet. Short version: on pure speed, Tor is a lateral-to-favorable move everywhere the user actually waits.
**Receiving / listening — same-to-better, and never a spinner.** The wallet does not poll for incoming payments. It holds a **live, open subscription** to the relay, and payments are *pushed* down that already-open connection and processed with no human watching. That is invisible on either transport. The difference is that on Tor each pushed message arrives quicker (no per-hop mixing delay), and the deliberate Poisson privacy-delay hides inside the already-invisible delivery gap. Nothing the user sees changes; what is behind it is faster. **Verdict: same-to-better.**
**Sending — faster where it matters most.** The only part of a send the user actually watches is the marquee moment: find the recipient's relay, publish the wrapped payment, and wait for the relay to confirm it holds it. On the mixnet that moment paid three separate taxes — a per-message mixing delay, a fixed ~3-second stream-settle, and multi-fragment delivery lag. On Tor all three are gone. So this spinner should feel **shorter**, not longer. Everything after it — building the reply, finalizing, posting the finished transaction to the Grin node — is background work, and the node post is plain clear-internet exactly as before. **Verdict: faster where it matters most.**
**Invoices / requests — same-to-faster, dominated by the human.** There is no separate "invoice" message type on the wire; a request is the same gift-wrapped DM as a payment, just with a human approval gate in front of it. Transport latency matters even less here because a person is tapping "approve." **Verdict: same-to-faster, dominated by the human step.**
**Name and identity lookups — fast, unchanged in feel.** Resolving `name@goblin.st` is a single HTTP GET behind a short "Searching…" spinner — the one genuinely blocking lookup in the app. Everything else (reverse name lookups, avatars, relay hints, pool refresh, price) is cached, quiet background work the user never waits on. A warm keep-alive connection pool over Tor makes repeat lookups cheap. **Verdict: fast and unchanged in feel — provided we keep the connection warm.**
**Cold start — simpler, comparable, warm-able.** The mixnet needed *two* separate mixnet clients racing each other for bandwidth grants, plus a whole sequencer, just to get connected "in seconds instead of a minute." Tor is **one** bootstrap — dramatically simpler. It overlaps with app launch, so it is mostly invisible; its only visible edge is the very first send of a session if Tor has not finished bootstrapping yet, which would surface as a slightly longer first "Sending…." Warming the circuit at launch hides even that. **Verdict: simpler, comparable, warm-able.**
**Battery / always-listening — improves.** A persistent Tor circuit is lighter than a live mixnet client: there is no continuous cover-traffic machinery to run and no per-hop delay work to perform. The wallet's existing "the connection died, rebuild it" logic and its background/foreground handling map cleanly onto Tor circuits. **Verdict: battery should improve.**
**Net verdict.** On pure speed and feel, Tor is lateral-to-favorable everywhere the user actually waits — and better in the two places they wait most (sending and cold start). What we trade is not speed. It is the mixnet's global-adversary timing guarantee, and we largely rebuild even that, for the realistic adversary, with relay-side Poisson.
---
## Phased implementation plan
### Phase 0 — iOS + mobile spike (de-risk before commit)
**Goal:** prove the copied engine drops into the seam on the platforms we are less sure of.
**Tasks:** (a) stand up `arti-client` → connect a test `.onion:443``client_async_tls` → Nostr ws handshake, proving `DataStream` satisfies the transport seam on desktop; (b) confirm the Android recipe holds in a Goblin dev build (native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`); (c) **the iOS spike** — does arti compile and bootstrap inside the iOS sandbox, plain-Tor (no PT bridges), and where does `fs-mistrust` land.
**Exit criteria:** a Nostr ws session to the relay over an onion, in acceptable time, on a real Android device; a clear yes/no + punch-list for iOS.
**Risk:** low on desktop/Android (GRIM shipped it); iOS unknown, which is why it is Phase 0.
### Phase 1 — Onion service on the relay
**Goal:** the relay is reachable over a stable `.onion`.
**Tasks:** system-Tor `torrc` onion service on the floonet box fronting the relay's ws port; publish and pin the `onion` in the gist beside the existing entry; add a second onion for the `goblin.st` name authority. Enable Vanguards on the service side.
**Validation:** `torify` a plain Nostr client to the onion and complete a handshake.
**Risk:** low — mature C-Tor doing the hosting half.
### Phase 2 — Wallet transport swap
**Goal:** all Nostr + HTTP traffic rides Tor; Nym is off the live path.
**Tasks:** copy GRIM's `src/tor/` into Goblin; implement `WebSocketTransport` against arti `DataStream`; re-route `http_request*` (Tor→onion for the authority, Tor→clearnet for gist/price/avatars); re-point `warm_up()`/`transport_ready()` at Tor readiness; keep the confirm-before-sent guard; **no clearnet fallback for the relay path — fail loudly**.
**Validation:** a real payment between two devices completes over the onion, with the read-back confirm firing.
**Risk:** low-medium — mitigated by copying a proven engine and native-tls.
### Phase 3 — Relay-side Poisson (timing privacy)
**Goal:** send-time and receive-time are decorrelated for the realistic adversary.
**Tasks:** the relay holds each inbound gift-wrap and releases it to the recipient after an exponentially-distributed delay (mean tuned so it stays inside the invisible delivery gap); confirm the sender's "Sent" still fires on *hold*, not on delivery, so the delay is free to the user.
**Validation:** relay logs + on-path capture show release timing decorrelated from arrival timing; sender UX latency unchanged.
**Risk:** low — it is a delay queue on a server we own.
### Phase 4 — Copy reword + iOS follow-through
**Goal:** the product says "Tor," honestly, everywhere; iOS is either shipped or explicitly deferred.
**Tasks:** reword all Nym/Mixnet strings across the six locales; publish the forum post; land iOS per the Phase-0 punch-list or file it as a tracked follow-up.
**Validation:** locale drift test green; no "Nym"/"Mixnet" left in user-facing copy.
**Risk:** low.
### Phase 5 — Retire Nym
**Goal:** shed the dead dependency and shrink the binary.
**Tasks:** delete `src/nym/*`, the `nym-sdk`/`smolmix` path-deps, and `floonet-mixexit`; shrink the Android native lib; simplify the all-in-one floonet package to **relay + torrc-onion + name-authority-onion**.
**Validation:** clean build, smaller Android lib, green E2E.
**Risk:** low — deletion after the replacement is proven.
---
## Migration & rollout
The pin format is forward-safe (serde-tolerant, `version:1` preserved), so rollout is graceful:
1. Ship the **Tor-capable build** via the in-app updater (GitHub releases).
2. Run the **system-Tor onion service alongside** the existing relay through the transition.
3. As users update, they pick up the `onion` pin from the gist and connect over Tor.
4. **Old Nym pins go dark gracefully** — Nym is failing on its own schedule anyway, so there is no working state to "cut over" from and no regression to manage.
No coordinated flag day. The network is already in the failure state the new build fixes.
---
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| arti **onion-client** maturity ("not yet as secure as C-Tor") | Mature **system-Tor** hosts the service side; Vanguards on the service; onion-client is actively hardening and we are only the dialer. |
| **iOS** unproven | Named Phase-0 spike; Android does not wait on it; plain-Tor (no PT bridges) is the accepted iOS mode. |
| **Android build** friction | Direct copy of GRIM's working recipe — native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`; native-tls dodges the rustls/ring provider conflict. |
| **Bootstrap latency** on first send | Warm the circuit at launch; keep the "Connecting…" readiness UX; the confirm-before-sent guard makes a slow first send safe, never lost. |
| **Poisson delay tuned too long** (feels laggy to recipient) | Tune mean to sit inside the already-async delivery gap; sender "Sent" fires on relay-hold regardless, so it is never on the user's critical path. |
| **Single-relay availability** | The relay was always a required component; onion descriptor HA via a 24/7 host (and onionbalance if needed). |
---
## Far-future option (not part of this plan)
If we ever want to close the one remaining gap — resistance to a global passive adversary — a self-run, tokenless, unbonded mixnet layer could in principle sit *on top of* this Tor foundation (wallet → Tor → our own entry → mixnodes → relay). We are **not building it.** It leans on exactly the free Nym grant that is being deleted, it adds latency, and it defends against a threat a Grin payments wallet does not face. It is noted here only so the door is documented, not opened. **Tor is the plan.**
---
## Bottom line
Return to Tor, embedded in-process, copied straight from GRIM's proven engine (arti 0.43, native-tls), dialing the relay's pinned onion — and rebuild the mixnet's one real advantage, timing unlinkability, as a Poisson release delay on the relay we already own and fully control. Keep the relay-mediated store-and-forward model, the genuine-readiness signal, and the read-back confirm-before-sent guard exactly as they are. Android is solved today; iOS gets a named spike. We give up one theoretical guarantee against a global adversary that is out of scope, and in exchange the wallet becomes more reliable, lighter on the battery, faster where users wait — and free of any token or rented bandwidth that can go dark at midnight.
---
## Forum post: why we're moving from the mixnet to Tor
*(Ready to publish, owner's voice.)*
**We're switching Goblin's private plumbing from the Nym mixnet back to Tor. Here's the honest why.**
When I built Goblin's privacy layer on the Nym mixnet, I meant it. A mixnet is the strongest metadata-privacy tool we have — it doesn't just hide your IP, it deliberately shuffles the timing of messages so nobody can even tell that *you sending* and *someone receiving* are the same payment. For a money wallet, that's exactly the property you want. I wasn't hedging when I picked it.
But a payments wallet has to stand on ground that doesn't move, and the ground moved. The free bandwidth tier that Goblin relied on turns out to be temporary testnet scaffolding that Nym is actively removing — it's written right into their code to expire, and their public gateways are switching over to a paid model. And the paid model means holding a specific crypto token to buy bandwidth. That's not a foundation I'm willing to build your money on. It went dark on us more than once, on a schedule, and "your payments work unless it's the wrong time of day" is not something I'll ship.
So we're going back to **Tor** — the most battle-tested privacy network on the internet, with no token, no rented bandwidth, and nothing to expire. And we're embedding it **right inside the app**, the same way our sibling wallet GRIM already does. No separate program to install, no server in the middle you have to trust. It just works, on your phone, out of the box.
Here's what that means for you, in plain terms. Tor hides your IP address — from our own relay and from your internet provider — so nobody watching the network can see it's *you* sending a payment. And the one thing Tor doesn't do on its own — that clever timing-shuffle a mixnet does — **we rebuild ourselves**: our relay briefly holds each payment and releases it on a randomized delay, so nobody can match "you sent" to "they received." You won't feel that delay, because it happens in the gap where a payment is already in flight. Your screen says "Sent" the instant our relay has your payment safely in hand.
And honestly? It's **better** day to day. Tor is faster for the moments you actually wait on — sending a payment, opening the app — because it skips the mixnet's built-in delays. It's easier on your battery. And it's simpler and more reliable, because there's no metered bandwidth left to run out.
I'll be straight about the one thing we give up: a full mixnet can resist an adversary powerful enough to watch the *entire* internet at once. Tor doesn't claim to stop that, and neither do we — it's not the threat a Grin payments wallet realistically faces. For every attacker that actually exists, you're covered.
Faster, more reliable, works on your phone, no tokens, no rented bandwidth to fail. That's the trade, and I'm glad to make it.
+8 -6
View File
@@ -18,7 +18,7 @@ A Goblin payment is **a Grin transaction wrapped in a private nostr message**.
2. **Nostr layer (the delivery).** Instead of making you hand slate files back
and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr
direct message**, routed through the **Nym mixnet**. You pay a `username` or
direct message**, routed through **Tor**. You pay a `username` or
`npub`; the recipient's wallet applies the slate automatically.
The slate is the payload; nostr is the transport. Everything below is about how
@@ -85,7 +85,7 @@ Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness).
---
## 3. Transport: NIP-17 gift wraps over Nym
## 3. Transport: NIP-17 gift wraps over Tor
A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
`send_control_dm` (both in `src/nostr/client.rs`). The message structure
@@ -106,10 +106,12 @@ A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`,
`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`.
**How relays are reached:** every relay connection runs through the in-process
**Nym mixnet** SOCKS5 proxy (`NymWebSocketTransport`; `run_service` waits for the
proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the
network layer. The Grin *node* connection (block sync + broadcasting the final tx)
**How relays are reached:** every relay connection runs through an in-process
**Tor** client (arti, linked directly into the wallet binary — no sidecar), via
`TorWebSocketTransport` (`run_service` waits for Tor to be ready before dialing).
So the relay never sees your IP: the money-path relay is dialed at its pinned
`.onion` address, and any relay without one is reached over a Tor exit to its
clearnet host. The Grin *node* connection (block sync + broadcasting the final tx)
is direct clearnet — it's public chain data, the same for everyone, not tied to
your identity.
-105
View File
@@ -1,105 +0,0 @@
// Local network measurement for the Nym read tunnel. Uses the wallet's REAL
// transport (warm_up + tuned tunnel + reselect + DNS cache + HTTP keep-alive
// pool), then fetches the live price API over the mixnet on a fixed interval
// so we can see (a) cold connect time, (b) whether the connection stays warm,
// (c) per-fetch latency over time.
//
// cargo run --release --example tunnel_measure -- <seconds> [interval_secs]
//
// e.g. `-- 300` (5 min) or `-- 600 15` (10 min, every 15s).
use std::time::{Duration, Instant};
const PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd";
#[tokio::main]
async fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let args: Vec<String> = std::env::args().collect();
let total_secs: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(300);
let interval_secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(15);
let run_start = Instant::now();
println!("[t=0.0s] warm_up(): starting the tunnel");
grim::nym::warm_up();
// Cold connect time: poll is_ready().
let mut connect_ms = None;
let t_connect = Instant::now();
while t_connect.elapsed() < Duration::from_secs(120) {
if grim::nym::is_ready() {
connect_ms = Some(t_connect.elapsed().as_millis());
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
match connect_ms {
Some(ms) => println!(
"[t={:.1}s] TUNNEL READY (cold connect {} ms)",
run_start.elapsed().as_secs_f64(),
ms
),
None => {
println!("tunnel never became ready in 120s; aborting");
return;
}
}
// Warm-loop: fetch price over the mixnet every interval, record latency.
let mut lats: Vec<u128> = vec![];
let mut fails = 0u32;
let deadline = run_start + Duration::from_secs(total_secs);
let mut n = 0u32;
while Instant::now() < deadline {
n += 1;
let t = Instant::now();
let ok = grim::nym::http_request("GET", PRICE_URL.to_string(), None, vec![]).await;
let ms = t.elapsed().as_millis();
match ok {
Some(body) if body.contains("grin") => {
lats.push(ms);
println!(
"[t={:.1}s] fetch #{n}: {} ms ready={}",
run_start.elapsed().as_secs_f64(),
ms,
grim::nym::is_ready()
);
}
other => {
fails += 1;
println!(
"[t={:.1}s] fetch #{n}: FAIL after {} ms (ready={}, body={:?})",
run_start.elapsed().as_secs_f64(),
ms,
grim::nym::is_ready(),
other.map(|b| b.chars().take(40).collect::<String>())
);
}
}
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
}
// Summary.
lats.sort_unstable();
let n_ok = lats.len();
let sum: u128 = lats.iter().sum();
let median = lats.get(n_ok / 2).copied().unwrap_or(0);
println!(
"\n==== SUMMARY ({}s run, {}s interval) ====",
total_secs, interval_secs
);
println!("cold connect: {} ms", connect_ms.unwrap());
println!("fetches: {} ok, {} failed", n_ok, fails);
if n_ok > 0 {
println!(
"warm fetch latency ms: min {} / median {} / max {} / mean {}",
lats.first().unwrap(),
median,
lats.last().unwrap(),
sum / n_ok as u128
);
let head: Vec<u128> = lats.iter().take(3).copied().collect();
println!("(sorted sample) fastest 3: {:?}", head);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
[Desktop Entry]
Name=Goblin
Exec=goblin
Exec=goblin %u
Icon=goblin
Type=Application
Categories=Finance
MimeType=application/x-slatepack;text/plain;
MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin;
+3 -3
View File
@@ -5,8 +5,8 @@
# Usage: linux/build_release.sh [platform]
# platform: 'x86_64' (default) or 'arm'
#
# Goblin links the Nym SDK IN-PROCESS (src/nym/), so the AppImage is one
# self-contained binary with no sidecar to embed or ship beside it.
# Goblin links the Tor transport (embedded arti) IN-PROCESS, so the AppImage is
# one self-contained binary with no sidecar to embed or ship beside it.
set -euo pipefail
@@ -41,7 +41,7 @@ export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include"
cargo zigbuild --release --target "${arch}.2.17"
# Assemble the AppDir: AppRun IS the goblin binary (Nym SDK linked in), plus the
# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the
# icon + desktop entry. Nothing else.
appdir="linux/Goblin.AppDir"
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
+55 -16
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "Anonym"
connected_nym: "Über Nym verbunden"
nym_ready: "Nym bereit · Relays…"
connecting_nym: "Verbinde mit Nym…"
connected_nym: "Über Tor verbunden"
nym_ready: "Tor bereit · Relays…"
connecting_nym: "Verbinde mit Tor…"
cant_reach_node: "Node nicht erreichbar"
node_synced: "Node synchronisiert"
syncing: "Synchronisiere…"
balance_updating: "Guthaben wird aktualisiert…"
balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo"
fiat_unavailable: "Kurs nicht verfügbar"
listening: "Wartet auf Zahlungen"
block: "Block %{height}"
waiting_for_chain: "Warte auf Chain…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "Aktivität"
nav_receive: "Empfangen"
nav_settings: "Einstellungen"
back_again: "Erneut Zurück drücken, um die Wallet zu wechseln"
activity: "Aktivität"
news: "Neuigkeiten"
empty_title: "Noch keine Aktivität"
empty_sub: "Sende oder empfange grin, um zu starten."
recent: "Zuletzt"
@@ -413,10 +417,11 @@ goblin:
to: "An"
from: "Von"
nostr: "nostr"
identity: "Identität"
fee_none: "Keine"
network_fee: "Netzwerkgebühr"
privacy: "Privatsphäre"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaktion"
cancel_request: "Anfrage abbrechen"
cancel_send: "Zahlung abbrechen"
@@ -466,6 +471,7 @@ goblin:
relays: "Relays"
nostr_relays: "Nostr-Relays"
node: "Node"
min_conf: "Mindestbestätigungen"
integrated_node: "Einstellungen des integrierten Nodes"
node_advanced: "Erweitert"
slatepacks: "Slatepacks"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "Wallet wechseln"
advanced: "Erweitert"
privacy: "Privatsphäre"
mixnet_routing: "Mixnet-Routing"
mixnet_routing: "Tor-Routing"
messages_lookups: "Nachrichten & Abfragen"
auto_accept: "Automatisch annehmen"
pairing: "Preiswährung"
@@ -484,6 +490,10 @@ goblin:
requests: "Anfragen"
incoming_requests: "Eingehende Anfragen"
incoming_requests_sub: "Erlaube anderen, Geld von dir anzufordern"
hide_amounts: "Beträge verbergen"
hide_amounts_sub: "Empfangene Beträge in Benachrichtigungen verbergen"
language: "Sprache"
update_available: "Update verfügbar"
appearance: "Erscheinungsbild"
theme: "Design"
theme_light: "Hell"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Netzwerk"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Drittanbieter"
grim: "GRIM (Upstream-Wallet)"
grin_node: "Grin-Node"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "QR ausblenden"
privacy:
title: "Netzwerk-Privatsphäre"
intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
intro: "Goblin sendet seinen privaten Datenverkehr über Tor und verbirgt so deine IP vor dem Relay — die Verschlüsselung verbirgt den Rest, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
payments: "Zahlungen"
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
usernames: "usernames"
usernames: "Benutzernamen"
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
price_avatars: "Preis"
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
over_mixnet: "Über das mixnet"
over_mixnet: "Über Tor"
direct_connection: "Direkte Verbindung"
grin_node: "Grin-Node"
grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft."
@@ -632,7 +642,7 @@ goblin:
title: "Kopplung"
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
pair_with: "Koppeln mit"
rates_note: "Kurse werden über das Nym mixnet abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
rates_note: "Kurse werden über Tor abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
relays:
title: "Relays"
intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen."
@@ -669,12 +679,38 @@ goblin:
n59_blurb: "Verpackt Nachrichten, sodass Relays nicht sehen können, wer mit wem kommuniziert."
n98_title: "HTTP-Auth"
n98_blurb: "Signiert die Benutzernamen-Registrierungsanfrage an goblin.st."
identities:
title: "Identitäten"
switch_hint: "Identität wechseln"
blurb: "Deine Identitäten sind die Namen, unter denen du bezahlt wirst. Alle zahlen in diese eine Wallet. Füge eine neue hinzu, wechsle zwischen ihnen oder entferne eine, die du nicht mehr brauchst."
privacy_note: "Diese Identitäten teilen sich eine Wallet und ein Guthaben — getrennte Eingänge, keine getrennten Gelder. Für wirklich getrenntes Geld nutze eine separate Wallet."
held: "Vorhandene Identitäten"
add: "Identität hinzufügen"
add_title: "Eine Identität hinzufügen"
nsec_hint: "Nsec einfügen"
generate_note: "Ein neuer, anonymer Schlüssel wird für diese Wallet erstellt."
import_instead: "Stattdessen eine bestehende Identität importieren"
generate_instead: "Stattdessen einen neuen Schlüssel erstellen"
choose_backup: "Eine .backup-Datei wählen"
backup_selected: "Backup-Datei ausgewählt"
delete_short: "Identität löschen"
delete_title: "%{name} löschen?"
delete_blurb: "Dies entfernt die Identität dauerhaft aus dieser Wallet. Bereits erhaltenes Geld bleibt in deinem Guthaben."
delete_backup_note: "Sichere sie zuerst — ohne ihren nsec oder ihre .backup-Datei ist sie nicht wiederherstellbar."
delete_confirm: "Löschen"
manage_title: "Identität verwalten"
tag_note: "Gib ihr einen privaten Namen. Nur du siehst ihn — er wird nie geteilt."
tag_hint: "Privater Name"
tag_save: "Speichern"
add_confirm: "Hinzufügen"
pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren."
working: "Arbeite…"
onboarding:
intro:
private_money_head: "Privates Geld"
private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain."
send_like_message_head: "Senden wie eine Nachricht"
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und das Nym mixnet an — niemand dazwischen sieht den Betrag oder die Beteiligten."
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und Tor an — niemand dazwischen sieht den Betrag oder die Beteiligten."
yours_alone_head: "Nur deins"
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
get_started: "Loslegen"
@@ -725,8 +761,8 @@ goblin:
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
title: "Deine Zahlungsidentität"
key_being_made: "Schlüssel wird erstellt…"
connected_nym: "über Nym verbunden"
connecting_nym: "verbinde über Nym…"
connected_nym: "über Tor verbunden"
connecting_nym: "verbinde über Tor…"
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht."
pick_username: "Benutzernamen wählen — optional"
@@ -734,7 +770,7 @@ goblin:
username_field_hint: "deinname"
working: "Arbeite…"
claim_username: "Benutzernamen sichern"
available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern."
available_when_connected: "Verfügbar, sobald Tor verbindet — oder überspringen und später sichern."
youre: "Du bist %{name}"
claimed_title: "%{name} gehört dir"
claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet."
@@ -789,14 +825,17 @@ goblin:
row_from: "Von"
row_to: "An"
row_note: "Notiz"
row_proof: "Zahlungsnachweis"
row_proof_val: "Enthalten"
row_proof_shared: "Nachweis geteilt mit"
row_they_pay: "Sie zahlen"
row_they_pay_val: "Nur wenn sie zustimmen"
row_delivery: "Zustellung"
row_delivery_val: "NIP-44-verschlüsselt, über Nym"
row_delivery_val: "NIP-44-verschlüsselt, über Tor"
row_network_fee: "Netzwerkgebühr"
row_network_fee_val: "Von deinem Guthaben abgezogen"
row_privacy: "Privatsphäre"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Anfrage senden"
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
hold_to_send: "Zum Senden halten"
+54 -15
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "Anonymous"
connected_nym: "Connected over Nym"
nym_ready: "Nym ready · relays…"
connecting_nym: "Connecting to Nym…"
connected_nym: "Connected over Tor"
nym_ready: "Tor ready · relays…"
connecting_nym: "Connecting to Tor…"
cant_reach_node: "Can't reach node"
node_synced: "Node synced"
syncing: "Syncing…"
balance_updating: "Balance updating…"
balance_stale: "Can't reach node · last known balance"
fiat_unavailable: "Rate unavailable"
listening: "Listening for payments"
block: "Block %{height}"
waiting_for_chain: "Waiting for chain…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "Activity"
nav_receive: "Receive"
nav_settings: "Settings"
back_again: "Press back again to switch wallets"
activity: "Activity"
news: "News"
empty_title: "No activity yet"
empty_sub: "Send or receive grin to get started."
recent: "Recent"
@@ -413,10 +417,11 @@ goblin:
to: "To"
from: "From"
nostr: "nostr"
identity: "Identity"
fee_none: "None"
network_fee: "Network fee"
privacy: "Privacy"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaction"
cancel_request: "Cancel request"
cancel_send: "Cancel payment"
@@ -466,6 +471,7 @@ goblin:
relays: "Relays"
nostr_relays: "Nostr Relays"
node: "Node"
min_conf: "Minimum confirmations"
integrated_node: "Integrated node settings"
node_advanced: "Advanced"
slatepacks: "Slatepacks"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "Switch wallet"
advanced: "Advanced"
privacy: "Privacy"
mixnet_routing: "Mixnet routing"
mixnet_routing: "Tor routing"
messages_lookups: "Messages & lookups"
auto_accept: "Auto-accept"
pairing: "Price currency"
@@ -484,6 +490,10 @@ goblin:
requests: "Requests"
incoming_requests: "Incoming requests"
incoming_requests_sub: "Let others request money from you"
hide_amounts: "Hide amounts"
hide_amounts_sub: "Hide received amounts in notifications"
language: "Language"
update_available: "Update available"
appearance: "Appearance"
theme: "Theme"
theme_light: "Light"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Network"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Third party"
grim: "GRIM (upstream wallet)"
grin_node: "Grin node"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "Hide QR"
privacy:
title: "Network privacy"
intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you."
intro: "Goblin sends its private traffic through Tor, which hides your IP from the relay — encryption hides the rest, so a relay can't link a payment back to you."
payments: "Payments"
payments_blurb: "Every nostr message carrying a slatepack."
usernames: "usernames"
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
price_avatars: "Price"
price_avatars_blurb: "The live fiat rate shown next to amounts."
over_mixnet: "Over the mixnet"
over_mixnet: "Over Tor"
direct_connection: "Direct connection"
grin_node: "Grin node"
grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity."
@@ -632,7 +642,7 @@ goblin:
title: "Pairing"
intro: "What your balance and amounts are shown against."
pair_with: "Pair with"
rates_note: "Rates fetch over the Nym mixnet, only while a pairing is on — off means no rate request leaves your device."
rates_note: "Rates fetch over Tor, only while a pairing is on — off means no rate request leaves your device."
relays:
title: "Relays"
intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive."
@@ -669,12 +679,38 @@ goblin:
n59_blurb: "Wraps messages so relays can't see who is talking to whom."
n98_title: "HTTP auth"
n98_blurb: "Signs the username registration request to goblin.st."
identities:
title: "Identities"
switch_hint: "Switch identity"
blurb: "Your identities are the names you get paid under. They all pay into this one wallet. Add a new one, switch between them, or remove one you don't need."
privacy_note: "These identities share one wallet and one balance — separate front doors, not separate funds. For truly separate money, use a separate wallet."
held: "Held identities"
add: "Add identity"
add_title: "Add an identity"
nsec_hint: "Paste an nsec"
generate_note: "A fresh, anonymous key will be created for this wallet."
import_instead: "Import an existing identity instead"
generate_instead: "Create a new key instead"
choose_backup: "Choose a .backup file"
backup_selected: "Backup file selected"
delete_short: "Delete identity"
delete_title: "Delete %{name}?"
delete_blurb: "This permanently removes the identity from this wallet. Money already received stays in your balance."
delete_backup_note: "Back it up first — without its nsec or .backup file it cannot be recovered."
delete_confirm: "Delete"
manage_title: "Manage identity"
tag_note: "Give it a private name. Only you see it — it is never shared."
tag_hint: "Private name"
tag_save: "Save"
add_confirm: "Add"
pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue."
working: "Working…"
onboarding:
intro:
private_money_head: "Private money"
private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain."
send_like_message_head: "Send like a message"
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and the Nym mixnet — no one in between can see the amount or who's involved."
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and Tor — no one in between can see the amount or who's involved."
yours_alone_head: "Yours alone"
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
get_started: "Get started"
@@ -725,8 +761,8 @@ goblin:
kicker: "STEP 3 OF 3 · IDENTITY"
title: "Your payment identity"
key_being_made: "key being made…"
connected_nym: "connected over Nym"
connecting_nym: "connecting over Nym…"
connected_nym: "connected over Tor"
connecting_nym: "connecting over Tor…"
fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds."
clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face."
pick_username: "Pick a username — optional"
@@ -734,7 +770,7 @@ goblin:
username_field_hint: "yourname"
working: "Working…"
claim_username: "Claim username"
available_when_connected: "Available once the mixnet connects — or skip and claim later."
available_when_connected: "Available once Tor connects — or skip and claim later."
youre: "You're %{name}"
claimed_title: "%{name} is yours"
claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet."
@@ -789,14 +825,17 @@ goblin:
row_from: "From"
row_to: "To"
row_note: "Note"
row_proof: "Payment proof"
row_proof_val: "Included"
row_proof_shared: "Proof shared with"
row_they_pay: "They pay"
row_they_pay_val: "Only if they approve"
row_delivery: "Delivery"
row_delivery_val: "NIP-44 encrypted, over Nym"
row_delivery_val: "NIP-44 encrypted, over Tor"
row_network_fee: "Network fee"
row_network_fee_val: "Deducted from your balance"
row_privacy: "Privacy"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Send request"
request_approve_hint: "They'll get a request to approve"
hold_to_send: "Hold to send"
+55 -16
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "Anonyme"
connected_nym: "Connecté via Nym"
nym_ready: "Nym prêt · relais…"
connecting_nym: "Connexion à Nym…"
connected_nym: "Connecté via Tor"
nym_ready: "Tor prêt · relais…"
connecting_nym: "Connexion à Tor…"
cant_reach_node: "Nœud injoignable"
node_synced: "Nœud synchronisé"
syncing: "Synchronisation…"
balance_updating: "Solde en cours de mise à jour…"
balance_stale: "Nœud injoignable · dernier solde connu"
fiat_unavailable: "Taux indisponible"
listening: "En attente de paiements"
block: "Bloc %{height}"
waiting_for_chain: "En attente de la chaîne…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "Activité"
nav_receive: "Recevoir"
nav_settings: "Réglages"
back_again: "Appuyez à nouveau sur retour pour changer de portefeuille"
activity: "Activité"
news: "Actualités"
empty_title: "Aucune activité"
empty_sub: "Envoyez ou recevez des grin pour commencer."
recent: "Récent"
@@ -413,10 +417,11 @@ goblin:
to: "À"
from: "De"
nostr: "nostr"
identity: "Identité"
fee_none: "Aucun"
network_fee: "Frais de réseau"
privacy: "Confidentialité"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaction"
cancel_request: "Annuler la demande"
cancel_send: "Annuler le paiement"
@@ -466,6 +471,7 @@ goblin:
relays: "Relais"
nostr_relays: "Relais Nostr"
node: "Nœud"
min_conf: "Confirmations minimales"
integrated_node: "Paramètres du nœud intégré"
node_advanced: "Avancé"
slatepacks: "Slatepacks"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "Changer de portefeuille"
advanced: "Avancé"
privacy: "Confidentialité"
mixnet_routing: "Routage par mixnet"
mixnet_routing: "Routage par Tor"
messages_lookups: "Messages et recherches"
auto_accept: "Acceptation auto"
pairing: "Devise des prix"
@@ -484,6 +490,10 @@ goblin:
requests: "Demandes"
incoming_requests: "Demandes entrantes"
incoming_requests_sub: "Laisser les autres vous demander de l'argent"
hide_amounts: "Masquer les montants"
hide_amounts_sub: "Masquer les montants reçus dans les notifications"
language: "Langue"
update_available: "Mise à jour disponible"
appearance: "Apparence"
theme: "Thème"
theme_light: "Clair"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Réseau"
network_value: "MW + mixnet Nym + nostr"
network_value: "MW + Tor + nostr"
third_party: "Tiers"
grim: "GRIM (portefeuille amont)"
grin_node: "Nœud grin"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "Masquer le QR"
privacy:
title: "Confidentialité réseau"
intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous."
intro: "Goblin envoie son trafic privé via Tor, qui masque votre IP au relais — le chiffrement masque le reste, afin qu'un relais ne puisse pas relier un paiement à vous."
payments: "Paiements"
payments_blurb: "Chaque message nostr transportant un slatepack."
usernames: "usernames"
usernames: "Noms d'utilisateur"
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
price_avatars: "Prix"
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
over_mixnet: "Via le mixnet"
over_mixnet: "Via Tor"
direct_connection: "Connexion directe"
grin_node: "Nœud grin"
grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité."
@@ -632,7 +642,7 @@ goblin:
title: "Appairage"
intro: "Ce à quoi votre solde et vos montants sont comparés."
pair_with: "Apparier avec"
rates_note: "Les cours sont récupérés via le mixnet Nym, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
rates_note: "Les cours sont récupérés via Tor, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
relays:
title: "Relais"
intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir."
@@ -669,12 +679,38 @@ goblin:
n59_blurb: "Enveloppe les messages pour que les relais ne voient pas qui parle à qui."
n98_title: "Auth HTTP"
n98_blurb: "Signe la demande d'enregistrement du nom d'utilisateur auprès de goblin.st."
identities:
title: "Identités"
switch_hint: "Changer d'identité"
blurb: "Vos identités sont les noms sous lesquels on vous paie. Elles alimentent toutes ce même portefeuille. Ajoutez-en une nouvelle, passez de l'une à l'autre, ou supprimez celle dont vous n'avez plus besoin."
privacy_note: "Ces identités partagent un portefeuille et un solde — des portes d'entrée distinctes, pas des fonds distincts. Pour un argent vraiment séparé, utilisez un portefeuille distinct."
held: "Identités détenues"
add: "Ajouter une identité"
add_title: "Ajouter une identité"
nsec_hint: "Coller un nsec"
generate_note: "Une nouvelle clé anonyme sera créée pour ce portefeuille."
import_instead: "Importer plutôt une identité existante"
generate_instead: "Créer plutôt une nouvelle clé"
choose_backup: "Choisir un fichier .backup"
backup_selected: "Fichier de sauvegarde sélectionné"
delete_short: "Supprimer l'identité"
delete_title: "Supprimer %{name} ?"
delete_blurb: "Cela supprime définitivement l'identité de ce portefeuille. L'argent déjà reçu reste dans votre solde."
delete_backup_note: "Sauvegardez-la d'abord — sans son nsec ou son fichier .backup, elle est irrécupérable."
delete_confirm: "Supprimer"
manage_title: "Gérer l'identité"
tag_note: "Donnez-lui un nom privé. Vous seul le voyez — il nest jamais partagé."
tag_hint: "Nom privé"
tag_save: "Enregistrer"
add_confirm: "Ajouter"
pass_prompt: "Le mot de passe de votre portefeuille chiffre et déverrouille chaque identité. Saisissez-le pour continuer."
working: "En cours…"
onboarding:
intro:
private_money_head: "Argent privé"
private_money_body: "Goblin est un portefeuille pour grin — de l'argent numérique sans montants ni adresses sur sa chaîne."
send_like_message_head: "Envoyer comme un message"
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et le mixnet Nym — personne entre les deux ne voit le montant ni les personnes impliquées."
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et Tor — personne entre les deux ne voit le montant ni les personnes impliquées."
yours_alone_head: "À vous seul"
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
get_started: "Commencer"
@@ -725,8 +761,8 @@ goblin:
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
title: "Votre identité de paiement"
key_being_made: "clé en cours de création…"
connected_nym: "connecté via Nym"
connecting_nym: "connexion via Nym…"
connected_nym: "connecté via Tor"
connecting_nym: "connexion via Tor…"
fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds."
clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage."
pick_username: "Choisir un nom d'utilisateur — facultatif"
@@ -734,7 +770,7 @@ goblin:
username_field_hint: "votrenom"
working: "En cours…"
claim_username: "Réserver le nom d'utilisateur"
available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard."
available_when_connected: "Disponible une fois Tor connecté — ou passez et réservez plus tard."
youre: "Vous êtes %{name}"
claimed_title: "%{name} est à vous"
claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille."
@@ -789,14 +825,17 @@ goblin:
row_from: "De"
row_to: "À"
row_note: "Note"
row_proof: "Preuve de paiement"
row_proof_val: "Incluse"
row_proof_shared: "Preuve partagée avec"
row_they_pay: "Ils paient"
row_they_pay_val: "Seulement s'ils approuvent"
row_delivery: "Livraison"
row_delivery_val: "Chiffré NIP-44, via Nym"
row_delivery_val: "Chiffré NIP-44, via Tor"
row_network_fee: "Frais de réseau"
row_network_fee_val: "Déduit de votre solde"
row_privacy: "Confidentialité"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Envoyer la demande"
request_approve_hint: "Ils recevront une demande à approuver"
hold_to_send: "Maintenir pour envoyer"
+57 -18
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "Аноним"
connected_nym: "Подключено через Nym"
nym_ready: "Nym готов · реле…"
connecting_nym: "Подключение к Nym…"
connected_nym: "Подключено через Tor"
nym_ready: "Tor готов · реле…"
connecting_nym: "Подключение к Tor…"
cant_reach_node: "Нет связи с узлом"
node_synced: "Узел синхронизирован"
syncing: "Синхронизация…"
balance_updating: "Баланс обновляется…"
balance_stale: "Узел недоступен · последний известный баланс"
fiat_unavailable: "Курс недоступен"
listening: "Ожидание платежей"
block: "Блок %{height}"
waiting_for_chain: "Ожидание цепочки…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "Действия"
nav_receive: "Получить"
nav_settings: "Настройки"
back_again: "Нажмите «назад» ещё раз, чтобы сменить кошелёк"
activity: "Действия"
news: "Новости"
empty_title: "Пока нет действий"
empty_sub: "Отправьте или получите grin, чтобы начать."
recent: "Недавние"
@@ -413,10 +417,11 @@ goblin:
to: "Кому"
from: "От"
nostr: "nostr"
identity: "Личность"
fee_none: "Нет"
network_fee: "Сетевая комиссия"
privacy: "Приватность"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Транзакция"
cancel_request: "Отменить запрос"
cancel_send: "Отменить платёж"
@@ -466,6 +471,7 @@ goblin:
relays: "Реле"
nostr_relays: "Реле Nostr"
node: "Узел"
min_conf: "Минимум подтверждений"
integrated_node: "Настройки встроенного узла"
node_advanced: "Дополнительно"
slatepacks: "Slatepacks"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "Сменить кошелёк"
advanced: "Дополнительно"
privacy: "Приватность"
mixnet_routing: "Маршрутизация через mixnet"
mixnet_routing: "Маршрутизация через Tor"
messages_lookups: "Сообщения и поиск"
auto_accept: "Автоприём"
pairing: "Валюта цены"
@@ -484,6 +490,10 @@ goblin:
requests: "Запросы"
incoming_requests: "Входящие запросы"
incoming_requests_sub: "Разрешить другим запрашивать у вас деньги"
hide_amounts: "Скрыть суммы"
hide_amounts_sub: "Скрывать полученные суммы в уведомлениях"
language: "Язык"
update_available: "Доступно обновление"
appearance: "Внешний вид"
theme: "Тема"
theme_light: "Светлая"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "Сборка %{build}"
network: "Сеть"
network_value: "MW + mixnet Nym + nostr"
network_value: "MW + Tor + nostr"
third_party: "Сторонние"
grim: "GRIM (исходный кошелёк)"
grin_node: "Узел Grin"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "Скрыть QR"
privacy:
title: "Сетевая приватность"
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
payments: "Платежи"
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
usernames: "usernames"
usernames: "Имена пользователей"
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
price_avatars: "Цена"
price_avatars_blurb: "Текущий курс рядом с суммами."
over_mixnet: "Через mixnet"
over_mixnet: "Через Tor"
direct_connection: "Прямое соединение"
grin_node: "Узел Grin"
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
@@ -632,7 +642,7 @@ goblin:
title: "Привязка"
intro: "К чему привязаны отображаемые баланс и суммы."
pair_with: "Привязать к"
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
relays:
title: "Реле"
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
@@ -665,16 +675,42 @@ goblin:
n44_blurb: "Аутентифицированный шифр, используемый внутри этих сообщений."
n49_title: "Шифрование ключа"
n49_blurb: "Как секретный ключ хранится в покое, защищённый вашим паролем."
n59_title: "Gift wrap"
n59_title: "Подарочная обёртка"
n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается."
n98_title: "HTTP-авторизация"
n98_blurb: "Подписывает запрос регистрации имени на goblin.st."
identities:
title: "Личности"
switch_hint: "Сменить личность"
blurb: "Ваши личности — это имена, под которыми вам платят. Все они пополняют этот один кошелёк. Добавьте новую, переключайтесь между ними или удалите ненужную."
privacy_note: "Эти личности используют один кошелёк и один баланс — отдельные двери, а не отдельные средства. Для действительно раздельных денег используйте отдельный кошелёк."
held: "Имеющиеся личности"
add: "Добавить личность"
add_title: "Добавить личность"
nsec_hint: "Вставьте nsec"
generate_note: "Для этого кошелька будет создан новый анонимный ключ."
import_instead: "Вместо этого импортировать существующую личность"
generate_instead: "Вместо этого создать новый ключ"
choose_backup: "Выбрать файл .backup"
backup_selected: "Файл резервной копии выбран"
delete_short: "Удалить личность"
delete_title: "Удалить %{name}?"
delete_blurb: "Это навсегда удалит личность из этого кошелька. Уже полученные деньги останутся на балансе."
delete_backup_note: "Сначала сделайте резервную копию — без её nsec или файла .backup её невозможно восстановить."
delete_confirm: "Удалить"
manage_title: "Управление личностью"
tag_note: "Дайте ей приватное имя. Его видите только вы — оно никогда не передаётся."
tag_hint: "Приватное имя"
tag_save: "Сохранить"
add_confirm: "Добавить"
pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить."
working: "Обработка…"
onboarding:
intro:
private_money_head: "Приватные деньги"
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
send_like_message_head: "Отправляйте как сообщение"
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и mixnet Nym — никто посередине не увидит сумму или участников."
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и Tor — никто посередине не увидит сумму или участников."
yours_alone_head: "Только ваше"
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
get_started: "Начать"
@@ -725,16 +761,16 @@ goblin:
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
title: "Ваша платёжная личность"
key_being_made: "ключ создаётся…"
connected_nym: "подключено через Nym"
connecting_nym: "подключение через Nym…"
connected_nym: "подключено через Tor"
connecting_nym: "подключение через Tor…"
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
pick_username: "Выберите имя — необязательно"
username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент."
username_field_hint: "yourname"
username_field_hint: "вашеимя"
working: "Обработка…"
claim_username: "Занять имя"
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
youre: "Вы %{name}"
claimed_title: "%{name} теперь ваше"
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
@@ -789,14 +825,17 @@ goblin:
row_from: "От"
row_to: "Кому"
row_note: "Заметка"
row_proof: "Подтверждение платежа"
row_proof_val: "Включено"
row_proof_shared: "Подтверждение получит"
row_they_pay: "Они платят"
row_they_pay_val: "Только если они одобрят"
row_delivery: "Доставка"
row_delivery_val: "Зашифровано NIP-44, через Nym"
row_delivery_val: "Зашифровано NIP-44, через Tor"
row_network_fee: "Сетевая комиссия"
row_network_fee_val: "Списывается с вашего баланса"
row_privacy: "Приватность"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Отправить запрос"
request_approve_hint: "Они получат запрос на одобрение"
hold_to_send: "Удерживайте для отправки"
+54 -15
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "Anonim"
connected_nym: "Nym üzerinden bağlı"
nym_ready: "Nym hazır · relaylar…"
connecting_nym: "Nym'e bağlanılıyor…"
connected_nym: "Tor üzerinden bağlı"
nym_ready: "Tor hazır · relaylar…"
connecting_nym: "Tor'a bağlanılıyor…"
cant_reach_node: "Düğüme ulaşılamıyor"
node_synced: "Düğüm eşitlendi"
syncing: "Eşitleniyor…"
balance_updating: "Bakiye güncelleniyor…"
balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye"
fiat_unavailable: "Kur mevcut değil"
listening: "Ödemeler bekleniyor"
block: "Blok %{height}"
waiting_for_chain: "Zincir bekleniyor…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "Etkinlik"
nav_receive: "Al"
nav_settings: "Ayarlar"
back_again: "Cüzdan değiştirmek için tekrar geri bas"
activity: "Etkinlik"
news: "Haberler"
empty_title: "Henüz etkinlik yok"
empty_sub: "Başlamak için grin gönder ya da al."
recent: "Son işlemler"
@@ -413,10 +417,11 @@ goblin:
to: "Alıcı"
from: "Gönderen"
nostr: "nostr"
identity: "Kimlik"
fee_none: "Yok"
network_fee: "Ağ ücreti"
privacy: "Gizlilik"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "İşlem"
cancel_request: "İsteği iptal et"
cancel_send: "Ödemeyi iptal et"
@@ -466,6 +471,7 @@ goblin:
relays: "Relaylar"
nostr_relays: "Nostr Relayları"
node: "Düğüm"
min_conf: "Asgari onay sayısı"
integrated_node: "Tümleşik düğüm ayarları"
node_advanced: "Gelişmiş"
slatepacks: "Slatepackler"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "Cüzdan değiştir"
advanced: "Gelişmiş"
privacy: "Gizlilik"
mixnet_routing: "Mixnet yönlendirme"
mixnet_routing: "Tor yönlendirme"
messages_lookups: "Mesajlar ve aramalar"
auto_accept: "Otomatik kabul"
pairing: "Fiyat para birimi"
@@ -484,6 +490,10 @@ goblin:
requests: "İstekler"
incoming_requests: "Gelen istekler"
incoming_requests_sub: "Başkalarının senden para istemesine izin ver"
hide_amounts: "Tutarları gizle"
hide_amounts_sub: "Bildirimlerde alınan tutarları gizle"
language: "Dil"
update_available: "Güncelleme mevcut"
appearance: "Görünüm"
theme: "Tema"
theme_light: "Açık"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "Sürüm %{build}"
network: "Ağ"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Üçüncü taraf"
grim: "GRIM (üst kaynak cüzdan)"
grin_node: "Grin düğümü"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "QR gizle"
privacy:
title: "Ağ gizliliği"
intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz."
intro: "Goblin özel trafiğini Tor üzerinden gönderir ve senin IP adresini relaydan gizler — şifreleme de gerisini gizler, böylece bir relay bir ödemeyi sana bağlayamaz."
payments: "Ödemeler"
payments_blurb: "Slatepack taşıyan her nostr mesajı."
usernames: "usernamelar"
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
price_avatars: "Fiyat"
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
over_mixnet: "Mixnet üzerinden"
over_mixnet: "Tor üzerinden"
direct_connection: "Doğrudan bağlantı"
grin_node: "Grin düğümü"
grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez."
@@ -632,7 +642,7 @@ goblin:
title: "Eşleştirme"
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
pair_with: "Eşleştir"
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Nym mixnet üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Tor üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
relays:
title: "Relaylar"
intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir."
@@ -669,12 +679,38 @@ goblin:
n59_blurb: "Mesajları sarar, böylece relaylar kimin kiminle konuştuğunu göremez."
n98_title: "HTTP kimlik doğrulama"
n98_blurb: "goblin.st'ye gönderilen kullanıcı adı kayıt isteğini imzalar."
identities:
title: "Kimlikler"
switch_hint: "Kimlik değiştir"
blurb: "Kimliklerin, ödeme aldığın adlardır. Hepsi bu tek cüzdana öder. Yeni bir tane ekle, aralarında geçiş yap veya ihtiyacın olmayanı kaldır."
privacy_note: "Bu kimlikler tek cüzdanı ve tek bakiyeyi paylaşır — ayrı kapılar, ayrı paralar değil. Gerçekten ayrı para için ayrı bir cüzdan kullan."
held: "Mevcut kimlikler"
add: "Kimlik ekle"
add_title: "Bir kimlik ekle"
nsec_hint: "Bir nsec yapıştır"
generate_note: "Bu cüzdan için yeni, anonim bir anahtar oluşturulacak."
import_instead: "Bunun yerine mevcut bir kimliği içe aktar"
generate_instead: "Bunun yerine yeni bir anahtar oluştur"
choose_backup: "Bir .backup dosyası seç"
backup_selected: "Yedek dosyası seçildi"
delete_short: "Kimliği sil"
delete_title: "%{name} silinsin mi?"
delete_blurb: "Bu, kimliği bu cüzdandan kalıcı olarak kaldırır. Zaten alınan para bakiyende kalır."
delete_backup_note: "Önce yedeğini al — nseci veya .backup dosyası olmadan geri getirilemez."
delete_confirm: "Sil"
manage_title: "Kimliği yönet"
tag_note: "Ona özel bir ad ver. Sadece sen görürsün — asla paylaşılmaz."
tag_hint: "Özel ad"
tag_save: "Kaydet"
add_confirm: "Ekle"
pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir."
working: "Çalışıyor…"
onboarding:
intro:
private_money_head: "Özel para"
private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit."
send_like_message_head: "Mesaj gibi gönder"
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Nym mixnet üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Tor üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
yours_alone_head: "Yalnızca senin"
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
get_started: "Başla"
@@ -725,8 +761,8 @@ goblin:
kicker: "ADIM 3 / 3 · KİMLİK"
title: "Ödeme kimliğin"
key_being_made: "anahtar oluşturuluyor…"
connected_nym: "Nym üzerinden bağlı"
connecting_nym: "Nym üzerinden bağlanılıyor…"
connected_nym: "Tor üzerinden bağlı"
connecting_nym: "Tor üzerinden bağlanılıyor…"
fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür."
clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz."
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
@@ -734,7 +770,7 @@ goblin:
username_field_hint: "adınız"
working: "Çalışıyor…"
claim_username: "Kullanıcı adı al"
available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al."
available_when_connected: "Tor bağlandığında müsait — ya da atla ve sonra al."
youre: "Sen %{name}'sin"
claimed_title: "%{name} artık senin"
claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç."
@@ -789,14 +825,17 @@ goblin:
row_from: "Gönderen"
row_to: "Alıcı"
row_note: "Not"
row_proof: "Ödeme kanıtı"
row_proof_val: "Dahil"
row_proof_shared: "Kanıt paylaşılır"
row_they_pay: "Onlar öder"
row_they_pay_val: "Yalnızca onaylarlarsa"
row_delivery: "Teslimat"
row_delivery_val: "NIP-44 şifreli, Nym üzerinden"
row_delivery_val: "NIP-44 şifreli, Tor üzerinden"
row_network_fee: "Ağ ücreti"
row_network_fee_val: "Bakiyenden düşülür"
row_privacy: "Gizlilik"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "İstek gönder"
request_approve_hint: "Onaylamaları için bir istek alacaklar"
hold_to_send: "Göndermek için basılı tut"
+54 -15
View File
@@ -359,13 +359,15 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "已通过 Nym 连接"
nym_ready: "Nym 就绪 · 连接中继…"
connecting_nym: "正在连接 Nym…"
connected_nym: "已通过 Tor 连接"
nym_ready: "Tor 就绪 · 连接中继…"
connecting_nym: "正在连接 Tor…"
cant_reach_node: "无法连接节点"
node_synced: "节点已同步"
syncing: "同步中…"
balance_updating: "余额更新中…"
balance_stale: "无法连接节点 · 上次已知余额"
fiat_unavailable: "汇率不可用"
listening: "正在监听付款"
block: "区块 %{height}"
waiting_for_chain: "等待链数据…"
@@ -374,7 +376,9 @@ goblin:
nav_activity: "动态"
nav_receive: "收款"
nav_settings: "设置"
back_again: "再按一次返回以切换钱包"
activity: "动态"
news: "新闻"
empty_title: "暂无动态"
empty_sub: "收发 grin 即可开始。"
recent: "最近"
@@ -413,10 +417,11 @@ goblin:
to: "收款方"
from: "付款方"
nostr: "nostr"
identity: "身份"
fee_none: "无"
network_fee: "网络费用"
privacy: "隐私"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "交易"
cancel_request: "取消请求"
cancel_send: "取消付款"
@@ -466,6 +471,7 @@ goblin:
relays: "中继"
nostr_relays: "Nostr 中继"
node: "节点"
min_conf: "最少确认数"
integrated_node: "集成节点设置"
node_advanced: "高级"
slatepacks: "Slatepack"
@@ -474,7 +480,7 @@ goblin:
switch_wallet: "切换钱包"
advanced: "高级"
privacy: "隐私"
mixnet_routing: "mixnet 路由"
mixnet_routing: "Tor 路由"
messages_lookups: "消息和查询"
auto_accept: "自动接受"
pairing: "价格货币"
@@ -484,6 +490,10 @@ goblin:
requests: "请求"
incoming_requests: "收到的请求"
incoming_requests_sub: "允许他人向你请求付款"
hide_amounts: "隐藏金额"
hide_amounts_sub: "在通知中隐藏收到的金额"
language: "语言"
update_available: "有可用更新"
appearance: "外观"
theme: "主题"
theme_light: "浅色"
@@ -497,7 +507,7 @@ goblin:
goblin: "Goblin"
build: "构建 %{build}"
network: "网络"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "第三方"
grim: "GRIM(上游钱包)"
grin_node: "Grin 节点"
@@ -617,14 +627,14 @@ goblin:
hide_qr: "隐藏二维码"
privacy:
title: "网络隐私"
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
payments: "付款"
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
usernames: "用户名"
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
price_avatars: "价格"
price_avatars_blurb: "金额旁显示的实时法币汇率。"
over_mixnet: "经由 mixnet"
over_mixnet: "经由 Tor"
direct_connection: "直接连接"
grin_node: "Grin 节点"
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
@@ -632,7 +642,7 @@ goblin:
title: "配对"
intro: "你的余额和金额以何种货币显示。"
pair_with: "配对货币"
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
relays:
title: "中继"
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
@@ -669,12 +679,38 @@ goblin:
n59_blurb: "包装消息,使中继无法看到通信双方是谁。"
n98_title: "HTTP 认证"
n98_blurb: "为向 goblin.st 注册用户名的请求签名。"
identities:
title: "身份"
switch_hint: "切换身份"
blurb: "你的身份就是你收款时使用的名称。它们都汇入这一个钱包。添加新身份、在它们之间切换,或移除不再需要的身份。"
privacy_note: "这些身份共用一个钱包和一个余额——是不同的入口,而不是不同的资金。若需真正分离的资金,请使用另一个钱包。"
held: "已持有的身份"
add: "添加身份"
add_title: "添加一个身份"
nsec_hint: "粘贴 nsec"
generate_note: "将为此钱包创建一个全新的匿名密钥。"
import_instead: "改为导入现有身份"
generate_instead: "改为创建新密钥"
choose_backup: "选择 .backup 文件"
backup_selected: "已选择备份文件"
delete_short: "删除身份"
delete_title: "删除 %{name}"
delete_blurb: "这将从此钱包中永久移除该身份。已收到的钱仍保留在你的余额中。"
delete_backup_note: "请先备份——没有它的 nsec 或 .backup 文件将无法恢复。"
delete_confirm: "删除"
manage_title: "管理身份"
tag_note: "给它起一个私密名称。只有你能看到——绝不会被分享。"
tag_hint: "私密名称"
tag_save: "保存"
add_confirm: "添加"
pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。"
working: "处理中…"
onboarding:
intro:
private_money_head: "私密货币"
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
send_like_message_head: "像发消息一样付款"
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Nym mixnet 送达 — 中间任何人都看不到金额或参与者。"
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
yours_alone_head: "完全属于你"
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
get_started: "开始使用"
@@ -725,8 +761,8 @@ goblin:
kicker: "步骤 3 / 3 · 身份"
title: "你的付款身份"
key_being_made: "正在生成密钥…"
connected_nym: "已通过 Nym 连接"
connecting_nym: "正在通过 Nym 连接…"
connected_nym: "已通过 Tor 连接"
connecting_nym: "正在通过 Tor 连接…"
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
pick_username: "选择用户名 — 可选"
@@ -734,7 +770,7 @@ goblin:
username_field_hint: "你的用户名"
working: "处理中…"
claim_username: "注册用户名"
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
youre: "你是 %{name}"
claimed_title: "%{name} 已归你所有"
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
@@ -789,14 +825,17 @@ goblin:
row_from: "付款方"
row_to: "收款方"
row_note: "备注"
row_proof: "支付证明"
row_proof_val: "已包含"
row_proof_shared: "证明分享给"
row_they_pay: "对方支付"
row_they_pay_val: "仅当对方同意时"
row_delivery: "传输"
row_delivery_val: "NIP-44 加密,经由 Nym"
row_delivery_val: "NIP-44 加密,经由 Tor"
row_network_fee: "网络费用"
row_network_fee_val: "从你的余额中扣除"
row_privacy: "隐私"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "发送请求"
request_approve_hint: "对方将收到一条待同意的请求"
hold_to_send: "长按发送"
+13
View File
@@ -57,6 +57,19 @@
<string>Document</string>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>st.goblin.pay</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>goblin</string>
</array>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSHumanReadableCopyright</key>
+2 -2
View File
@@ -54,8 +54,8 @@ function build_lib() {
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
rm -f Cargo.toml-e
# The Nym mixnet is linked INTO libgrim.so (nym-sdk is a regular dependency),
# so there is no separate sidecar binary to cross-build or bundle into jniLibs.
# The Tor transport (embedded arti) is linked INTO libgrim.so, so there is no
# separate sidecar binary to cross-build or bundle into jniLibs.
}
### Build application
+70 -4
View File
@@ -39,6 +39,17 @@ pub struct Desktop {
/// Flag to check if attention required after window focusing.
attention_required: Arc<AtomicBool>,
/// Long-lived clipboard owner. On Linux (X11 AND Wayland) the clipboard
/// selection is owned by the live `arboard::Clipboard` instance: the prior
/// code created a fresh instance per call and dropped it the moment the
/// function returned, so the selection ownership was released immediately and
/// copied text (e.g. a recovery phrase) vanished before it could be pasted —
/// the "Paste does nothing on desktop" bug. Keeping ONE instance alive for
/// the app lifetime makes our process the durable selection owner, so a copy
/// survives long enough to paste (in-app or into another window). Held behind
/// a Mutex because the trait methods take `&self` and arboard's take `&mut`.
clipboard: Arc<parking_lot::Mutex<Option<arboard::Clipboard>>>,
}
impl Desktop {
@@ -49,9 +60,28 @@ impl Desktop {
camera_index: Arc::new(AtomicUsize::new(0)),
stop_camera: Arc::new(AtomicBool::new(false)),
attention_required: Arc::new(AtomicBool::new(false)),
clipboard: Arc::new(parking_lot::Mutex::new(None)),
}
}
/// Run `f` against the process-wide, lazily-created clipboard instance,
/// returning `default` if the clipboard backend can't be opened. Reusing one
/// instance (rather than `Clipboard::new()` per call) is what keeps a copied
/// selection alive on Linux — see the `clipboard` field.
fn with_clipboard<R>(&self, f: impl FnOnce(&mut arboard::Clipboard) -> R, default: R) -> R {
let mut guard = self.clipboard.lock();
if guard.is_none() {
match arboard::Clipboard::new() {
Ok(c) => *guard = Some(c),
Err(e) => {
log::error!("clipboard: failed to open: {e}");
return default;
}
}
}
f(guard.as_mut().unwrap())
}
// #[allow(dead_code)]
#[cfg(not(target_os = "macos"))]
fn start_camera_capture(
@@ -211,13 +241,24 @@ impl PlatformCallbacks for Desktop {
}
fn copy_string_to_buffer(&self, data: String) {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.set_text(data).unwrap();
// Reuse the long-lived instance so the selection survives the call (see
// the `clipboard` field). A backend error is logged, never a panic — a
// failed copy must not crash the wallet.
self.with_clipboard(
|clipboard| {
if let Err(e) = clipboard.set_text(data) {
log::error!("clipboard: set_text failed: {e}");
}
},
(),
);
}
fn get_string_from_buffer(&self) -> String {
let mut clipboard = arboard::Clipboard::new().unwrap();
clipboard.get_text().unwrap_or("".to_string())
self.with_clipboard(
|clipboard| clipboard.get_text().unwrap_or_default(),
String::new(),
)
}
fn start_camera(&self) {
@@ -359,3 +400,28 @@ lazy_static! {
/// Last captured image from started camera.
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
}
#[cfg(test)]
mod clipboard_tests {
use super::*;
use crate::gui::platform::PlatformCallbacks;
/// Round-trips a copy then paste through the REAL `Desktop` platform impl on
/// the live session clipboard. Ignored by default (needs a display/clipboard
/// backend); run manually with a Wayland/X11 session:
/// cargo test --lib clipboard_roundtrip -- --ignored --nocapture
/// Proves the persistent-instance fix: with the old fresh-instance-per-call
/// pattern this read back empty on Wayland/X11.
#[test]
#[ignore]
fn clipboard_roundtrip() {
let d = Desktop::new();
let phrase = "abandon ability able about above absent absorb abstract \
absurd abuse access accident account accuse achieve acid acoustic \
acquire across act action actor actress";
d.copy_string_to_buffer(phrase.to_string());
std::thread::sleep(std::time::Duration::from_millis(200));
let got = d.get_string_from_buffer();
assert_eq!(got, phrase, "clipboard round-trip lost the copied text");
}
}
+370 -1
View File
@@ -16,7 +16,7 @@
use grin_wallet_libwallet::TxLogEntryType;
use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta};
use crate::nostr::{Contact, NewsItem, NostrSendStatus, NostrStore, TxNostrMeta};
use crate::wallet::Wallet;
use crate::wallet::types::WalletTx;
@@ -34,6 +34,10 @@ pub struct ActivityItem {
pub time: i64,
/// Counterparty npub hex, when known.
pub npub: Option<String>,
/// The wallet's OWN nostr identity (pubkey hex) that was active when this tx
/// happened — the front door it used. Empty/None on pre-feature rows (treated
/// as the primary identity). Drives the subtle per-identity row cue.
pub owner_pubkey: Option<String>,
}
/// Full detail for the receipt / transaction-detail screen: GRIM tx data
@@ -316,6 +320,10 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
.map(|t| t.timestamp())
.unwrap_or(0);
let canceled = is_canceled(tx, meta.as_ref());
let owner_pubkey = meta
.as_ref()
.map(|m| m.recipient_pubkey.clone())
.filter(|h| !h.is_empty());
ActivityItem {
tx_id: tx.data.id,
@@ -328,6 +336,7 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
system,
time,
npub: meta.map(|m| m.npub),
owner_pubkey,
}
}
@@ -378,3 +387,363 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
hits.truncate(limit);
hits
}
/// The news post to show in the Home panel for the wallet's active language, or
/// `None` (panel hides). Selection is language-aware: the newest article whose
/// detected language matches the app locale, falling back to the newest English
/// article. The returned item's title has any `[xx]` language marker stripped
/// for display. `GOBLIN_FAKE_NEWS=1` injects a fixed multilingual set in debug
/// builds so the panel can be screenshotted without a live relay feed.
pub fn news_latest(wallet: &Wallet) -> Option<NewsItem> {
let items = news_pool(wallet);
let mut item = select_news(&items, &news_locale_code())?;
item.title = news_display_title(&item.title);
Some(item)
}
/// The candidate news set (all cached posts), or a fixed multilingual sample
/// under `GOBLIN_FAKE_NEWS` in debug builds. Kept separate from selection so the
/// selection logic stays a pure, unit-testable function.
fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
#[cfg(debug_assertions)]
if std::env::var("GOBLIN_FAKE_NEWS").is_ok() {
return vec![
NewsItem {
d: "welcome-en".to_string(),
created_at: 100,
title: "Welcome to Goblin".to_string(),
summary: "Private grin payments over Tor. Read more: https://docs.goblin.st"
.to_string(),
lang: None,
published_at: Some(1_782_864_000), // 2026-07-01 UTC
},
NewsItem {
d: "welcome-de".to_string(),
created_at: 100,
title: "Willkommen bei Goblin [de]".to_string(),
summary: "Private Grin-Zahlungen über Tor. Mehr dazu: https://docs.goblin.st"
.to_string(),
lang: None,
published_at: Some(1_782_864_000), // 2026-07-01 UTC
},
];
}
wallet
.nostr_service()
.map(|s| s.store.all_news())
.unwrap_or_default()
}
/// The app's active locale folded to the ISO 639-1 code used to match news
/// articles. The shipped locales are `en/de/fr/ru/tr/zh-CN`; only `zh-CN` needs
/// folding to its 639-1 primary `zh`, and every other locale already is a
/// two-letter primary. Region and separator (`-`/`_`) are dropped.
fn news_locale_code() -> String {
let loc = rust_i18n::locale().to_string().to_lowercase();
loc.split(['-', '_']).next().unwrap_or("en").to_string()
}
/// Detect an article's language as a lower-case ISO 639-1 code. Priority: the
/// stored event language tag, then a trailing `[xx]` marker on the title, else
/// English (`None`). Pure — the unit tests exercise it directly.
pub fn news_language(item: &NewsItem) -> Option<String> {
if let Some(l) = &item.lang {
let l = l.trim().to_lowercase();
if is_lang_code(&l) {
return Some(l);
}
}
title_lang_marker(&item.title)
}
/// The trailing `[xx]` marker on a title (case-insensitive, `xx` = two ASCII
/// letters), as a lower-case code, or `None`. Only a marker at the very end of
/// the (trimmed) title counts, so a `[link]` mid-sentence is never mistaken for
/// a language.
fn title_lang_marker(title: &str) -> Option<String> {
let t = title.trim();
let inner = t.strip_suffix(']')?.rsplit_once('[')?.1;
let code = inner.trim().to_lowercase();
if is_lang_code(&code) {
Some(code)
} else {
None
}
}
/// A displayable title with any trailing `[xx]` language marker removed.
pub fn news_display_title(title: &str) -> String {
match title_lang_marker(title) {
Some(_) => {
let t = title.trim_end();
// Drop the `[xx]` token and the whitespace that preceded it.
match t.rfind('[') {
Some(idx) => t[..idx].trim_end().to_string(),
None => t.to_string(),
}
}
None => title.to_string(),
}
}
/// Format a unix timestamp (seconds) as an ISO-8601 calendar date in UTC
/// (`YYYY-MM-DD`), never a US `M/D/Y`, and date only (no time-of-day, unlike the
/// activity feed). Dates the Home news panel. An out-of-range stamp falls back to
/// the epoch date rather than panicking.
pub fn news_date_iso(ts: i64) -> String {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(ts, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "1970-01-01".to_string())
}
/// The hard character budget for a Home news title before it ellipsizes. Titles
/// up to ~34 chars sit at the full 16pt on a 390px phone; between there and this
/// cap the panel shrinks the font to keep one line; past it they are ellipsized.
/// This is the author's predictable writing budget.
pub const NEWS_TITLE_MAX_CHARS: usize = 48;
/// A news title clamped to [`NEWS_TITLE_MAX_CHARS`], ellipsizing (`…`) past it so
/// an over-long title is handled predictably rather than relying on layout alone.
/// The shrink-to-fit font sizing in the panel is the second, screen-width-aware
/// half of the guardrail. Clamps on `char` boundaries so multi-byte titles are
/// never split mid-codepoint.
pub fn news_title_clamped(title: &str) -> String {
let chars: Vec<char> = title.chars().collect();
if chars.len() > NEWS_TITLE_MAX_CHARS {
let keep = NEWS_TITLE_MAX_CHARS.saturating_sub(1);
format!("{}", chars[..keep].iter().collect::<String>())
} else {
title.to_string()
}
}
/// True for a two-letter ASCII-alphabetic language code.
fn is_lang_code(s: &str) -> bool {
s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic())
}
/// Select the news article to show for `target` (an ISO 639-1 code): the newest
/// article in that language, else the newest English article (English = an
/// explicit `en` OR no detected language). Pure so it is unit-testable without a
/// wallet/store. Ties on `created_at` resolve to the last such article.
pub fn select_news(items: &[NewsItem], target: &str) -> Option<NewsItem> {
let target = if target.is_empty() { "en" } else { target };
let lang_of = |it: &NewsItem| news_language(it).unwrap_or_else(|| "en".to_string());
items
.iter()
.filter(|it| lang_of(it) == target)
.max_by_key(|it| it.created_at)
.or_else(|| {
items
.iter()
.filter(|it| lang_of(it) == "en")
.max_by_key(|it| it.created_at)
})
.cloned()
}
/// Split a plain-text summary into (segment, is_url) runs so http(s) URLs render
/// as tappable links and the rest as plain labels. Trailing sentence
/// punctuation is trimmed off a URL so "…goblin.st." doesn't link the dot.
pub fn split_urls(s: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut rest = s;
while let Some(idx) = rest.find("http") {
let candidate = &rest[idx..];
if candidate.starts_with("http://") || candidate.starts_with("https://") {
if idx > 0 {
out.push((rest[..idx].to_string(), false));
}
let end = candidate
.find(char::is_whitespace)
.unwrap_or(candidate.len());
let mut url = &candidate[..end];
while let Some(last) = url.chars().last() {
if matches!(last, '.' | ',' | ')' | ']' | '}' | '!' | '?' | ';' | ':') {
url = &url[..url.len() - last.len_utf8()];
} else {
break;
}
}
out.push((url.to_string(), true));
rest = &candidate[url.len()..];
} else {
// A bare "http" that isn't a scheme; emit it as text and move past it.
let split_at = idx + 4;
out.push((rest[..split_at].to_string(), false));
rest = &rest[split_at..];
}
}
if !rest.is_empty() {
out.push((rest.to_string(), false));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_urls_isolates_links() {
let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now");
assert_eq!(
segs,
vec![
("Tor is live. Read more: ".to_string(), false),
("https://docs.goblin.st".to_string(), true),
(" now".to_string(), false),
]
);
}
#[test]
fn split_urls_trims_trailing_punctuation_and_handles_no_url() {
let segs = split_urls("See https://x.io.");
assert_eq!(
segs,
vec![
("See ".to_string(), false),
("https://x.io".to_string(), true),
(".".to_string(), false),
]
);
assert_eq!(
split_urls("plain text"),
vec![("plain text".to_string(), false)]
);
}
fn news(d: &str, created_at: i64, title: &str, lang: Option<&str>) -> NewsItem {
NewsItem {
d: d.to_string(),
created_at,
title: title.to_string(),
summary: String::new(),
lang: lang.map(|s| s.to_string()),
published_at: None,
}
}
#[test]
fn language_from_event_tag_wins() {
// A stored event language tag is authoritative, even if the title has no
// marker (bare `["l","de"]`) or a differing marker.
let it = news("a", 1, "Neuigkeiten", Some("de"));
assert_eq!(news_language(&it).as_deref(), Some("de"));
// NIP-32-style tag is stored the same way (code already extracted upstream).
let it = news("b", 1, "News", Some("FR"));
assert_eq!(news_language(&it).as_deref(), Some("fr"));
// A non-code tag value is ignored, falling through to the title (English).
let it = news("c", 1, "News", Some("english"));
assert_eq!(news_language(&it), None);
}
#[test]
fn language_from_title_suffix_marker() {
let it = news("a", 1, "2026-07-05 Welcome to Goblin [de]", None);
assert_eq!(news_language(&it).as_deref(), Some("de"));
// Case-insensitive.
let it = news("b", 1, "Bonjour [FR]", None);
assert_eq!(news_language(&it).as_deref(), Some("fr"));
// Only a marker at the very end counts; a bracketed word mid-title does not.
let it = news("c", 1, "Read the [guide] today", None);
assert_eq!(news_language(&it), None);
}
#[test]
fn no_marker_means_english() {
let it = news("a", 1, "Welcome to Goblin", None);
assert_eq!(news_language(&it), None);
// A non-two-letter bracket suffix is not a language marker.
let it = news("b", 1, "Build 137 [beta]", None);
assert_eq!(news_language(&it), None);
}
#[test]
fn display_title_strips_marker() {
assert_eq!(
news_display_title("2026-07-05 Welcome to Goblin [de]"),
"2026-07-05 Welcome to Goblin"
);
assert_eq!(news_display_title("Bonjour [FR]"), "Bonjour");
// No marker: unchanged.
assert_eq!(news_display_title("Welcome to Goblin"), "Welcome to Goblin");
// Non-language bracket suffix: left intact.
assert_eq!(news_display_title("Build 137 [beta]"), "Build 137 [beta]");
}
#[test]
fn date_is_iso_utc_day_only() {
// 2026-07-01 00:00:00 UTC.
assert_eq!(news_date_iso(1_782_864_000), "2026-07-01");
// A within-the-day stamp still yields the same calendar date (no time).
assert_eq!(news_date_iso(1_782_864_000 + 3600 * 13 + 59), "2026-07-01");
// Epoch.
assert_eq!(news_date_iso(0), "1970-01-01");
}
#[test]
fn title_clamped_ellipsizes_past_max() {
// Short titles pass through untouched.
let short = "News in Your Language";
assert_eq!(news_title_clamped(short), short);
// Exactly the cap is untouched.
let at_cap = "x".repeat(NEWS_TITLE_MAX_CHARS);
assert_eq!(news_title_clamped(&at_cap), at_cap);
// One over the cap ellipsizes to exactly the cap length (… included).
let over = "y".repeat(NEWS_TITLE_MAX_CHARS + 10);
let clamped = news_title_clamped(&over);
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
assert!(clamped.ends_with('…'));
// Multi-byte titles clamp on char boundaries (no panic / no split codepoint).
let cjk = "".repeat(NEWS_TITLE_MAX_CHARS + 5);
let clamped = news_title_clamped(&cjk);
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
}
#[test]
fn select_matches_locale_then_falls_back_to_english() {
let pool = vec![
news("en", 100, "Welcome to Goblin", None),
news("de", 90, "Willkommen bei Goblin [de]", None),
news("fr", 80, "Bonjour", Some("fr")),
];
// German locale → the German article.
assert_eq!(select_news(&pool, "de").unwrap().d, "de");
// French locale (via event tag) → the French article.
assert_eq!(select_news(&pool, "fr").unwrap().d, "fr");
// English locale → the English (unmarked) article.
assert_eq!(select_news(&pool, "en").unwrap().d, "en");
// A locale with no article → fall back to the newest English article.
assert_eq!(select_news(&pool, "ru").unwrap().d, "en");
}
#[test]
fn select_picks_newest_within_language_slice() {
let pool = vec![
news("de-old", 50, "Alt [de]", None),
news("de-new", 150, "Neu [de]", None),
news("en", 200, "Newest overall", None),
];
// Within German, the newest German article wins — NOT the newer English one.
assert_eq!(select_news(&pool, "de").unwrap().d, "de-new");
}
#[test]
fn locale_folding_maps_zh_cn_to_zh() {
rust_i18n::set_locale("zh-CN");
assert_eq!(news_locale_code(), "zh");
rust_i18n::set_locale("de");
assert_eq!(news_locale_code(), "de");
rust_i18n::set_locale("en");
assert_eq!(news_locale_code(), "en");
}
#[test]
fn empty_pool_selects_nothing() {
assert!(select_news(&[], "de").is_none());
}
}
+34 -13
View File
@@ -54,12 +54,6 @@ pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
(to(r), to(g), to(b))
}
/// Standard HSL → RGB → `#rrggbb`.
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
let (r, g, b) = hsl_rgb8(h, s, l);
format!("#{r:02x}{g:02x}{b:02x}")
}
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
/// lowercase hex pubkey used as the seed everywhere.
pub fn to_hex_seed(id: &str) -> String {
@@ -70,21 +64,48 @@ pub fn to_hex_seed(id: &str) -> String {
}
}
/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`.
/// Shared by the Grin-mark avatar and the bare-background variant so both draw
/// the byte-identical gradient for one key. Keep this math in lockstep with the
/// shared reference port.
fn gradient_params(hex: &str) -> (String, String, f64) {
/// Gradient stop colors (RGB bytes) + rotation angle derived from the seed `hex`.
/// The single source of the per-identity gradient math; both the `#rrggbb`
/// string form (for the SVG avatar) and the byte form (for the small egui cue)
/// come from here, so a dot/edge cue matches the avatar exactly. Keep this in
/// lockstep with the shared reference port.
fn gradient_rgb(hex: &str) -> ((u8, u8, u8), (u8, u8, u8), f64) {
let hash = Sha256::digest(hex.as_bytes());
let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0;
let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0;
let h2 = (base + offset) % 360.0;
let angle = (hash[3] as f64 / 255.0) * 360.0;
let c1 = hsl_to_rgb(base, 0.62, 0.55);
let c2 = hsl_to_rgb(h2, 0.62, 0.42);
let c1 = hsl_rgb8(base, 0.62, 0.55);
let c2 = hsl_rgb8(h2, 0.62, 0.42);
(c1, c2, angle)
}
/// Gradient stop colors (`#rrggbb`) + rotation angle for the SVG avatar.
fn gradient_params(hex: &str) -> (String, String, f64) {
let (c1, c2, angle) = gradient_rgb(hex);
let hex_of = |(r, g, b): (u8, u8, u8)| format!("#{r:02x}{g:02x}{b:02x}");
(hex_of(c1), hex_of(c2), angle)
}
/// The two gradient stop colors (RGB bytes) for an identity, seeded by the same
/// pubkey math as its gradient avatar, so a small dot or edge cue drawn from
/// these matches that identity's avatar. `id` may be an npub or raw hex.
pub fn gradient_rgb8(id: &str) -> ((u8, u8, u8), (u8, u8, u8)) {
let hex = to_hex_seed(id);
let (c1, c2, _angle) = gradient_rgb(&hex);
(c1, c2)
}
/// The identity's gradient stops PLUS the rotation angle (degrees), so a small
/// egui-drawn badge can reproduce the same rotated linear gradient the SVG
/// avatar uses (`gradient_avatar_svg`: `x1=0 y1=0 x2=1 y2=1` then
/// `rotate(angle, 0.5, 0.5)`). `id` may be an npub or raw hex.
pub fn gradient_stops(id: &str) -> ((u8, u8, u8), (u8, u8, u8), f32) {
let hex = to_hex_seed(id);
let (c1, c2, angle) = gradient_rgb(&hex);
(c1, c2, angle as f32)
}
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
/// are inlined into ONE html document; for a standalone document (how egui
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -804,7 +804,7 @@ impl OnboardingContent {
ui.label(
// Relay-gated readiness: "connected over Nym" only once a
// relay is actually live, not merely when the tunnel is warm.
RichText::new(if crate::nym::transport_ready() {
RichText::new(if crate::tor::transport_ready() {
t!("goblin.onboarding.identity.connected_nym")
} else {
t!("goblin.onboarding.identity.connecting_nym")
+320 -26
View File
@@ -145,6 +145,13 @@ pub struct SendFlow {
fee_requested_for: Option<u64>,
/// Draft note held while the editor modal is open, so Cancel discards it.
note_draft: String,
/// Proof-on-request context parsed from the pay URI (frozen contract 4.1).
/// `proof` is the merchant's grin1 proof address (presence => proof mode),
/// `order` the opaque invoice handle, `notify` the watcher npub. All `None`
/// for a normal send, so person-to-person payments carry nothing extra.
proof: Option<String>,
order: Option<String>,
notify: Option<String>,
}
impl Default for SendFlow {
@@ -171,6 +178,9 @@ impl Default for SendFlow {
receipt_npub: None,
fee_requested_for: None,
note_draft: String::new(),
proof: None,
order: None,
notify: None,
}
}
}
@@ -210,6 +220,87 @@ impl SendFlow {
self.stage = Stage::Recipient;
}
/// Build a send flow from a scanned/deep-linked payment payload and land it
/// on the SAME screen a scanned QR would: a full checkout (recipient key +
/// amount) opens the prefilled review directly; anything else drops into the
/// recipient search with any amount/memo prefilled. Used by the `goblin:` /
/// `nostr:` deep-link handler so a web "Open in Goblin" button reaches the
/// exact QR-to-review destination. `now` is the egui input time (debounce seed
/// for the search path).
pub fn from_deeplink(uri: &str, wallet: &Wallet, now: f64) -> Self {
let mut flow = Self::default();
flow.apply_scan(uri, wallet, now);
flow
}
/// Apply a scanned/deep-linked payment payload (npub / `nostr:` / `goblin:`
/// URI / @handle, with optional amount+memo), mutating this flow exactly as
/// the camera scan path does. Shared by the QR scanner and the deep-link
/// handler so both spell the same destination. `now` seeds the search-box
/// debounce.
fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) {
// Proof-on-request context (frozen contract 4.1) rides alongside the
// recipient/amount/memo routing and is independent of whether we land on
// Review or Search, so pull it from the same pure parse. `order` is a
// non-editable routing key, never shown as the memo. Absent params leave
// proof mode off, so a normal payment is byte-identical to before.
let pay = crate::nostr::payuri::parse(text);
self.proof = pay.proof;
self.order = pay.order;
self.notify = pay.notify;
match recognize_scan(text) {
// GoblinPay checkout / full pay link: recipient key AND amount are
// both known, so skip discovery and open review directly, prefilled.
// (Owner-ordered: a merchant checkout shouldn't re-walk the recipient
// search.) A cheap LOCAL contact lookup supplies a friendly name when
// we already know them; otherwise the short npub — no network
// resolution on this path.
ScanPrefill::Review {
hex,
relay_hints,
amount,
memo,
} => {
let name = wallet
.nostr_service()
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)))
.unwrap_or_else(|| short_npub(&hex));
self.recipient = Some(Recipient {
name,
npub: hex,
relay_hints,
});
self.amount = amount;
if let Some(memo) = memo {
self.note = memo;
}
self.error = None;
self.stage = Stage::Review;
}
// Only a recipient (or a name/@handle needing resolution): drop it
// into the search box so the picker's debounced lookup resolves +
// verifies it like typed input, exactly as before. Any amount/memo
// still prefill.
ScanPrefill::Search {
recipient,
amount,
memo,
} => {
self.search = recipient;
self.input_changed_at = now;
self.lookup_query.clear();
self.net_candidate = None;
if let Some(amount) = amount {
self.amount = amount;
}
if let Some(memo) = memo {
self.note = memo;
}
self.stage = Stage::Recipient;
}
}
}
/// Render the flow. Returns true when the flow is finished (close it).
pub fn ui(
&mut self,
@@ -488,6 +579,7 @@ impl SendFlow {
ui,
&name,
&data::full_npub(&npub),
"",
&npub,
"",
false,
@@ -550,6 +642,7 @@ impl SendFlow {
ui,
&c.name,
&tag,
"",
&c.npub,
"",
false,
@@ -680,31 +773,10 @@ impl SendFlow {
// seed words or slatepack contents into the search box.
match &result {
QrScanResult::Text(text) => {
// Parse as a (possibly amount-bearing) pay-URI. UNTRUSTED
// input: the parser is pure, fail-closed, and only ever
// PREFILLS — the recipient still resolves + verifies via the
// picker and the amount/review screens still gate the send.
// A bad amount/memo is dropped; a bare `nostr:<nprofile>`
// behaves exactly as before.
let pay = crate::nostr::payuri::parse(text);
// Drop the scanned key into the search box; the picker's
// debounced lookup resolves + verifies it like typed input.
self.search = pay.recipient;
self.input_changed_at = ui.input(|i| i.time);
self.lookup_query.clear();
self.net_candidate = None;
// Prefill the amount only when the wallet's own parser
// accepted it (strictly positive). We stay on the normal
// picker -> amount/review flow; nothing auto-advances.
if let Some(amount) = pay.amount {
self.amount = amount;
}
// Prefill the send note from the (already sanitized) memo;
// it rides along into the tx message via `dispatch`.
if let Some(memo) = pay.memo {
self.note = memo;
}
let _ = wallet;
// Classify + apply the scan. UNTRUSTED input: the parser is pure
// and fail-closed, and every path still gates the send on the
// review screen (the user confirms) — nothing auto-SENDS.
self.apply_scan(text, wallet, ui.input(|i| i.time));
}
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
}
@@ -976,7 +1048,7 @@ impl SendFlow {
w::amount_text_centered(ui, &display, 64.0);
}
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = super::pairing_preview(grin) {
if let Some(preview) = super::pairing_preview(grin, ui.ctx()) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
@@ -1151,6 +1223,34 @@ impl SendFlow {
&format!("\u{201C}{}\u{201D}", self.note.trim()),
);
}
// Proof-on-request indicator (frozen contract W3): a small, informational
// row shown only when this payment will return a receiver-signed Grin
// payment proof. Seeing it is the whole interaction: nothing to accept or
// configure. Absent for every ordinary send.
if self.proof.is_some() && !self.request {
w::info_row(
ui,
&t!("goblin.send.row_proof"),
&t!("goblin.send.row_proof_val"),
);
// Make the proof's hidden delivery target visible and consented: when
// a `notify` npub rode in on the scanned URI, the wallet gift-wraps
// the full signed proof (with the buyer's sender address + kernel) to
// that key at finalize. Show exactly where it goes so it is a seen,
// chosen recipient rather than a silent watcher: if you scanned it,
// you asked for it. Display only; nothing here changes what is sent.
if let Some(notify) = self.notify.as_deref() {
// Truncate the bech32 npub for a single-line row (npub1abc…wxyz).
// `notify` is a validated npub (ASCII, well over 18 chars), so the
// byte slices land on char boundaries.
let short = if notify.len() > 18 {
format!("{}{}", &notify[..12], &notify[notify.len() - 6..])
} else {
notify.to_string()
};
w::info_row(ui, &t!("goblin.send.row_proof_shared"), &short);
}
}
if self.request {
w::info_row(
ui,
@@ -1273,6 +1373,9 @@ impl SendFlow {
recipient.npub.clone(),
note,
recipient.relay_hints.clone(),
self.proof.clone(),
self.order.clone(),
self.notify.clone(),
));
}
}
@@ -1509,3 +1612,194 @@ fn resolve_nip05_blocking(name: &str, domain: &str) -> Option<nip05::Nip05Resolu
.ok()
.flatten()
}
/// What a scanned QR resolves to for the send flow.
#[derive(Debug, PartialEq, Eq)]
enum ScanPrefill {
/// A GoblinPay payment: the recipient key AND a validated amount are both
/// already known, so the flow skips the recipient search/discovery step and
/// opens the review screen directly. The user still confirms on review.
Review {
/// Recipient pubkey hex.
hex: String,
/// nprofile relay hints (delivery targets for an undiscoverable 10050).
relay_hints: Vec<String>,
/// Validated decimal-GRIN amount string.
amount: String,
/// Optional sanitized memo → send note.
memo: Option<String>,
},
/// Only a recipient string (a bare key/name, or a payload without a usable
/// amount): drop it into the search box and let the picker resolve it, the
/// pre-existing behavior. Any amount/memo still prefill.
Search {
recipient: String,
amount: Option<String>,
memo: Option<String>,
},
}
/// Decode a recipient string (`npub` / `nprofile` / 64-char hex, with an
/// optional `nostr:` prefix) to a `(pubkey hex, relay hints)` pair. `None` for a
/// name / `@handle` (which needs a NIP-05 network resolution) or garbage — those
/// still go through the search box. Pure: no I/O.
fn decode_recipient_key(input: &str) -> Option<(String, Vec<String>)> {
use nostr_sdk::nips::nip19::Nip19Profile;
use nostr_sdk::{FromBech32, PublicKey};
let key = input.trim().strip_prefix("nostr:").unwrap_or(input.trim());
if let Ok(pk) = PublicKey::from_bech32(key) {
Some((pk.to_hex(), vec![]))
} else if let Ok(p) = Nip19Profile::from_bech32(key) {
let hints = p.relays.iter().map(|r| r.to_string()).collect();
Some((p.public_key.to_hex(), hints))
} else if key.len() == 64 && key.chars().all(|c| c.is_ascii_hexdigit()) {
Some((key.to_lowercase(), vec![]))
} else {
None
}
}
/// Classify a scanned QR payload (see [`ScanPrefill`]). A GoblinPay checkout QR
/// carries the recipient AND amount, so when the recipient decodes to a concrete
/// key locally and the amount validates, we can skip discovery and go straight
/// to review. Every other payload (name/@handle, amount-less key, non-`nostr:`
/// text) degrades to the search-box path. Pure and total, so it is unit-testable
/// without a wallet or camera.
fn recognize_scan(scanned: &str) -> ScanPrefill {
let pay = crate::nostr::payuri::parse(scanned);
match (decode_recipient_key(&pay.recipient), pay.amount.clone()) {
(Some((hex, relay_hints)), Some(amount)) => ScanPrefill::Review {
hex,
relay_hints,
amount,
memo: pay.memo,
},
_ => ScanPrefill::Search {
recipient: pay.recipient,
amount: pay.amount,
memo: pay.memo,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
// A real, valid bech32 npub for decode tests (the Goblin news key).
const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
#[test]
fn goblinpay_uri_with_amount_goes_straight_to_review() {
let uri = format!("nostr:{NPUB}?amount=1.5&memo=MM-ABC123");
match recognize_scan(&uri) {
ScanPrefill::Review {
hex,
amount,
memo,
relay_hints,
} => {
assert_eq!(hex.len(), 64);
assert_eq!(amount, "1.5");
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
assert!(relay_hints.is_empty());
}
other => panic!("expected Review, got {other:?}"),
}
}
#[test]
fn goblin_scheme_pay_link_goes_straight_to_review() {
// The `goblin:` deep-link scheme (web "Open in Goblin" buttons) must
// classify identically to the `nostr:` QR payload: a full pay link
// (key + amount) lands on the prefilled review.
let uri = format!("goblin:{NPUB}?amount=1.5&memo=MM-ABC123");
match recognize_scan(&uri) {
ScanPrefill::Review {
hex,
amount,
memo,
relay_hints,
} => {
assert_eq!(hex.len(), 64);
assert_eq!(amount, "1.5");
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
assert!(relay_hints.is_empty());
}
other => panic!("expected Review, got {other:?}"),
}
}
#[test]
fn goblin_and_nostr_schemes_classify_equivalently() {
// Same recipient + amount + memo under either scheme yields the same
// classification and the same extracted values.
for scheme in ["goblin", "nostr"] {
match recognize_scan(&format!("{scheme}:{NPUB}?amount=2&memo=Hi")) {
ScanPrefill::Review { amount, memo, .. } => {
assert_eq!(amount, "2");
assert_eq!(memo.as_deref(), Some("Hi"));
}
other => panic!("{scheme}: expected Review, got {other:?}"),
}
}
}
#[test]
fn key_without_amount_uses_search_box() {
// A bare recipient key with no amount is NOT a full payment; the picker
// resolves it as before (search box), no straight-to-review.
match recognize_scan(&format!("nostr:{NPUB}")) {
ScanPrefill::Search {
recipient,
amount,
memo,
} => {
assert_eq!(recipient, NPUB);
assert_eq!(amount, None);
assert_eq!(memo, None);
}
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn name_handle_with_amount_still_uses_search_box() {
// A NIP-05 name needs a network resolution, so even with an amount it goes
// through the search box (which prefills the amount along the way).
match recognize_scan("nostr:alice@goblin.st?amount=2") {
ScanPrefill::Search {
recipient,
amount,
memo,
} => {
assert_eq!(recipient, "alice@goblin.st");
assert_eq!(amount.as_deref(), Some("2"));
assert_eq!(memo, None);
}
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn non_nostr_payload_uses_search_box() {
match recognize_scan("just some text") {
ScanPrefill::Search { recipient, .. } => assert_eq!(recipient, "just some text"),
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn decode_key_variants() {
// Bech32 npub, with and without the scheme prefix.
assert!(decode_recipient_key(NPUB).is_some());
assert!(decode_recipient_key(&format!("nostr:{NPUB}")).is_some());
// 64-char hex is accepted and lower-cased.
let hex = "AB".repeat(32);
let (out, _) = decode_recipient_key(&hex).unwrap();
assert_eq!(out, hex.to_lowercase());
// A name / @handle is not a concrete key.
assert!(decode_recipient_key("alice@goblin.st").is_none());
assert!(decode_recipient_key("@alice").is_none());
}
}
+315 -58
View File
@@ -464,13 +464,59 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
/// A balance hero block: kicker, big number + ツ, optional fiat line.
/// `updating` marks a zero balance that is only zero because funds are in
/// flight or the first sync is still running.
/// Honest subline shown under the balance figure. A wallet that can't reach a
/// node must never present a bare `0` (or a silently-stale number) as if it were
/// a live, confirmed balance.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum BalanceSubline {
/// Nothing to add: the shown balance is live and non-zero.
None,
/// Balance reads 0 while a sync/first-scan is in progress or funds are in
/// flight — say "updating", not "empty".
Updating,
/// Balance reads 0 and the node is unreachable with nothing cached — say
/// "can't reach node", never a bare 0.
Unreachable,
/// A cached (last-known) balance is shown but the node is currently
/// unreachable — flag it as possibly stale.
Stale,
}
/// Pure decision for the balance subline. `updating` means a sync is in progress
/// (or funds are in flight); `error` means the wallet currently can't reach a
/// node. Priority: updating > unreachable > stale.
pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSubline {
if total == 0 && updating {
BalanceSubline::Updating
} else if total == 0 && error {
BalanceSubline::Unreachable
} else if error {
BalanceSubline::Stale
} else {
BalanceSubline::None
}
}
/// What the fiat subline should render under the balance. `None` (pairing off)
/// draws no line at all; otherwise the line is honest about its state and never
/// paints a stale rate as if current.
pub enum FiatLine {
/// A ready "≈ … · 1ツ = …" line built from a fresh rate.
Text(String),
/// A live fetch is in flight; show a subtle placeholder, not a number.
Loading,
/// The rate could not be fetched; say so rather than show an old value.
Unavailable,
}
pub fn balance_hero(
ui: &mut Ui,
total: u64,
spendable: u64,
updating: bool,
error: bool,
sync_pct: u8,
fiat: Option<&str>,
fiat: Option<FiatLine>,
size: f32,
) {
let t = theme::tokens();
@@ -498,30 +544,70 @@ pub fn balance_hero(
);
});
}
// A fresh sync or funds in flight leave a stark 0 that reads as "funds
// vanished" — say the balance is still updating, with the scan % when the
// node reports one (1..99), so the user sees progress without opening the
// wallet switcher.
if total == 0 && updating {
let label = if (1..100).contains(&sync_pct) {
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
} else {
t!("goblin.home.balance_updating").to_string()
// A stark 0 (or a stale number) reads as "funds vanished". Pick the honest
// subline: still-updating, node-unreachable, or last-known-balance. See
// [`balance_subline`] for the pure state machine.
match balance_subline(total, updating, error) {
BalanceSubline::Updating => {
let label = if (1..100).contains(&sync_pct) {
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
} else {
t!("goblin.home.balance_updating").to_string()
};
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(12.5, fonts::medium()))
.color(t.text_dim),
);
});
}
BalanceSubline::Unreachable => {
// Node unreachable and nothing cached yet: a bare 0 would claim the
// wallet is empty. Say the truth so the user switches nodes instead
// of assuming funds vanished.
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.home.cant_reach_node"))
.font(FontId::new(12.5, fonts::medium()))
.color(t.neg),
);
});
}
BalanceSubline::Stale => {
// A cached balance is shown but we can't currently reach a node:
// flag it as possibly stale rather than presenting it as live.
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.home.balance_stale"))
.font(FontId::new(12.5, fonts::medium()))
.color(t.text_dim),
);
});
}
BalanceSubline::None => {}
}
if let Some(fiat) = fiat {
// Loading and Unavailable both render a subtle dim line — never a stale
// number. While loading, nudge egui to re-poll so the rate pops in once the
// live fetch lands even if the view is otherwise idle (bounded to the time
// the balance is actually on screen — not a background timer).
let text = match fiat {
FiatLine::Text(s) => s,
FiatLine::Loading => {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
"≈ …".to_string()
}
FiatLine::Unavailable => t!("goblin.home.fiat_unavailable").to_string(),
};
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(12.5, fonts::medium()))
.color(t.text_dim),
);
});
}
if let Some(fiat) = fiat {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(fiat)
RichText::new(text)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
@@ -529,12 +615,15 @@ pub fn balance_hero(
}
}
/// An activity row: avatar, title, subtitle, signed amount.
/// An activity row: avatar, a left title/message column that truncates, and a
/// right column with the signed amount over the date/time. `time` is the
/// right-side timestamp (empty draws no time line — e.g. a canceled tx).
/// Returns the row click response.
pub fn activity_row(
ui: &mut Ui,
title: &str,
subtitle: &str,
note: &str,
time: &str,
id: &str,
amount: &str,
incoming: bool,
@@ -543,7 +632,10 @@ pub fn activity_row(
tex: Option<&egui::TextureHandle>,
) -> Response {
let t = theme::tokens();
let row_h = 60.0;
// A touch taller than a single-line row so the amount can sit centered
// against the two-line title/subtitle stack with clear breathing room
// above and below instead of colliding with the title baseline.
let row_h = 64.0;
let (rect, resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
let mut content = ui.new_child(
@@ -572,41 +664,77 @@ pub fn activity_row(
avatar_any(ui, title, id, 40.0, tex);
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add(
egui::Label::new(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
// Single-line, truncated: keeps the fixed-height row tidy even when
// the subtitle is a long value (e.g. a full npub in the picker).
ui.add(
egui::Label::new(
RichText::new(subtitle)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
)
.truncate(),
);
});
// Right column FIRST so the left title/message column is bounded to the
// remaining width and truncates cleanly. The amount sits on top with the
// date/time right-aligned directly beneath it; a row with no timestamp
// (a canceled tx) draws no time line at all.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// A canceled tx delivered no funds: mute the amount so it never
// reads as a completed green credit (or a real debit).
ui.label(
RichText::new(amount)
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(if canceled {
t.text_dim
} else if incoming {
t.pos
} else {
t.text
}),
);
let amt_ink = if canceled {
t.text_dim
} else if incoming {
t.pos
} else {
t.text
};
let amt_font = FontId::new(15.0, fonts::mono_semibold());
let time_font = FontId::new(13.0, fonts::regular());
let amt_g = (!amount.is_empty()).then(|| {
ui.painter()
.layout_no_wrap(amount.to_string(), amt_font, amt_ink)
});
let time_g = (!time.is_empty()).then(|| {
ui.painter()
.layout_no_wrap(time.to_string(), time_font, t.text_dim)
});
let col_w = amt_g
.as_ref()
.map(|g| g.size().x)
.unwrap_or(0.0)
.max(time_g.as_ref().map(|g| g.size().x).unwrap_or(0.0));
if col_w > 0.0 {
let amt_h = amt_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
let time_h = time_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
let col_h = amt_h + if time_h > 0.0 { 2.0 + time_h } else { 0.0 };
ui.allocate_ui_with_layout(
Vec2::new(col_w, col_h),
Layout::top_down(Align::Max),
|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if let Some(g) = amt_g {
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
ui.painter().galley(r.min, g, amt_ink);
}
if let Some(g) = time_g {
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
ui.painter().galley(r.min, g, t.text_dim);
}
},
);
ui.add_space(10.0);
}
// Remaining width to the left: the counterparty/title on top, the
// message pinned left and truncated with an ellipsis beneath it.
ui.vertical(|ui| {
ui.add_space(2.0);
ui.add(
egui::Label::new(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
if !note.is_empty() {
ui.add(
egui::Label::new(
RichText::new(note)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
)
.truncate(),
);
}
});
});
});
// Divider.
@@ -938,3 +1066,132 @@ impl HoldToSend {
false
}
}
/// Like [`info_row`], but with the per-identity gradient dot drawn just left of
/// the value — the transaction-detail legend for which held identity a payment
/// used, alongside its name/npub.
pub fn info_row_dot(ui: &mut Ui, label: &str, value: &str, seed: &str) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add(
egui::Label::new(
RichText::new(value)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
ui.add_space(7.0);
let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
identity_dot(ui.painter(), rect.center(), 4.0, seed);
});
});
ui.add_space(8.0);
ui.painter().hline(
ui.min_rect().left()..=ui.min_rect().right(),
ui.cursor().top(),
Stroke::new(1.0, t.line),
);
ui.add_space(8.0);
}
/// A per-identity cue: a small disc filled with an identity's OWN gradient — the
/// same pubkey-seeded, rotated two-stop linear gradient its avatar uses
/// (`identicon`), so the disc reads as a color legend for that identity and
/// matches its avatar everywhere it appears (activity-row corner badge, switcher,
/// transaction detail). The fill is a true smooth gradient (an egui mesh, not a
/// flat chip) at 0.9 opacity, with a 1px theme-aware hairline ring that keeps it
/// legible on both a pure-white and a pure-black background. `seed` is the
/// identity's npub or pubkey hex. Single shared renderer, so the look is tuned in
/// exactly one place. Matches the owner-approved cue mock precisely.
pub fn identity_dot(painter: &egui::Painter, center: egui::Pos2, radius: f32, seed: &str) {
let t = theme::tokens();
let ((r1, g1, b1), (r2, g2, b2), angle) = super::identicon::gradient_stops(seed);
// 0.9 fill opacity per the mock.
const FILL_A: u8 = 230;
let lerp = |a: u8, b: u8, f: f32| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
let col = |f: f32| {
let f = f.clamp(0.0, 1.0);
Color32::from_rgba_unmultiplied(lerp(r1, r2, f), lerp(g1, g2, f), lerp(b1, b2, f), FILL_A)
};
// Reproduce the SVG's rotated linear gradient (base axis 0,0 -> 1,1 in the
// unit bounding box, then rotate(angle) about the centre). For a rim point in
// direction (cv, sv) on the unit circle, its bounding-box offset from centre
// is (0.5·cv, 0.5·sv); inverse-rotating by the gradient angle and projecting
// onto the (1,1) axis gives the stop parameter t.
let (sin_a, cos_a) = angle.to_radians().sin_cos();
let t_at = |cv: f32, sv: f32| -> f32 {
let dx = 0.5 * cv;
let dy = 0.5 * sv;
let rx = cos_a * dx + sin_a * dy;
let ry = -sin_a * dx + cos_a * dy;
(rx + ry) * 0.5 + 0.5
};
// Gradient-filled disc as a triangle fan; egui interpolates the per-vertex
// colours, so the fill is a smooth gradient at this size.
let n = 28usize;
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(center, col(t_at(0.0, 0.0)));
for i in 0..=n {
let a = std::f32::consts::TAU * (i as f32 / n as f32);
let (sv, cv) = a.sin_cos();
mesh.colored_vertex(center + radius * egui::vec2(cv, sv), col(t_at(cv, sv)));
}
for i in 1..=n as u32 {
mesh.add_triangle(0, i, i + 1);
}
painter.add(egui::Shape::mesh(mesh));
// 1px theme-aware hairline ring (matches the mock): near-black on light,
// near-white on dark, so the disc has a defined edge on either background and
// against the avatar it badges.
let ring = if t.dark_base {
Color32::from_rgba_unmultiplied(250, 250, 247, 82)
} else {
Color32::from_rgba_unmultiplied(14, 14, 12, 71)
};
painter.circle_stroke(center, radius, Stroke::new(1.0, ring));
}
#[cfg(test)]
mod tests {
use super::{BalanceSubline, balance_subline};
// A live, non-zero balance needs no subline.
#[test]
fn live_balance_has_no_subline() {
assert_eq!(balance_subline(1_000, false, false), BalanceSubline::None);
}
// Zero while syncing / funds in flight is "updating", not "empty".
#[test]
fn zero_while_updating_says_updating() {
assert_eq!(balance_subline(0, true, false), BalanceSubline::Updating);
}
// Zero with an unreachable node and nothing cached must say so, never a
// bare 0 that reads as "wallet empty" (the silent-zero incident).
#[test]
fn zero_with_node_error_says_unreachable() {
assert_eq!(balance_subline(0, false, true), BalanceSubline::Unreachable);
}
// A cached balance shown during a node outage is flagged stale, not passed
// off as a live figure.
#[test]
fn cached_balance_with_error_is_stale() {
assert_eq!(balance_subline(500, false, true), BalanceSubline::Stale);
}
// Updating wins over error while the balance is still zero: a fresh switch
// to a new node shows progress, not a scary red banner, until it errors.
#[test]
fn updating_takes_priority_over_error_at_zero() {
assert_eq!(balance_subline(0, true, true), BalanceSubline::Updating);
}
}
+37 -1
View File
@@ -43,6 +43,7 @@ use crate::gui::views::wallets::wallet::RecoverySettings;
use crate::gui::views::wallets::wallet::types::{WalletContentContainer, wallet_status_text};
use crate::gui::views::{Content, Modal, TitlePanel, View};
use crate::http::{ReleaseInfo, retrieve_release};
use crate::node::Node;
use crate::settings::AppUpdate;
use crate::wallet::types::{ConnectionMethod, WalletTask};
use crate::wallet::{Wallet, WalletList};
@@ -52,6 +53,10 @@ pub struct WalletsContent {
/// List of wallets.
wallets: WalletList,
/// When Android back was last swallowed at the wallet Home, arming the
/// standard double-back-to-exit window (the first press shows a hint).
back_exit_at: Option<std::time::Instant>,
/// Initial wallet creation [`Modal`] content.
add_wallet_modal_content: AddWalletModal,
/// Wallet opening [`Modal`] content.
@@ -92,6 +97,7 @@ impl Default for WalletsContent {
fn default() -> Self {
Self {
wallets: WalletList::default(),
back_exit_at: None,
wallet_selection_content: WalletListModal::new(None, None, true),
open_wallet_content: OpenWalletModal::new(),
add_wallet_modal_content: AddWalletModal::default(),
@@ -411,7 +417,26 @@ impl WalletsContent {
// never a back fallback to the chooser.
if self.wallet_content.can_back() {
self.wallet_content.back(cb);
return false;
}
// Wallet Home with nothing to pop: double-back to the WALLET
// SWITCHER. The first press arms a short window and shows a "press
// back again" hint; a second press inside it DESELECTS the wallet —
// it stays UNLOCKED (no close, no logout; its nostr listener keeps
// running) and the chooser shows. Quitting the app lives only at
// the switcher, via GRIM's native exit-confirmation on back.
// Sub-screens above kept normal back navigation, no hint.
if self
.back_exit_at
.map(|at| at.elapsed() <= Duration::from_secs(2))
.unwrap_or(false)
{
self.back_exit_at = None;
self.wallets.select(None);
return false;
}
self.back_exit_at = Some(std::time::Instant::now());
self.wallet_content.show_back_hint();
return false;
}
true
@@ -466,6 +491,17 @@ impl WalletsContent {
if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() {
Content::toggle_network_panel();
}
// Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the
// send-review flow instead of the slatepack message handler: stash it for
// the Goblin surface to open, and select/open the wallet with NO message
// so the surface shows. Same destination as scanning a checkout QR.
let data = match data {
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
crate::set_pending_pay_uri(d);
None
}
other => other,
};
// Pass data to single wallet or show wallets selection.
if wallets_size == 1 {
let w = self.wallets.list()[0].clone();
@@ -825,7 +861,7 @@ impl WalletsContent {
// Show button to see update information.
View::item_button(ui, CornerRadius::default(), NOTEPAD, None, || {
self.changelog_content = Some(ChangelogContent::new(update.changelog.clone()));
let title = format!("Grim {}", update.version);
let title = format!("Goblin {}", update.version);
Modal::new(ChangelogContent::MODAL_ID)
.position(ModalPosition::Center)
.title(title)
+6 -6
View File
@@ -16,7 +16,7 @@ use egui::scroll_area::ScrollBarVisibility;
use egui::{Id, OpenUrl, RichText, ScrollArea};
use crate::gui::Colors;
use crate::gui::icons::{BRACKETS_CURLY, GITHUB_LOGO, TELEGRAM_LOGO};
use crate::gui::icons::{BOOK, GITHUB_LOGO, TELEGRAM_LOGO};
use crate::gui::views::{Modal, View};
/// Application release changelog content.
@@ -26,11 +26,11 @@ pub struct ChangelogContent {
}
/// Endpoint for GitHub repository.
const GITHUB_URL: &'static str = "https://github.com/GetGrin/grim";
const GITHUB_URL: &'static str = "https://github.com/2ro/goblin";
/// Endpoint for Telegram releases channel.
const TELEGRAM_URL: &'static str = "https://t.me/grim_releases";
/// Endpoint for git repository.
const GIT_URL: &'static str = "https://code.gri.mw/GUI/grim";
const TELEGRAM_URL: &'static str = "https://t.me/goblinfamily";
/// Endpoint for project website.
const GIT_URL: &'static str = "https://docs.goblin.st";
impl ChangelogContent {
/// Create new content instance.
@@ -114,7 +114,7 @@ impl ChangelogContent {
columns[2].vertical_centered_justified(|ui| {
// Draw button to open repository link.
let mut git_clicked = false;
View::button(ui, BRACKETS_CURLY, Colors::white_or_black(false), || {
View::button(ui, BOOK, Colors::white_or_black(false), || {
git_clicked = true;
});
if git_clicked {
+6
View File
@@ -325,6 +325,12 @@ impl WalletContent {
self.goblin.take_switch_request()
}
/// Show the goblin surface's brief "press back again" hint (first back of
/// the double-back-to-switcher at the wallet Home).
pub fn show_back_hint(&mut self) {
self.goblin.show_back_hint();
}
/// Navigate back on navigation stack. Returns true if not consumed.
pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool {
if self.goblin.overlay_active() {
+1 -1
View File
@@ -19,4 +19,4 @@ mod release;
pub use release::*;
pub(crate) mod price;
pub use price::grin_rate;
pub use price::{RateState, grin_rate};
+163 -54
View File
@@ -12,11 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! GRIN price preview, cached per currency. Off by default (no pairing → no
//! fetch); only once the user opts into a pairing is the rate fetched — and it
//! goes over the Nym mixnet like everything else, never the clear net. The rate
//! barely moves and CoinGecko's free tier rate-limits frequent polling (the
//! shared exit IP all the more), so it refreshes on a slow cache, not live.
//! GRIN price preview. Off by default (no pairing → no fetch); only once the
//! user opts into a pairing is the rate fetched — and it goes over Tor like
//! everything else, never the clear net.
//!
//! The rate is fetched LIVE, on view: the fiat subline asks for the rate only
//! while the balance is actually on screen, and a fetch is kicked whenever the
//! last one aged past a short freshness window ([`FRESH_SECS`]). There is no
//! disk cache and no background timer — an idle or payment-listening wallet
//! never polls, so it costs nothing on battery. The rate lives in memory for
//! the freshness window so flipping between screens does not refetch, and a
//! fresh session starts blank and fetches on the first view.
use lazy_static::lazy_static;
use parking_lot::RwLock;
@@ -24,19 +30,19 @@ use std::collections::{HashMap, HashSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::AppConfig;
use crate::nym;
use crate::tor;
/// Cache refresh interval (seconds).
const REFRESH_SECS: i64 = 300;
/// How long an in-memory rate is considered current (seconds). Viewing the
/// balance with a rate older than this kicks a live refetch, and until a fresh
/// rate lands the stale value is NOT painted — the line shows loading, then the
/// new rate (or unavailable on failure). Short enough to track the market, long
/// enough that screen flips within the window reuse the same fetch.
const FRESH_SECS: i64 = 180;
/// Minimum delay between fetch attempts for a currency, so a failing fetch
/// (e.g. no network) does not respawn a thread every frame.
const RETRY_SECS: i64 = 30;
/// How stale a disk-cached rate may be and still be worth painting on cold start
/// (48h). Older than this and we start blank rather than show a very wrong price.
const SEED_MAX_AGE_SECS: i64 = 48 * 3600;
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
@@ -47,12 +53,29 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
const PROBE_ATTEMPTS: u32 = 3;
lazy_static! {
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
/// In-session GRIN rates per `vs_currency`: code -> (rate, fetched_at). Memory
/// only — never persisted, so a fresh session starts empty.
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
/// Currencies with a fetch currently in flight.
static ref FETCHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
/// Last fetch attempt per currency (unix secs).
static ref LAST_TRY: RwLock<HashMap<String, i64>> = RwLock::new(HashMap::new());
/// Currencies whose most recent completed attempt failed (with no fresh rate),
/// so the line can honestly say "unavailable" instead of spinning forever.
static ref FAILED: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
}
/// What the fiat line should render for a currency right now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RateState {
/// A rate fetched within the freshness window — safe to paint as current.
Fresh(f64),
/// No current rate yet, but a fetch is in flight (or just kicked): show a
/// subtle placeholder, not a stale number.
Loading,
/// The last fetch failed and nothing fresh is available: say so (or hide),
/// never fall back to an old value.
Unavailable,
}
fn now() -> i64 {
@@ -62,22 +85,51 @@ fn now() -> i64 {
.unwrap_or(0)
}
/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh,
/// triggering a background refresh otherwise. Returns `None` until the first
/// successful fetch for that currency.
pub fn grin_rate(vs: &str) -> Option<f64> {
let cached = { RATES.read().get(vs).cloned() };
let needs_refresh = match cached {
Some((_, ts)) => now() - ts > REFRESH_SECS,
None => true,
};
if needs_refresh {
trigger_refresh(vs.to_string());
}
cached.map(|(rate, _)| rate)
/// True if a rate fetched at `fetched_at` is still current as of `now`.
fn is_fresh(fetched_at: i64, now: i64) -> bool {
now - fetched_at <= FRESH_SECS
}
/// Spawn a background refresh for one currency (deduped per code).
/// Pure state decision, factored out for testing: given the (optional) cached
/// rate and the current fetch bookkeeping, decide what to render. A cached rate
/// older than the freshness window is deliberately NOT returned as `Fresh`.
fn classify(cached: Option<(f64, i64)>, now: i64, fetching: bool, failed: bool) -> RateState {
if let Some((rate, ts)) = cached {
if is_fresh(ts, now) {
return RateState::Fresh(rate);
}
}
// No fresh rate. A fetch in flight (or just kicked) reads as loading; a
// recently failed attempt with nothing fresh reads as unavailable.
if fetching {
RateState::Loading
} else if failed {
RateState::Unavailable
} else {
RateState::Loading
}
}
/// Get the render state of the GRIN rate against `vs` (e.g. "usd", "eur",
/// "btc"). Called from the fiat line while the balance is on screen: if the
/// in-session rate is missing or stale it kicks a live refetch over Tor, and it
/// never reports a rate older than the freshness window as current.
pub fn grin_rate(vs: &str) -> RateState {
let cached = { RATES.read().get(vs).cloned() };
let fresh = cached.map(|(_, ts)| is_fresh(ts, now())).unwrap_or(false);
if !fresh {
trigger_refresh(vs.to_string());
}
classify(
cached,
now(),
FETCHING.read().contains(vs),
FAILED.read().contains(vs),
)
}
/// Spawn a background refresh for one currency (deduped per code). Kicked only
/// from a view that is actually asking for the rate — never on a timer.
fn trigger_refresh(vs: String) {
let t = now();
{
@@ -99,33 +151,28 @@ fn trigger_refresh(vs: String) {
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ok = rt.block_on(async {
if let Some(rate) = fetch_rate(&vs).await {
record_rate(&vs, rate);
true
} else {
false
}
});
if ok {
FAILED.write().remove(&vs);
} else {
FAILED.write().insert(vs.clone());
}
FETCHING.write().remove(&vs);
});
}
/// Record a freshly fetched rate: into the in-memory cache (with `now()`) AND to
/// disk, so the next cold start can paint it instantly (see [`seed_from_disk`]).
/// Record a freshly fetched rate into the in-memory cache (with `now()`) and
/// clear any prior failure flag. Nothing is written to disk.
fn record_rate(vs: &str, rate: f64) {
let t = now();
RATES.write().insert(vs.to_string(), (rate, t));
AppConfig::set_last_rate(vs, rate, t);
}
/// Seed the in-memory cache from the disk-persisted last rate, if it is fresh
/// enough (< 48h). Inserted with its ORIGINAL timestamp so it reads as stale —
/// [`grin_rate`] returns it immediately for an instant preview, yet `needs_refresh`
/// stays true so a live refresh is still kicked. Called once, early in start().
pub fn seed_from_disk() {
if let Some((vs, rate, at)) = AppConfig::last_rate() {
if now() - at <= SEED_MAX_AGE_SECS {
RATES.write().entry(vs).or_insert((rate, at));
}
}
RATES.write().insert(vs.to_string(), (rate, now()));
FAILED.write().remove(vs);
}
/// Kick a refresh for the current pairing's currency the moment the tunnel is
@@ -133,7 +180,8 @@ pub fn seed_from_disk() {
/// It doubles as the end-to-end exit probe: if every attempt fails while the
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
/// tunnel generation) rather than let it stall the wallet for minutes.
/// tunnel generation) rather than let it stall the wallet for minutes. This is a
/// one-shot per tunnel connection, not a poll loop.
pub fn eager_refresh() {
let vs = match AppConfig::pairing().vs_currency() {
Some(vs) => vs.to_string(),
@@ -154,7 +202,7 @@ pub fn eager_refresh() {
.build()
.unwrap();
rt.block_on(async {
let generation = nym::tunnel_generation();
let generation = tor::tunnel_generation();
let mut ok = false;
for attempt in 1..=PROBE_ATTEMPTS {
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
@@ -171,27 +219,32 @@ pub fn eager_refresh() {
}
}
}
if ok {
FAILED.write().remove(&vs);
} else {
FAILED.write().insert(vs.clone());
}
// Every attempt failed AND the tunnel still claims ready on the SAME
// generation we probed: the exit is up but blackholing our HTTP. Condemn
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
// probed generation so a reselect that already happened is never hit.
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
nym::condemn_exit(generation);
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
tor::condemn_exit(generation);
}
});
FETCHING.write().remove(&vs);
}
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
/// Fetch the GRIN/`vs` rate from CoinGecko over Tor.
async fn fetch_rate(vs: &str) -> Option<f64> {
let url = format!(
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}",
vs
);
// CoinGecko rejects requests without a User-Agent (403). A static,
// non-identifying UA is fine over the mixnet.
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
let body = nym::http_request("GET", url, None, headers).await?;
// CoinGecko rejects requests without a User-Agent (403); the Tor client sets a
// browser-like default UA on every request, so we pass no extra headers here
// (passing one again would send the header twice).
let body = tor::http_request("GET", url, None, vec![]).await?;
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
@@ -203,3 +256,59 @@ async fn fetch_rate(vs: &str) -> Option<f64> {
}
parsed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn freshness_window_is_a_few_minutes() {
// Guards the ruling: a short window in minutes, not the old 48h cache.
assert!(
(120..=300).contains(&FRESH_SECS),
"FRESH_SECS = {FRESH_SECS}"
);
}
#[test]
fn is_fresh_at_boundary() {
let now = 1_000_000;
assert!(is_fresh(now, now)); // just fetched
assert!(is_fresh(now - FRESH_SECS, now)); // exactly on the edge is still fresh
assert!(!is_fresh(now - FRESH_SECS - 1, now)); // one second past → stale
}
#[test]
fn classify_fresh_rate_is_painted() {
let now = 1_000_000;
let cached = Some((1.23, now - 10));
// Even mid-fetch, a fresh rate wins.
assert_eq!(classify(cached, now, true, false), RateState::Fresh(1.23));
}
#[test]
fn classify_stale_rate_never_painted_shows_loading_while_fetching() {
let now = 1_000_000;
let cached = Some((1.23, now - FRESH_SECS - 60));
assert_eq!(classify(cached, now, true, false), RateState::Loading);
}
#[test]
fn classify_stale_rate_after_failure_is_unavailable() {
let now = 1_000_000;
let cached = Some((1.23, now - FRESH_SECS - 60));
// Not fetching, last attempt failed → honest "unavailable", not the old value.
assert_eq!(classify(cached, now, false, true), RateState::Unavailable);
}
#[test]
fn classify_missing_rate_loads_then_reports_failure() {
let now = 1_000_000;
// First view: nothing cached, fetch just kicked.
assert_eq!(classify(None, now, true, false), RateState::Loading);
// Fetch finished and failed, nothing fresh → unavailable.
assert_eq!(classify(None, now, false, true), RateState::Unavailable);
// Freshly triggered, not yet marked fetching or failed → still loading.
assert_eq!(classify(None, now, false, false), RateState::Loading);
}
}
+131 -8
View File
@@ -38,8 +38,14 @@ mod http;
pub mod logger;
mod node;
pub mod nostr;
/// The old Nym-mixnet transport, DORMANT since the Tor swap. Retained on disk but
/// only compiled with `--features nym` (its nym-sdk deps link a different
/// libsqlite3-sys than arti and cannot coexist with Tor in one binary). Deletion
/// is a later phase.
#[cfg(feature = "nym")]
pub mod nym;
mod settings;
pub mod tor;
mod wallet;
/// Upstream GRIM version the fork is based on (third-party credit).
@@ -117,20 +123,25 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
// would panic on the first TLS handshake. nym uses its own explicit provider,
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
let _ = rustls::crypto::ring::default_provider().install_default();
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
// traffic egresses through it; nothing clearnet.
nym::warm_up();
// Seed the price cache from disk so the amount preview can paint an instant
// (stale-marked) fiat value while the first live fetch is still in flight.
crate::http::price::seed_from_disk();
// Pre-warm the embedded Tor client FIRST, before i18n/node setup, so the Tor
// bootstrap (the long pole on cold start) overlaps everything else and
// price/NIP-05/nostr are ready at first use. All of Goblin's relay + HTTP
// traffic egresses through Tor; the Grin node stays on the clear internet
// exactly as before (its lazy warm-on-activity polling is untouched).
tor::warm_up();
// Setup translations.
setup_i18n();
// Start integrated node if needed.
if AppConfig::autostart_node() {
Node::start();
}
// macOS delivers `goblin:` link clicks as a kAEGetURL Apple Event, not on argv
// and not through any path winit/eframe surface. Install a handler for it here,
// BEFORE the event loop starts, so both a cold launch (event queued at start-up)
// and a warm click (app already running) route the URL into the same argv entry
// (`on_data`). No-op / not compiled on every other platform.
#[cfg(target_os = "macos")]
mac_deeplink::install();
// Launch graphical interface.
eframe::run_native("Goblin", options, app_creator)
}
@@ -382,6 +393,99 @@ pub fn on_data(data: String) {
*w_data = Some(data);
}
/// macOS-only bridge that turns a `goblin:` URL click into an [`on_data`] call.
///
/// On Linux/Windows the OS hands the URL to the app on argv (cold) or through the
/// single-instance socket (warm); on Android it arrives as an Intent. macOS uses
/// neither: it dispatches scheme clicks as a Carbon/Apple Event (`kAEGetURL`) that
/// winit + eframe never surface, so the click would otherwise vanish. We install a
/// handler for that event straight on the shared `NSAppleEventManager`, extract the
/// URL string, and feed it to [`on_data`] — the exact same entry point argv uses,
/// so the Goblin surface's per-frame router lands the pay URI on the prefilled
/// review screen just as a scanned QR or a Linux argv link does.
///
/// This talks to the Objective-C runtime through the classic `objc` crate, which is
/// already in the macOS build graph (nokhwa/cocoa/metal/wgpu all pull it), so it
/// adds no new dependency and only a few KB of handler code. The Apple Event route
/// deliberately avoids the `NSApplicationDelegate` that winit owns — registering our
/// own `kAEGetURL` handler neither subclasses nor swizzles winit's delegate.
#[cfg(target_os = "macos")]
mod mac_deeplink {
use objc::declare::ClassDecl;
use objc::runtime::{Object, Sel};
use objc::{class, msg_send, sel, sel_impl};
use std::ffi::CStr;
use std::os::raw::c_char;
use std::sync::Once;
// Four-char codes (OSType) for the URL-open Apple Event.
const K_INTERNET_EVENT_CLASS: u32 = 0x4755_524c; // 'GURL'
const K_AE_GET_URL: u32 = 0x4755_524c; // 'GURL'
const KEY_DIRECT_OBJECT: u32 = 0x2d2d_2d2d; // '----'
/// `- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
/// withReplyEvent:(NSAppleEventDescriptor *)reply`.
extern "C" fn handle_get_url(
_this: &Object,
_cmd: Sel,
event: *mut Object,
_reply: *mut Object,
) {
if event.is_null() {
return;
}
unsafe {
// [[event paramDescriptorForKeyword:keyDirectObject] stringValue] -> NSString*.
let desc: *mut Object = msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT];
if desc.is_null() {
return;
}
let url: *mut Object = msg_send![desc, stringValue];
if url.is_null() {
return;
}
let utf8: *const c_char = msg_send![url, UTF8String];
if utf8.is_null() {
return;
}
let s = CStr::from_ptr(utf8).to_string_lossy().into_owned();
if !s.is_empty() {
crate::on_data(s);
}
}
}
/// Register the `kAEGetURL` handler on the shared `NSAppleEventManager`. Idempotent
/// (the handler class is built once); call before the event loop starts.
pub fn install() {
static ONCE: Once = Once::new();
ONCE.call_once(|| unsafe {
// Build a tiny NSObject subclass carrying the handler method.
let superclass = class!(NSObject);
let mut decl = match ClassDecl::new("GoblinAppleEventHandler", superclass) {
Some(d) => d,
None => return,
};
decl.add_method(
sel!(handleGetURLEvent:withReplyEvent:),
handle_get_url as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
);
let cls = decl.register();
// One instance, intentionally leaked: it must outlive every event for the
// whole process lifetime, and the app never unregisters.
let handler: *mut Object = msg_send![cls, new];
let manager: *mut Object =
msg_send![class!(NSAppleEventManager), sharedAppleEventManager];
let _: () = msg_send![manager,
setEventHandler: handler
andSelector: sel!(handleGetURLEvent:withReplyEvent:)
forEventClass: K_INTERNET_EVENT_CLASS
andEventID: K_AE_GET_URL];
});
}
}
/// Unix-seconds timestamp of the most recent GUI frame. Background workers read
/// it to tell whether the app is actually on-screen: while the app is
/// backgrounded, eframe stops calling the per-frame draw and this stops
@@ -448,6 +552,25 @@ pub fn notify_payment_requested(name: &str, amount: &str) {
lazy_static! {
/// Data provided from deeplink or opened file.
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// A pending `goblin:` / `nostr:` payment deep link, waiting for the Goblin
/// wallet surface to open its send-review flow. Separate from
/// [`INCOMING_DATA`] (slatepack messages / opened files): a payment link is
/// routed here so it lands on the prefilled review screen rather than the
/// slatepack message handler. Consumed by the Goblin view once a wallet is
/// open and showing.
static ref PENDING_PAY_URI: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
}
/// Stash a payment deep link for the Goblin surface to open (see
/// [`take_pending_pay_uri`]). The most recent link wins.
pub fn set_pending_pay_uri(uri: String) {
*PENDING_PAY_URI.write() = Some(uri);
}
/// Take (and clear) a pending payment deep link, if any. The Goblin wallet view
/// polls this each frame and opens a prefilled send-review flow for it.
pub fn take_pending_pay_uri() -> Option<String> {
PENDING_PAY_URI.write().take()
}
/// Callback from Java code with passed data.
+1 -1
View File
@@ -114,7 +114,7 @@ pub fn init_logger() {
/// Get information about application build.
fn build_info() -> String {
format!(
"This is Grim version {}, built for {} by {}.",
"This is Goblin version {}, built for {} by {}.",
built_info::PKG_VERSION,
built_info::TARGET,
built_info::RUSTC_VERSION,
+846 -176
View File
File diff suppressed because it is too large Load Diff
+574
View File
@@ -0,0 +1,574 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! One wallet, one grin seed / one balance, but MANY nostr identities (nsecs),
//! exactly one of which is ACTIVE at a time. This module is the wallet-level
//! held-identity INDEX: it owns which identities the wallet holds, their display
//! order, and which is active. It stores NO secrets. Each held identity is a
//! full [`NostrIdentity`] on disk (its own NIP-49 ncryptsec, see
//! [`crate::nostr::identity`]); this index only points at those files.
//!
//! On-disk layout under `<base_data>/nostr/`:
//! ```text
//! nostr/
//! identities.json # this index (0600, no secrets): active + order + entries
//! identity.json # identity #1 (the legacy file, NEVER overwritten by a switch)
//! identities/<hex>/identity.json # each additional held identity
//! db/ # shared rkv store (dedup, contacts, meta) — one for all identities
//! ```
//!
//! Migration is trivial and fund-safe: a pre-feature wallet has only a bare
//! `identity.json`. On first load the index adopts it as the single, active
//! identity #1 — no key regeneration, no rewrite of the legacy file, and the
//! grin seed/balance are never touched (this module cannot reach them).
//!
//! A switch only moves the `active` pointer here and rebinds the running service
//! to the target's key (the wallet does the teardown + bring-up + catch-up). The
//! legacy `identity.json` is deliberately never overwritten, so an older build
//! that ignores this index still opens the wallet on identity #1 (clean rollback).
use crate::nostr::identity::NostrIdentity;
use serde_derive::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Index file name inside the nostr directory.
const INDEX_FILE: &str = "identities.json";
/// The legacy single-identity file, which becomes identity #1.
const LEGACY_FILE: &str = "identity.json";
/// Sub-directory holding each additional (non-legacy) identity.
const SUBDIR: &str = "identities";
/// Cap on how many identities one wallet may hold. Bounds the on-disk key files
/// and the switcher list, and stops a hostile import loop from ballooning either.
pub const MAX_IDENTITIES: usize = 8;
/// One held identity, referenced by the index. No secret material.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct HeldEntry {
/// Public key, lowercase hex — the stable id of this identity.
pub pubkey: String,
/// Path to the identity's `identity.json`, RELATIVE to the nostr dir:
/// `"identity.json"` for the legacy identity #1, else
/// `"identities/<hex>/identity.json"`.
pub path: String,
/// A short human label: the identity's claimed name (local part of its NIP-05)
/// when it has one, else empty. NOT rendered — the UI derives its display from
/// the name or a truncated npub — kept only as a convenience field in the
/// index. Plaintext by design (this index carries no secret). Never a
/// placeholder word.
pub label: String,
}
impl HeldEntry {
/// Absolute path to this entry's identity file under `nostr_dir`.
pub fn abs_path(&self, nostr_dir: &PathBuf) -> PathBuf {
let mut p = nostr_dir.clone();
for seg in self.path.split('/') {
p.push(seg);
}
p
}
/// Load the full [`NostrIdentity`] this entry points at.
pub fn load(&self, nostr_dir: &PathBuf) -> Option<NostrIdentity> {
NostrIdentity::load_at(&self.abs_path(nostr_dir))
}
}
/// The held-identity index: which identities the wallet holds and which is
/// active. Persisted as `identities.json`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HeldIdentities {
pub ver: u8,
/// Active identity, lowercase hex. Drives the single live subscription and
/// all display; the only pointer a switch moves.
pub active: String,
/// Display order, lowercase hex.
pub order: Vec<String>,
/// Entry metadata (no secrets).
pub identities: Vec<HeldEntry>,
}
impl HeldIdentities {
/// Index file path inside the nostr dir.
pub fn index_path(nostr_dir: &PathBuf) -> PathBuf {
let mut p = nostr_dir.clone();
p.push(INDEX_FILE);
p
}
/// Relative path an additional identity's file lives at.
fn rel_path_for(hex: &str) -> String {
format!("{SUBDIR}/{hex}/{LEGACY_FILE}")
}
/// Load the index if present and parseable.
pub fn load(nostr_dir: &PathBuf) -> Option<HeldIdentities> {
let raw = fs::read_to_string(Self::index_path(nostr_dir)).ok()?;
serde_json::from_str(&raw).ok()
}
/// Persist the index with owner-only (0600) permissions. It carries no
/// secret, but a consistent 0700/0600 posture across the nostr dir is
/// simplest to reason about.
pub fn save(&self, nostr_dir: &PathBuf) -> std::io::Result<()> {
fs::create_dir_all(nostr_dir)?;
let raw = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
write_private_0600(&Self::index_path(nostr_dir), raw.as_bytes())
}
/// The active entry, if the pointer resolves to a held identity.
pub fn active_entry(&self) -> Option<&HeldEntry> {
self.identities.iter().find(|e| e.pubkey == self.active)
}
/// Look up an entry by hex.
pub fn entry(&self, hex: &str) -> Option<&HeldEntry> {
self.identities.iter().find(|e| e.pubkey == hex)
}
/// Whether the wallet already holds this pubkey (dedupe guard on add/import).
pub fn contains(&self, hex: &str) -> bool {
self.identities.iter().any(|e| e.pubkey == hex)
}
/// Held-identity count.
pub fn len(&self) -> usize {
self.identities.len()
}
pub fn is_empty(&self) -> bool {
self.identities.is_empty()
}
/// True if another identity may still be added under the cap.
pub fn has_room(&self) -> bool {
self.identities.len() < MAX_IDENTITIES
}
/// Build a fresh single-identity index from the legacy identity #1. This is
/// the migration shape: exactly one held identity, active, referencing the
/// legacy `identity.json` in place (never rewritten).
pub fn from_legacy(legacy: &NostrIdentity) -> Option<HeldIdentities> {
let hex = legacy.pubkey_hex()?;
Some(HeldIdentities {
ver: 1,
active: hex.clone(),
order: vec![hex.clone()],
identities: vec![HeldEntry {
pubkey: hex,
path: LEGACY_FILE.to_string(),
label: label_for(legacy),
}],
})
}
/// Load the index, migrating a legacy single-identity wallet in place, and
/// self-healing an index whose `active` pointer no longer resolves. Returns
/// the index plus the ACTIVE identity to run. `legacy` is the identity loaded
/// from the bare `identity.json` (identity #1), used for migration/repair.
///
/// Never touches funds and never regenerates a key. Writes only the index
/// (`identities.json`) — the identity files themselves are left as they are.
pub fn load_or_migrate(
nostr_dir: &PathBuf,
legacy: &NostrIdentity,
) -> Option<(HeldIdentities, NostrIdentity)> {
let legacy_hex = legacy.pubkey_hex()?;
match Self::load(nostr_dir) {
Some(mut idx) => {
// Repair: ensure identity #1 is always represented (it is the
// rollback anchor), without disturbing the active pointer.
if !idx.contains(&legacy_hex) {
idx.identities.push(HeldEntry {
pubkey: legacy_hex.clone(),
path: LEGACY_FILE.to_string(),
label: label_for(legacy),
});
if !idx.order.contains(&legacy_hex) {
idx.order.push(legacy_hex.clone());
}
let _ = idx.save(nostr_dir);
}
// Resolve the active identity; if its file is missing/corrupt,
// fall back to identity #1 so the wallet always has a running
// identity rather than none.
let active = idx
.active_entry()
.and_then(|e| e.load(nostr_dir))
.or_else(|| {
idx.active = legacy_hex.clone();
let _ = idx.save(nostr_dir);
Some(legacy.clone())
})?;
Some((idx, active))
}
None => {
// Legacy layout: adopt identity.json as the sole, active identity.
let idx = Self::from_legacy(legacy)?;
let _ = idx.save(nostr_dir);
Some((idx, legacy.clone()))
}
}
}
/// Add an already-built identity to the set (does NOT change the active
/// pointer). Writes the identity's file under `identities/<hex>/` and records
/// the entry. Enforces the cap and dedupe. Returns the new entry's hex.
pub fn add(
&mut self,
nostr_dir: &PathBuf,
identity: &NostrIdentity,
) -> Result<String, HeldError> {
let hex = identity.pubkey_hex().ok_or(HeldError::BadPubkey)?;
if self.contains(&hex) {
return Err(HeldError::AlreadyHeld);
}
if !self.has_room() {
return Err(HeldError::AtCapacity);
}
let rel = Self::rel_path_for(&hex);
let mut abs = nostr_dir.clone();
for seg in rel.split('/') {
abs.push(seg);
}
identity
.save_at(&abs)
.map_err(|e| HeldError::Io(e.to_string()))?;
self.identities.push(HeldEntry {
pubkey: hex.clone(),
path: rel,
label: label_for(identity),
});
self.order.push(hex.clone());
self.save(nostr_dir)
.map_err(|e| HeldError::Io(e.to_string()))?;
Ok(hex)
}
/// Move the active pointer to a held identity. The caller is responsible for
/// tearing down and re-standing the service on the new key; this only records
/// the choice so the next open lands on it too.
pub fn set_active(&mut self, nostr_dir: &PathBuf, hex: &str) -> Result<(), HeldError> {
if !self.contains(hex) {
return Err(HeldError::NotHeld);
}
self.active = hex.to_string();
self.save(nostr_dir)
.map_err(|e| HeldError::Io(e.to_string()))
}
/// Re-encrypt every held identity's ncryptsec from `old` to `new`, in place
/// on disk. Used by the wallet-password change so all front doors follow the
/// one password. Best-effort per file; returns the first error encountered
/// after attempting the rest.
pub fn reencrypt_all(
&self,
nostr_dir: &PathBuf,
old: &str,
new: &str,
) -> Result<(), HeldError> {
let mut first_err = None;
for entry in &self.identities {
let abs = entry.abs_path(nostr_dir);
match NostrIdentity::load_at(&abs) {
Some(mut id) => {
if let Err(e) = id.reencrypt(old, new) {
first_err.get_or_insert(HeldError::Io(e.to_string()));
continue;
}
if let Err(e) = id.save_at(&abs) {
first_err.get_or_insert(HeldError::Io(e.to_string()));
}
}
None => {
first_err.get_or_insert(HeldError::Io(format!(
"identity file unreadable: {}",
entry.path
)));
}
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
}
/// Errors from held-identity index operations. Carries no secret material.
#[derive(Debug, thiserror::Error)]
pub enum HeldError {
#[error("that identity is already in this wallet")]
AlreadyHeld,
#[error("this wallet already holds the maximum number of identities")]
AtCapacity,
#[error("identity not held by this wallet")]
NotHeld,
#[error("identity has a malformed public key")]
BadPubkey,
#[error("identity store error: {0}")]
Io(String),
}
/// A convenience label for an identity: the local part of its claimed NIP-05
/// name when it has one, else EMPTY (never a placeholder word — an unnamed
/// identity is shown by its truncated npub in the UI, not by a label). Never
/// includes secret material.
fn label_for(id: &NostrIdentity) -> String {
id.nip05
.as_deref()
.and_then(|n| n.split('@').next())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_default()
}
/// The catch-up `since` (unix seconds) for the identity we are bringing up. We
/// want to cover "since THIS identity last listened", not "since the wallet last
/// connected on any identity", so a payment that arrived while this identity was
/// dormant is fetched and redeemed on switch. Falls back to the wallet-wide last
/// connection, then to now, and always subtracts the same generous lookback so a
/// boundary payment is never missed. Pure — unit tested.
pub fn catchup_since(
identity_last_active: Option<i64>,
wallet_last_connected: Option<i64>,
now: i64,
lookback: i64,
) -> i64 {
let base = identity_last_active
.or(wallet_last_connected)
.unwrap_or(now);
(base - lookback).max(0)
}
/// Write a file with owner-only (0600) permissions on Unix.
fn write_private_0600(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(data)?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, data)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"goblin-held-test-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::create_dir_all(&d);
d
}
#[test]
fn migration_adopts_legacy_as_single_active_identity() {
let dir = tmpdir("migrate");
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
legacy.save(&dir).unwrap(); // writes identity.json
let legacy_hex = legacy.pubkey_hex().unwrap();
// No index yet -> migrate.
assert!(HeldIdentities::load(&dir).is_none());
let (idx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
assert_eq!(idx.len(), 1);
assert_eq!(idx.active, legacy_hex);
assert_eq!(active.npub, legacy.npub);
// Legacy entry points at the untouched identity.json.
assert_eq!(idx.active_entry().unwrap().path, "identity.json");
// Index now persisted and reloads identically.
let reloaded = HeldIdentities::load(&dir).unwrap();
assert_eq!(reloaded.active, legacy_hex);
assert_eq!(reloaded.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn add_switch_and_cap() {
let dir = tmpdir("addswitch");
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
legacy.save(&dir).unwrap();
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
let legacy_hex = legacy.pubkey_hex().unwrap();
// Add a second identity; active stays on #1 until we switch.
let (second, _) = NostrIdentity::create_random("pw").unwrap();
let second_hex = idx.add(&dir, &second).unwrap();
assert_eq!(idx.len(), 2);
assert_eq!(idx.active, legacy_hex);
// It has its own file under identities/<hex>/.
assert!(idx.entry(&second_hex).unwrap().load(&dir).is_some());
// Dedupe: adding the same pubkey again is refused.
assert!(matches!(
idx.add(&dir, &second),
Err(HeldError::AlreadyHeld)
));
// Switch active pointer.
idx.set_active(&dir, &second_hex).unwrap();
assert_eq!(idx.active, second_hex);
// Persisted.
assert_eq!(HeldIdentities::load(&dir).unwrap().active, second_hex);
// Switching to an unknown identity is refused.
assert!(matches!(
idx.set_active(&dir, "deadbeef"),
Err(HeldError::NotHeld)
));
// Fill to the cap and assert the (N+1)th is rejected.
while idx.has_room() {
let (extra, _) = NostrIdentity::create_random("pw").unwrap();
idx.add(&dir, &extra).unwrap();
}
assert_eq!(idx.len(), MAX_IDENTITIES);
let (overflow, _) = NostrIdentity::create_random("pw").unwrap();
assert!(matches!(
idx.add(&dir, &overflow),
Err(HeldError::AtCapacity)
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn active_resolves_after_reload_and_survives_switch() {
let dir = tmpdir("resolve");
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
legacy.save(&dir).unwrap();
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
let (second, _) = NostrIdentity::create_random("pw").unwrap();
let second_hex = idx.add(&dir, &second).unwrap();
idx.set_active(&dir, &second_hex).unwrap();
// Reopen: load_or_migrate must return the ACTIVE identity (#2), and the
// legacy identity.json must be UNCHANGED (still identity #1 for rollback).
let (reidx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
assert_eq!(reidx.active, second_hex);
assert_eq!(active.npub, second.npub);
let legacy_on_disk = NostrIdentity::load(&dir).unwrap();
assert_eq!(legacy_on_disk.npub, legacy.npub);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_index_active_falls_back_to_legacy() {
let dir = tmpdir("fallback");
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
legacy.save(&dir).unwrap();
let legacy_hex = legacy.pubkey_hex().unwrap();
// Index points active at an identity whose file does not exist.
let idx = HeldIdentities {
ver: 1,
active: "00ff00ff".repeat(8),
order: vec![legacy_hex.clone()],
identities: vec![HeldEntry {
pubkey: legacy_hex.clone(),
path: "identity.json".to_string(),
label: String::new(),
}],
};
idx.save(&dir).unwrap();
let (repaired, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
// Falls back to identity #1 rather than leaving no running identity.
assert_eq!(active.npub, legacy.npub);
assert_eq!(repaired.active, legacy_hex);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reencrypt_all_moves_every_identity_to_new_password() {
let dir = tmpdir("reencrypt");
let (legacy, _) = NostrIdentity::create_random("old").unwrap();
legacy.save(&dir).unwrap();
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
let (second, _) = NostrIdentity::create_random("old").unwrap();
idx.add(&dir, &second).unwrap();
idx.reencrypt_all(&dir, "old", "new").unwrap();
for entry in &idx.identities {
let id = entry.load(&dir).unwrap();
assert!(id.unlock("new").is_ok(), "must open under the new password");
assert!(id.unlock("old").is_err(), "must not open under the old one");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn imported_nsec_adds_as_held_identity_and_unlocks() {
// The multi-identity import path: a bare nsec becomes a held identity via
// the same NIP-49 encrypted store, keyed by its own pubkey, openable under
// the wallet password. (Regression guard for the add-import flow.)
use nostr_sdk::{Keys, ToBech32};
let dir = tmpdir("import");
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
legacy.save(&dir).unwrap();
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
// A distinct external key, as an nsec string.
let ext = Keys::generate();
let nsec = ext.secret_key().to_bech32().unwrap();
let (imported, _) = NostrIdentity::create_imported(&nsec, "pw").unwrap();
let hex = idx.add(&dir, &imported).unwrap();
// Held, active pointer unchanged (add never switches), file openable.
assert_eq!(idx.len(), 2);
assert_eq!(idx.active, legacy.pubkey_hex().unwrap());
let stored = idx.entry(&hex).unwrap().load(&dir).unwrap();
assert_eq!(stored.source, crate::nostr::IdentitySource::Imported);
assert_eq!(stored.unlock("pw").unwrap().public_key(), ext.public_key());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn catchup_since_prefers_identity_then_wallet_then_now() {
let lookback = 3 * 86_400;
let now = 1_000_000;
// Per-identity value wins: cover from when THIS identity last listened.
assert_eq!(
catchup_since(Some(500_000), Some(900_000), now, lookback),
500_000 - lookback
);
// Falls back to the wallet-wide last connection when the identity has none.
assert_eq!(
catchup_since(None, Some(900_000), now, lookback),
900_000 - lookback
);
// Falls back to now when nothing is known (never worse than a fresh start).
assert_eq!(catchup_since(None, None, now, lookback), now - lookback);
// Never negative.
assert_eq!(catchup_since(Some(10), None, now, lookback), 0);
}
}
+39
View File
@@ -59,6 +59,12 @@ pub struct NostrIdentity {
/// the first service start selects them.
#[serde(default)]
pub dm_relays: Vec<String>,
/// PRIVATE, app-only label the user sets to name this identity for
/// themselves. Stays local: it lives in this 0600 file (and rides inside the
/// NIP-44-sealed .backup envelope, which serializes the whole struct), and is
/// NEVER published — not in kind-0 metadata, not in any event.
#[serde(default)]
pub private_tag: Option<String>,
}
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
@@ -146,6 +152,38 @@ impl NostrIdentity {
let _ = fs::remove_file(Self::path(nostr_dir));
}
/// Load an identity from an explicit file path — a member of the held
/// identity set (see [`crate::nostr::identities`]), which stores each
/// additional identity in its own `identities/<hex>/identity.json`.
pub fn load_at(path: &PathBuf) -> Option<NostrIdentity> {
let raw = fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
/// Persist this identity to an explicit file path with owner-only (0600)
/// permissions, creating (and 0700-restricting) the parent directory. Used
/// by the held identity set for the non-legacy identities; the ncryptsec
/// blob must never be world-readable.
pub fn save_at(&self, path: &PathBuf) -> Result<(), IdentityError> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
restrict_dir(&dir.to_path_buf());
}
let raw = serde_json::to_string_pretty(self)?;
write_private(path, raw.as_bytes())?;
Ok(())
}
/// The identity's public key as lowercase hex — the stable id used to key it
/// in the held-identity index and on disk. `None` if the stored npub is
/// malformed (never expected for an identity we wrote).
pub fn pubkey_hex(&self) -> Option<String> {
use nostr_sdk::PublicKey;
PublicKey::from_bech32(&self.npub)
.ok()
.map(|pk| pk.to_hex())
}
/// Build an identity from already-unlocked keys under a (possibly
/// different) password — used when importing a backup that was exported
/// under another wallet's password.
@@ -204,6 +242,7 @@ impl NostrIdentity {
anonymous: true,
prev_npubs: Vec::new(),
dm_relays: Vec::new(),
private_tag: None,
})
}
+7
View File
@@ -167,6 +167,13 @@ mod tests {
received_rumor_id: None,
created_at: unix_time(),
updated_at: unix_time(),
proof_mode: false,
proof_order: None,
proof_notify: None,
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: String::new(),
}
}
+4 -1
View File
@@ -31,6 +31,9 @@ pub use store::NostrStore;
mod identity;
pub use identity::{IdentitySource, NostrIdentity};
pub mod identities;
pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since};
pub mod protocol;
pub use protocol::*;
@@ -40,7 +43,7 @@ pub mod ingest;
pub use ingest::*;
mod client;
pub use client::{NostrProfile, NostrService, send_phase};
pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase};
pub mod avatar;
pub mod nip05;
+11 -11
View File
@@ -13,8 +13,8 @@
// limitations under the License.
//! NIP-05 username resolution/verification and goblin.st registration,
//! all HTTP routed through the Nym mixnet (the in-process smolmix tunnel). Nothing
//! here touches clearnet.
//! all HTTP routed over Tor (the in-process arti tunnel). Nothing here touches
//! clearnet.
use base64::Engine;
use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind};
@@ -22,7 +22,7 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::nostr::relays::HOME_NIP05_DOMAIN;
use crate::nym;
use crate::tor;
use parking_lot::RwLock;
/// The active name-authority "home" domain, mirrored here from the wallet config
@@ -102,7 +102,7 @@ pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
domain,
urlencode(name)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
let body = tor::http_request("GET", url, None, vec![]).await?;
parse_well_known(&body, name)
}
@@ -120,7 +120,7 @@ pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option<String> {
domain,
urlencode(pubkey_hex)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
let body = tor::http_request("GET", url, None, vec![]).await?;
let doc: Value = serde_json::from_str(&body).ok()?;
doc.get("name")
.and_then(|v| v.as_str())
@@ -159,7 +159,7 @@ pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check {
domain,
urlencode(name)
);
let Some(body) = nym::http_request("GET", url, None, vec![]).await else {
let Some(body) = tor::http_request("GET", url, None, vec![]).await else {
return Nip05Check::Unreachable;
};
check_body(&body, pubkey, name)
@@ -218,7 +218,7 @@ pub async fn check_availability(server: &str, name: &str) -> Availability {
server.trim_end_matches('/'),
urlencode(name)
);
let body = match nym::http_request("GET", url, None, vec![]).await {
let body = match tor::http_request("GET", url, None, vec![]).await {
Some(b) => b,
None => return Availability::Unknown,
};
@@ -284,7 +284,7 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
("Authorization".to_string(), auth),
("Content-Type".to_string(), "application/json".to_string()),
];
let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else {
let Some(resp) = tor::http_request("POST", url, Some(body), headers).await else {
return RegisterResult::Network;
};
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
@@ -313,7 +313,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
return Err("couldn't sign the request".to_string());
};
let headers = vec![("Authorization".to_string(), auth)];
match nym::http_request("DELETE", url, None, headers).await {
match tor::http_request("DELETE", url, None, headers).await {
Some(resp) if resp.contains("\"released\":true") => Ok(()),
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
.ok()
@@ -328,7 +328,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/profile/{}", server, urlencode(name));
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
if code == 404 {
return Some(None);
}
@@ -347,7 +347,7 @@ pub async fn fetch_avatar(server: &str, hash: &str) -> Option<Vec<u8>> {
}
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/avatar/{}.png", server, hash);
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) {
return None;
}
+375 -13
View File
@@ -12,12 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Pay-URI parser for scanned payment QRs.
//! Pay-URI parser for scanned payment QRs and payment deep links.
//!
//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional
//! amount (and memo):
//! A GoblinPay checkout QR (or an "Open in Goblin" web button) carries an
//! optional amount (and memo) on the recipient, under either the `goblin:`
//! scheme (Goblin's own registered deep-link scheme, so the OS routes it to the
//! wallet) or the plain `nostr:` scheme (what a QR/social payload spells). Both
//! are the SAME payload — only the scheme differs:
//!
//! ```text
//! goblin:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
//! nostr:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
//! ```
//!
@@ -38,6 +42,21 @@ use grin_core::core::amount_from_hr_string;
const MAX_URI_LEN: usize = 4096;
/// Memo byte cap (post control-strip), display / tx-message only.
const MAX_MEMO_BYTES: usize = 256;
/// Order-handle byte cap (post percent-decode + control-strip). The order
/// param is an opaque routing key (magick's `MM-<hex>` invoice number), never
/// free text, so it is capped tight per the frozen contract (section 4.1).
const MAX_ORDER_BYTES: usize = 64;
/// Upper bound on the whole-GRIN part of an accepted amount. It sits far below
/// the point where `amount_from_hr_string`'s `grins * GRIN_BASE` would overflow
/// u64 (which in release wraps to a small atomic value while the review screen
/// still shows the giant figure), yet comfortably above Grin's real circulating
/// supply (~10^8 GRIN). A scanned amount above this is abuse, not a payment.
const MAX_WHOLE_GRIN: u64 = 1_000_000_000;
/// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered
/// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet);
/// `nostr:` is the equivalent QR/social payload. Both spell the same payment.
const PAY_SCHEMES: [&str; 2] = ["goblin:", "nostr:"];
/// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the
/// bech32/name that used to go straight into the search box). `amount` is the
@@ -49,6 +68,21 @@ pub struct PayUri {
pub recipient: String,
pub amount: Option<String>,
pub memo: Option<String>,
/// Proof-on-request: the merchant's Grin slatepack (`grin1`/`tgrin1`) proof
/// address. Presence turns proof mode ON for this one transaction; the value
/// is threaded verbatim as `payment_proof_recipient_address` on the send.
/// Fail-closed: an unparseable value is dropped to `None` (a proof-less
/// send), never blocking the payment. Frozen contract section 4.1.
pub proof: Option<String>,
/// The opaque order handle (magick's `MM-<hex>` invoice number) the wallet
/// echoes verbatim into the delivery events' `payment-request` tag. Distinct
/// from `memo`: `order` is a non-editable routing key, `memo` is editable
/// display text. Dropped if empty after sanitization.
pub order: Option<String>,
/// The watcher's Nostr pubkey (`npub…`) the wallet gift-wraps the full proof
/// delivery to. Dropped if it is not a valid npub; absence simply means no
/// encrypted delivery target (the plain receipt still publishes).
pub notify: Option<String>,
}
impl PayUri {
@@ -58,6 +92,9 @@ impl PayUri {
recipient,
amount: None,
memo: None,
proof: None,
order: None,
notify: None,
}
}
}
@@ -74,11 +111,11 @@ pub fn parse(scanned: &str) -> PayUri {
return PayUri::bare(String::new());
}
// Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks
// Strict scheme: only a `goblin:`/`nostr:` prefix (case-insensitive) unlocks
// amount/memo parsing, matching the scanner's existing strip logic. Any
// other payload (a bare npub, or some other scheme) is returned verbatim,
// exactly as the scanner treated it before pay-URIs existed.
let rest = match strip_nostr_prefix(text) {
let rest = match strip_pay_scheme(text) {
Some(rest) => rest,
None => return PayUri::bare(text.to_string()),
};
@@ -92,6 +129,9 @@ pub fn parse(scanned: &str) -> PayUri {
let mut amount = None;
let mut memo = None;
let mut proof = None;
let mut order = None;
let mut notify = None;
if let Some(query) = query {
for pair in query.split('&') {
let Some((key, val)) = pair.split_once('=') else {
@@ -102,6 +142,12 @@ pub fn parse(scanned: &str) -> PayUri {
// second `amount=` can't override a validated one.
"amount" if amount.is_none() => amount = validate_amount(val),
"memo" if memo.is_none() => memo = validate_memo(val),
// Proof-on-request params (frozen contract section 4.1). Each is
// fail-closed: an invalid value is dropped to `None`, degrading to
// a normal proof-less payment rather than blocking the send.
"proof" if proof.is_none() => proof = validate_proof(val),
"order" if order.is_none() => order = validate_order(val),
"notify" if notify.is_none() => notify = validate_notify(val),
// Unknown params are ignored for forward-compat.
_ => {}
}
@@ -112,18 +158,33 @@ pub fn parse(scanned: &str) -> PayUri {
recipient,
amount,
memo,
proof,
order,
notify,
}
}
/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder.
/// Byte-safe against a leading multibyte char (no `[..6]` slice panic).
fn strip_nostr_prefix(text: &str) -> Option<&str> {
let head = text.get(..6)?;
if head.eq_ignore_ascii_case("nostr:") {
Some(&text[6..])
} else {
None
/// Strip a case-insensitive payment scheme prefix (`goblin:` or `nostr:`),
/// returning the remainder. Byte-safe against a leading multibyte char (the
/// `text.get(..n)` guards against a `[..n]` slice panic).
fn strip_pay_scheme(text: &str) -> Option<&str> {
for scheme in PAY_SCHEMES {
let n = scheme.len();
if let Some(head) = text.get(..n) {
if head.eq_ignore_ascii_case(scheme) {
return Some(&text[n..]);
}
}
}
None
}
/// True when `scanned` (once trimmed) carries a Goblin payment scheme
/// (`goblin:` or `nostr:`) — i.e. a payment deep link rather than a slatepack
/// message or opened file. Used to route an incoming argv/intent payload to the
/// send-review flow instead of the slatepack message handler.
pub fn is_pay_uri(scanned: &str) -> bool {
strip_pay_scheme(scanned.trim()).is_some()
}
/// Validate an `amount` value: percent-decode, then accept it ONLY if the
@@ -132,6 +193,24 @@ fn strip_nostr_prefix(text: &str) -> Option<&str> {
/// manual entry). Returns the clean decoded decimal string on success.
fn validate_amount(raw: &str) -> Option<String> {
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
// A Grin amount is only ASCII `[0-9.]`. Reject any non-ASCII up front:
// `amount_from_hr_string` slices the fractional tail at a fixed byte index,
// which panics if that index lands inside a multibyte UTF-8 char. The scan /
// deep-link thread has no catch_unwind, so a crafted amount like `0.€€€€`
// would crash the wallet. Fail closed to manual entry instead.
if !decoded.is_ascii() {
return None;
}
// Cap the whole-GRIN part below the u64 overflow point. Without this a giant
// amount wraps (in release) to a small atomic value that is what actually
// gets dispatched, while the review screen shows the giant figure. A whole
// part that does not even fit u64 is left for `amount_from_hr_string` to
// reject (its own `parse::<u64>` errors, so no wrap can occur).
if let Ok(whole) = decoded.split('.').next().unwrap_or("").parse::<u64>() {
if whole > MAX_WHOLE_GRIN {
return None;
}
}
match amount_from_hr_string(&decoded) {
Ok(atomic) if atomic > 0 => Some(decoded),
_ => None,
@@ -155,6 +234,81 @@ fn validate_memo(raw: &str) -> Option<String> {
if text.is_empty() { None } else { Some(text) }
}
/// Validate a `proof` value: percent-decode, then accept it ONLY if it has the
/// shape of a Grin slatepack address (`grin1…`/`tgrin1…`, bech32 charset, long
/// enough to be a real key). This is a fail-closed SHAPE check; the send path
/// re-parses it authoritatively via `SlatepackAddress::try_from` and drops it
/// again if that fails, so an almost-valid string can never turn into a bad
/// proof recipient. Returns the clean decoded address on success.
fn validate_proof(raw: &str) -> Option<String> {
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
let decoded = decoded.trim();
if looks_like_slatepack_address(decoded) {
Some(decoded.to_string())
} else {
None
}
}
/// Loose shape test for a Grin slatepack address: a `grin1`/`tgrin1` bech32
/// human-readable prefix followed by a bech32-charset data part of plausible
/// length. Mirrors magick's `isValidGoblinPayAddress` grin1 arm; authoritative
/// decode happens at send time.
fn looks_like_slatepack_address(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
let data = if let Some(d) = lower.strip_prefix("tgrin1") {
d
} else if let Some(d) = lower.strip_prefix("grin1") {
d
} else {
return false;
};
// bech32 data charset excludes `1`, `b`, `i`, `o`; a real key is well over
// 20 data chars. We only guard obvious garbage here.
data.len() >= 20
&& data
.chars()
.all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o'))
}
/// Validate an `order` value: percent-decode, strip ASCII control chars (it is a
/// routing key, never display text or a path), hard-cap at [`MAX_ORDER_BYTES`]
/// on a UTF-8 boundary, trim. Empty → `None`. The wallet echoes this verbatim
/// into the `payment-request` tag of every delivery event, so it must survive
/// the round trip unchanged.
fn validate_order(raw: &str) -> Option<String> {
let decoded = percent_decode(raw);
let cleaned: Vec<u8> = decoded
.into_iter()
.filter(|&b| b >= 0x20 && b != 0x7f)
.collect();
let text = String::from_utf8_lossy(&cleaned).into_owned();
let text = truncate_on_char_boundary(text, MAX_ORDER_BYTES);
let text = text.trim().to_string();
if text.is_empty() { None } else { Some(text) }
}
/// Validate a `notify` value: percent-decode, then accept it ONLY if it has the
/// shape of an `npub` (bech32 `npub1…`). Fail-closed SHAPE check; the delivery
/// path re-decodes it authoritatively via `PublicKey::from_bech32` and drops it
/// again on failure. Returns the clean decoded npub on success.
fn validate_notify(raw: &str) -> Option<String> {
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
let decoded = decoded.trim();
let lower = decoded.to_ascii_lowercase();
if let Some(data) = lower.strip_prefix("npub1") {
// A bech32-encoded 32-byte key is ~59 data chars; guard obvious garbage.
if data.len() >= 50
&& data
.chars()
.all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o'))
{
return Some(decoded.to_string());
}
}
None
}
/// Truncate a string to at most `max` bytes without splitting a UTF-8 char.
fn truncate_on_char_boundary(s: String, max: usize) -> String {
if s.len() <= max {
@@ -231,6 +385,46 @@ mod tests {
assert_eq!(out.amount.as_deref(), Some("2"));
}
#[test]
fn goblin_scheme_equivalent_to_nostr() {
// The `goblin:` deep-link scheme is the same payload as `nostr:` — it
// MUST parse to the identical recipient / amount / memo. This is the
// contract behind the web "Open in Goblin" buttons.
let nostr = parse(&format!("nostr:{NPROFILE}?amount=1.5&memo=Coffee"));
let goblin = parse(&format!("goblin:{NPROFILE}?amount=1.5&memo=Coffee"));
assert_eq!(goblin, nostr);
assert_eq!(goblin.recipient, NPROFILE);
assert_eq!(goblin.amount.as_deref(), Some("1.5"));
assert_eq!(goblin.memo.as_deref(), Some("Coffee"));
}
#[test]
fn goblin_scheme_case_insensitive() {
let out = parse(&format!("GOBLIN:{NPROFILE}?amount=2"));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount.as_deref(), Some("2"));
}
#[test]
fn bare_goblin_nprofile_unchanged() {
let out = parse(&format!("goblin:{NPROFILE}"));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount, None);
assert_eq!(out.memo, None);
}
#[test]
fn is_pay_uri_recognizes_both_schemes() {
assert!(is_pay_uri(&format!("goblin:{NPROFILE}?amount=1")));
assert!(is_pay_uri(&format!("nostr:{NPROFILE}")));
assert!(is_pay_uri(&format!(" GOBLIN:{NPROFILE} ")));
// A slatepack message / bare key / other scheme is NOT a pay URI.
assert!(!is_pay_uri("BEGINSLATEPACK. abc DEFG. ENDSLATEPACK."));
assert!(!is_pay_uri("npub1abcdef"));
assert!(!is_pay_uri("bitcoin:bc1qxyz?amount=1"));
assert!(!is_pay_uri(""));
}
#[test]
fn with_amount() {
let out = parse(&format!("nostr:{NPROFILE}?amount=1.5"));
@@ -268,6 +462,54 @@ mod tests {
}
}
#[test]
fn multibyte_amount_rejected_no_panic() {
// A crafted multibyte amount must never reach grin_core's fixed-index
// fractional-tail slice (which panics on a non-char-boundary). It is
// dropped to None and the payment degrades to manual entry; no panic on
// the scan/deep-link thread. The `0.€€€€` case slices mid-char in
// grin_core without the ASCII guard.
for bad in [
"0.\u{20ac}\u{20ac}\u{20ac}\u{20ac}", // 3-byte euro signs
"0.\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}", // 2-byte accents past WIDTH
"\u{20ac}",
"1\u{e9}",
"0.5\u{1f600}", // 4-byte emoji
] {
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
assert_eq!(out.amount, None, "expected {bad:?} to be dropped");
assert_eq!(out.recipient, NPROFILE);
}
}
#[test]
fn oversized_amount_rejected() {
// Above the whole-GRIN ceiling → dropped before the grin parse, so a huge
// display value can't overflow-wrap into a small dispatched atomic amount.
for bad in [
"2000000000", // 2e9 GRIN, over the 1e9 ceiling
"99999999999", // ~1e11 GRIN
"1000000001.5", // just over the ceiling, with a fraction
"18446744073709551615", // u64::MAX whole grins (would wrap grins*BASE)
] {
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
assert_eq!(out.amount, None, "expected {bad:?} to be rejected");
}
// The ceiling itself and just under it still parse.
assert_eq!(
parse(&format!("nostr:{NPROFILE}?amount=1000000000"))
.amount
.as_deref(),
Some("1000000000")
);
assert_eq!(
parse(&format!("nostr:{NPROFILE}?amount=999999999.5"))
.amount
.as_deref(),
Some("999999999.5")
);
}
#[test]
fn overlong_memo_truncated() {
let long = "a".repeat(500);
@@ -382,4 +624,124 @@ mod tests {
assert_eq!(smallest.amount.as_deref(), Some("0.000000001"));
assert_eq!(smallest.memo.as_deref(), Some("MM-ABC123"));
}
// --- proof-on-request params (frozen contract section 4.1) --------------
/// A shape-valid grin1 slatepack address (charset excludes 1/b/i/o).
const GRIN1: &str = "grin1qqvqzqzpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz";
/// A shape-valid npub (the Goblin news key).
const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
#[test]
fn parses_all_three_proof_params() {
let uri = format!(
"nostr:{NPROFILE}?amount=1.5&memo=Coffee&proof={GRIN1}&order=MM-ABC123&notify={NPUB}"
);
let out = parse(&uri);
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount.as_deref(), Some("1.5"));
assert_eq!(out.memo.as_deref(), Some("Coffee"));
assert_eq!(out.proof.as_deref(), Some(GRIN1));
assert_eq!(out.order.as_deref(), Some("MM-ABC123"));
assert_eq!(out.notify.as_deref(), Some(NPUB));
}
#[test]
fn goblin_scheme_carries_proof_params() {
// The clickable `goblin:` deep link must parse identically to `nostr:`.
let query = format!("amount=2&proof={GRIN1}&order=MM-1&notify={NPUB}");
let nostr = parse(&format!("nostr:{NPROFILE}?{query}"));
let goblin = parse(&format!("goblin:{NPROFILE}?{query}"));
assert_eq!(goblin, nostr);
assert_eq!(goblin.proof.as_deref(), Some(GRIN1));
}
#[test]
fn proof_absent_leaves_none() {
// A normal magick / p2p payment carries no proof params: all three None.
let out = parse(&format!("nostr:{NPROFILE}?amount=1&memo=MM-1"));
assert_eq!(out.proof, None);
assert_eq!(out.order, None);
assert_eq!(out.notify, None);
}
#[test]
fn tgrin1_proof_accepted() {
let tgrin1 = format!("t{GRIN1}");
let out = parse(&format!("nostr:{NPROFILE}?proof={tgrin1}"));
assert_eq!(out.proof.as_deref(), Some(tgrin1.as_str()));
}
#[test]
fn bad_proof_dropped_fail_closed() {
// Not a slatepack address (wrong hrp, too short, or plain garbage) → the
// param is dropped and the payment degrades to a normal proof-less send.
for bad in [
"npub1abcdef",
"grin1", // hrp only, no data
"grin1short", // data too short
"bc1qxyz", // wrong network
"notanaddress",
"",
] {
let out = parse(&format!("nostr:{NPROFILE}?amount=1&proof={bad}"));
assert_eq!(out.proof, None, "expected {bad:?} proof to be dropped");
// Dropping proof never blocks the rest of the payment.
assert_eq!(out.amount.as_deref(), Some("1"));
}
}
#[test]
fn order_is_control_stripped_and_capped() {
// Percent-encoded control chars are removed; a routing key survives verbatim.
let out = parse(&format!("nostr:{NPROFILE}?order=MM-A%00B%0AC"));
assert_eq!(out.order.as_deref(), Some("MM-ABC"));
// Over-cap orders truncate at 64 bytes.
let long = "M".repeat(200);
let out = parse(&format!("nostr:{NPROFILE}?order={long}"));
assert_eq!(out.order.as_deref().map(|s| s.len()), Some(64));
}
#[test]
fn empty_order_dropped() {
assert_eq!(parse(&format!("nostr:{NPROFILE}?order=")).order, None);
// Whitespace-only after decode is also empty.
assert_eq!(parse(&format!("nostr:{NPROFILE}?order=%20%20")).order, None);
}
#[test]
fn bad_notify_dropped_fail_closed() {
for bad in [
"nprofile1abc",
"npub1",
"hex0123",
"grin1qqqqqqqqqqqqqqqqqqqqqq",
"",
] {
let out = parse(&format!("nostr:{NPROFILE}?amount=1&notify={bad}"));
assert_eq!(out.notify, None, "expected {bad:?} notify to be dropped");
assert_eq!(out.amount.as_deref(), Some("1"));
}
}
#[test]
fn proof_params_first_occurrence_wins() {
let out = parse(&format!(
"nostr:{NPROFILE}?order=MM-1&order=MM-EVIL&proof={GRIN1}&proof=grin1short"
));
assert_eq!(out.order.as_deref(), Some("MM-1"));
assert_eq!(out.proof.as_deref(), Some(GRIN1));
}
#[test]
fn old_wallet_forward_compat_unaffected() {
// The pre-existing recipient/amount/memo contract is unchanged when the
// new params ride alongside (a shipped old wallet just ignores them).
let out = parse(&format!(
"nostr:{NPROFILE}?amount=1.5&memo=MM-1&proof={GRIN1}&order=MM-1&notify={NPUB}"
));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount.as_deref(), Some("1.5"));
assert_eq!(out.memo.as_deref(), Some("MM-1"));
}
}
+23 -43
View File
@@ -62,22 +62,13 @@ const MIN_BACKDATE_SECS: u64 = 172_800;
/// offline behave exactly like a fresh fetch.
const PINNED_POOL: &str = r#"{
"version": 1,
"updated": "2026-07-02",
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.",
"updated": "2026-07-04",
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. Every relay is reached over a Tor exit to its clearnet host, so the wallet's IP stays hidden behind Tor.",
"min_message_length": 131072,
"relays": [
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe" },
{ "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://relay.0xchat.com", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://relay.snort.social", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://nostr.mom", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://nostr.oxtr.dev", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://relay.nostr.net", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://purplepag.es", "roles": ["discovery"], "vetted": "2026-07-01" },
{ "url": "wss://indexer.coracle.social", "roles": ["discovery"], "vetted": "2026-07-01" }
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
{ "url": "wss://relay.0xchat.com", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-04" }
]
}"#;
@@ -202,12 +193,6 @@ pub fn load() -> RelayPool {
std::fs::read_to_string(cache_path())
.ok()
.and_then(|raw| RelayPool::parse(&raw))
// A cache written by a pre-exit build parses fine but hides the
// scoped-exit money path (and the current primary relay) for up to
// CACHE_MAX_AGE_SECS after an app update — relay connects then ride
// the slow public-IPR path for days. The pinned pool is newer than
// any exit-less file, so prefer it until the next gist refresh.
.filter(RelayPool::has_exit)
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
}
@@ -225,18 +210,11 @@ pub async fn refresh_if_stale() {
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
.unwrap_or(false)
// An exit-less cache predates the current pool shape (see `load`,
// which already ignores it) — replace it now instead of serving the
// pinned fallback for the rest of the file's 7 days.
&& std::fs::read_to_string(&path)
.ok()
.and_then(|raw| RelayPool::parse(&raw))
.is_some_and(|p| p.has_exit());
.unwrap_or(false);
if fresh {
return;
}
let Some(raw) = crate::nym::http_request("GET", POOL_URL.to_string(), None, vec![]).await
let Some(raw) = crate::tor::http_request("GET", POOL_URL.to_string(), None, vec![]).await
else {
warn!("relay pool: refresh fetch failed, keeping current pool");
return;
@@ -305,7 +283,7 @@ pub async fn probe(url: &str) -> bool {
let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())];
let ok = tokio::time::timeout(
PROBE_TIMEOUT,
crate::nym::http_request("GET", http_url, None, headers),
crate::tor::http_request("GET", http_url, None, headers),
)
.await
.ok()
@@ -380,28 +358,30 @@ mod tests {
let pool = RelayPool::parse(PINNED_POOL).expect("pinned pool must parse");
assert_eq!(pool.version, 1);
assert_eq!(pool.min_message_length, MIN_MESSAGE_LENGTH);
assert_eq!(pool.relays.len(), 12);
// Three Tor-friendly relays matching the live gist; no relay pins an onion
// any more (the onion money path was dropped in build134 — every relay is
// reached over a Tor exit to its clearnet host).
assert_eq!(pool.relays.len(), 3);
let dm = pool.dm_relays();
assert_eq!(dm.len(), 10);
assert_eq!(dm.len(), 3);
assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev"));
assert!(dm.iter().all(|r| r.vetted.is_some()));
let disc = pool.discovery_relays();
// relay.floonet.dev carries both roles; the two indexers
// are discovery-only.
assert_eq!(disc.len(), 3);
assert!(disc.contains(&"wss://purplepag.es".to_string()));
assert!(disc.contains(&"wss://indexer.coracle.social".to_string()));
// relay.floonet.dev and relay.0xchat.com carry the discovery role too.
assert_eq!(disc.len(), 2);
assert!(disc.contains(&"wss://relay.floonet.dev".to_string()));
assert!(disc.contains(&"wss://relay.0xchat.com".to_string()));
}
#[test]
fn exit_field_is_optional_and_looked_up_by_url() {
// The pinned pool advertises the money-path relay's co-located scoped
// exit (the .AF floonet-mixexit) so it bootstraps OFFLINE, before any
// network; every other relay is exit-less (reached over the tunnel).
// The pinned pool no longer carries any co-located Nym exit — every relay
// is reached over the Tor exit now — so has_exit() is false for it. The
// exit_for / exit_for_host LOOKUP logic below still works for a pool that
// DOES advertise one, and the (dormant) src/nym transport still reads it.
let pinned = RelayPool::parse(PINNED_POOL).unwrap();
assert!(pinned.has_exit());
assert!(pinned.exit_for("wss://relay.floonet.dev").is_some());
assert!(pinned.exit_for("wss://nos.lol").is_none());
assert!(!pinned.has_exit());
assert!(pinned.exit_for("wss://relay.floonet.dev").is_none());
// A pool that DOES advertise an exit for one relay.
let pool = RelayPool::parse(
+12 -3
View File
@@ -14,11 +14,20 @@
//! Default relay set and relay list helpers.
/// Default DM relays: the Floonet relay plus large public relays for redundancy.
/// Default DM relays: the Floonet relay (the pinned shared floor) plus
/// Tor-reachable public relays for redundancy.
///
/// TRANSPORT CONSTRAINT: Goblin dials every relay over Tor, so the defaults MUST
/// be relays that accept Tor-exit connections. `relay.damus.io` and `nos.lol`
/// throttle/block Tor exits — a wallet left on the raw defaults (e.g. when pool
/// selection hasn't run or found nothing) then had NO working fallback whenever
/// the Floonet onion flapped, so its payments stopped flowing. `relay.0xchat.com`
/// and `offchain.pub` are Tor-friendly (and are also probe-vetted pool `dm`
/// candidates), giving a real fallback that survives an onion drop.
pub const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.floonet.dev",
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.0xchat.com",
"wss://offchain.pub",
];
/// Default NIP-05 identity server.
+110
View File
@@ -29,6 +29,10 @@ use crate::nostr::types::*;
/// Keys are processed-event markers older than this get pruned (30 days).
const PROCESSED_TTL_SECS: i64 = 30 * 86_400;
/// Cap on stored news posts (newest kept, older pruned) — the panel only ever
/// shows the latest, so this is just a small archive bound.
const NEWS_CAP: usize = 8;
/// Nostr metadata archive for a wallet.
pub struct NostrStore {
env: Arc<RwLock<Rkv<SafeModeEnvironment>>>,
@@ -42,6 +46,8 @@ pub struct NostrStore {
processed: SingleStore<SafeModeDatabase>,
/// Service settings (last connected time etc).
settings: SingleStore<SafeModeDatabase>,
/// Cached news posts by `d` tag.
news: SingleStore<SafeModeDatabase>,
}
impl NostrStore {
@@ -75,6 +81,7 @@ impl NostrStore {
let settings = k
.open_single("nostr_settings", StoreOptions::create())
.unwrap();
let news = k.open_single("nostr_news", StoreOptions::create()).unwrap();
Self {
env,
tx_meta,
@@ -82,6 +89,7 @@ impl NostrStore {
requests,
processed,
settings,
news,
}
}
@@ -268,6 +276,30 @@ impl NostrStore {
let _ = writer.commit();
}
/// Unix time this identity (by pubkey hex) was last the ACTIVE, live-listening
/// identity. Held per identity in the one shared settings store so that a
/// switch back to a dormant identity can catch up "since it last listened"
/// rather than "since the wallet last connected". `None` for an identity that
/// has never been active (fresh/imported), which the catch-up handles by
/// falling back to the wallet-wide last connection.
pub fn last_active_at(&self, pubkey_hex: &str) -> Option<i64> {
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
let key = format!("last_active_at:{pubkey_hex}");
if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, &key) {
return Some(v);
}
None
}
pub fn set_last_active_at(&self, pubkey_hex: &str, ts: i64) {
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let key = format!("last_active_at:{pubkey_hex}");
let _ = self.settings.put(&mut writer, &key, &Value::I64(ts));
let _ = writer.commit();
}
/// Unix time of the last contact-name re-verify sweep (persisted across
/// restarts so a fresh launch only re-sweeps if it's been a while).
pub fn last_name_sweep_at(&self) -> Option<i64> {
@@ -288,6 +320,28 @@ impl NostrStore {
let _ = writer.commit();
}
// ── news ────────────────────────────────────────────────────────────────
pub fn all_news(&self) -> Vec<NewsItem> {
self.all_json(&self.news)
}
/// The latest news post overall (newest `created_at`).
pub fn latest_news(&self) -> Option<NewsItem> {
self.all_news().into_iter().max_by_key(|n| n.created_at)
}
/// Store a news post: newest-`created_at`-per-`d` wins, capped to the newest
/// `NEWS_CAP` entries (older pruned). Keyed by `d`, so the store holds one
/// row per addressable post.
pub fn save_news(&self, item: NewsItem) {
let merged = reconcile_news(self.all_news(), item, NEWS_CAP);
self.clear(&self.news);
for n in &merged {
self.put_json(&self.news, &n.d, n);
}
}
// ── archive control (user-facing) ───────────────────────────────────────
/// Export the whole archive as a JSON document.
@@ -309,3 +363,59 @@ impl NostrStore {
self.clear(&self.processed);
}
}
/// Merge an incoming news post into the stored set: newest `created_at` wins
/// per `d`, then keep only the newest `cap` overall. Pure so it's unit-testable
/// without the rkv env.
fn reconcile_news(mut all: Vec<NewsItem>, incoming: NewsItem, cap: usize) -> Vec<NewsItem> {
if let Some(existing) = all.iter_mut().find(|n| n.d == incoming.d) {
if incoming.created_at >= existing.created_at {
*existing = incoming;
}
} else {
all.push(incoming);
}
all.sort_by_key(|n| std::cmp::Reverse(n.created_at));
all.truncate(cap);
all
}
#[cfg(test)]
mod tests {
use super::*;
fn item(d: &str, created_at: i64) -> NewsItem {
NewsItem {
d: d.to_string(),
created_at,
title: format!("t{created_at}"),
summary: String::new(),
lang: None,
published_at: None,
}
}
#[test]
fn newest_per_d_wins_and_cap_prunes() {
// Same d, newer created_at replaces (and carries the newer title).
let start = vec![item("a", 100)];
let merged = reconcile_news(start, item("a", 200), 8);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].created_at, 200);
assert_eq!(merged[0].title, "t200");
// Same d, OLDER created_at is ignored.
let merged = reconcile_news(merged, item("a", 150), 8);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].created_at, 200);
// Distinct d accumulate, newest first, capped to `cap`.
let mut all = vec![];
for i in 0..10 {
all = reconcile_news(all, item(&format!("d{i}"), i as i64), 3);
}
assert_eq!(all.len(), 3);
assert_eq!(all[0].created_at, 9);
assert_eq!(all[2].created_at, 7);
}
}
+82
View File
@@ -81,6 +81,51 @@ pub struct TxNostrMeta {
pub received_rumor_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Proof-on-request (frozen contract section 4.3): a proof address was
/// threaded into this send, so on finalize the wallet must produce and
/// deliver a real Grin payment proof. `false` for every ordinary send, so a
/// person-to-person payment carries and delivers nothing extra.
#[serde(default)]
pub proof_mode: bool,
/// The opaque order handle (the `order=` URI param, magick's `MM-<hex>`
/// invoice number). Echoed verbatim into the delivery events'
/// `payment-request` tag. The wallet never learns magick's internal orderId.
#[serde(default)]
pub proof_order: Option<String>,
/// The watcher's npub (the `notify=` URI param): the gift-wrap target for the
/// full proof delivery. `None` = no encrypted delivery target (plain receipt
/// still publishes).
#[serde(default)]
pub proof_notify: Option<String>,
/// The payment amount in integer nanogrin, persisted so the crash-safe
/// reconcile pass can rebuild the delivery-event `amount` tag without the
/// slate.
#[serde(default)]
pub proof_amount: Option<u64>,
/// Set once the encrypted proof delivery (frozen contract 4.3.2) has been
/// accepted by a relay. The plain receipt is tracked separately by
/// `receipt_sent`, since the two now publish at different lifecycle points
/// (receipt at dispatch, proof at finalize). Until then the reconcile pass
/// retries the proof delivery.
#[serde(default)]
pub proof_delivered: bool,
/// Set once the plain "payment sent" receipt (frozen contract 4.3.1) has been
/// accepted by a relay. Published at S1 DISPATCH, the moment the payment
/// envelope is accepted and the wallet UI flips to "sent", NOT at finalize,
/// so the buyer's order page loses its scannable QR the instant they pay and
/// the double-send window closes. One receipt per tx: this flag is the guard
/// against a duplicate at finalize. `false` for every ordinary (non-order)
/// send, which publishes no receipt at all.
#[serde(default)]
pub receipt_sent: bool,
/// The wallet's OWN nostr identity (pubkey hex) that was ACTIVE when this row
/// was created — the "front door" the payment came in / went out on. One
/// wallet can hold several identities that all redeem into the single shared
/// grin balance; this tags which one so activity can be shown per identity and
/// a later per-identity accounting split has the data. Serde-default empty on
/// pre-feature rows, which are treated as identity #1 (the primary).
#[serde(default)]
pub recipient_pubkey: String,
}
/// A contact: another nostr user we can pay.
@@ -146,6 +191,43 @@ pub struct PaymentRequest {
pub status: RequestStatus,
}
/// A cached news post (kind 30023 long-form) from the Goblin news key, shown
/// in the Home news panel. Only the fields the panel needs are persisted.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct NewsItem {
/// The addressable `d` tag (replaceable-event identifier); dedupe key.
pub d: String,
/// Event `created_at` (seconds); newest per `d` wins, newest overall shows.
pub created_at: i64,
/// The post `title` tag. May carry a trailing `[xx]` language marker, which
/// the Home panel strips for display (see `data::news_display_title`).
pub title: String,
/// Plain-text summary (the `summary` tag, or a stripped content fallback).
pub summary: String,
/// Article language as a lower-case ISO 639-1 code, taken from an event
/// language tag (`l` / `lang`) when present. `None` falls back to the
/// title-suffix marker, then to English. `#[serde(default)]` so posts cached
/// before this field existed still deserialize.
#[serde(default)]
pub lang: Option<String>,
/// Optional NIP-23 `published_at` tag (unix seconds). When present the Home
/// panel dates the article by this rather than `created_at` (which tracks the
/// event's last edit). `#[serde(default)]` so posts cached before this field
/// existed still deserialize.
#[serde(default)]
pub published_at: Option<i64>,
}
/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at
/// S1 dispatch for this send. True only for an order-carrying send in proof
/// mode: a person-to-person send (no `order=` context) publishes no receipt at
/// all, at any lifecycle point. The receipt is the buyer's routing key back to
/// the market (the `payment-request` tag echoes the order handle), so without an
/// order handle there is nothing the market could match and nothing to publish.
pub fn receipt_due_at_dispatch(proof_mode: bool, order: Option<&str>) -> bool {
proof_mode && order.is_some_and(|o| !o.trim().is_empty())
}
/// Current unix time in seconds.
pub fn unix_time() -> i64 {
std::time::SystemTime::now()
+33 -5
View File
@@ -30,6 +30,7 @@ use nostr_sdk::nips::nip59::{self, UnwrappedGift};
use nostr_sdk::{
Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use zeroize::Zeroizing;
/// The capability Goblin advertises in its kind 10050 `encryption` tag,
/// space-separated best-first (NIP-17 backward-compat extension).
@@ -56,12 +57,17 @@ pub fn peer_supports_v3(encryption: Option<&str>) -> bool {
/// Derive the v3 conversation key between our secret key and a peer's
/// public key, bridging nostr-sdk's key types (secp256k1 0.29) to the nip44
/// crate's (0.31) via their byte serializations.
fn conversation_key(secret: &nostr_sdk::SecretKey, public: &PublicKey) -> Result<[u8; 32], String> {
fn conversation_key(
secret: &nostr_sdk::SecretKey,
public: &PublicKey,
) -> Result<Zeroizing<[u8; 32]>, String> {
let sk = secp256k1::SecretKey::from_byte_array(secret.to_secret_bytes())
.map_err(|e| format!("invalid secret key: {e}"))?;
let pk = secp256k1::XOnlyPublicKey::from_byte_array(*public.as_bytes())
.map_err(|e| format!("invalid public key: {e}"))?;
Ok(nip44::get_conversation_key_v3(sk, pk))
// The raw ECDH conversation key is secret material: wrap it so it is scrubbed
// from memory on drop. It derefs to `&[u8; 32]` for the nip44 calls below.
Ok(Zeroizing::new(nip44::get_conversation_key_v3(sk, pk)))
}
/// Build a NIP-17 private-message gift wrap encrypted with NIP-44 v3.
@@ -73,9 +79,31 @@ pub fn wrap(
content: String,
rumor_extra_tags: Vec<Tag>,
) -> Result<Event, String> {
// Rumor: kind 14, receiver p-tag first, then the extra tags — the same
// shape `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59).
let mut rumor: UnsignedEvent = EventBuilder::new(Kind::PrivateDirectMessage, content)
wrap_kind(
sender,
receiver,
Kind::PrivateDirectMessage,
content,
rumor_extra_tags,
)
}
/// Build a NIP-59 gift wrap around a rumor of an ARBITRARY `rumor_kind`,
/// encrypted with NIP-44 v3. `wrap` is the kind-14 private-message case; the
/// proof-on-request delivery (frozen contract 4.3.2) uses this with a kind-17
/// rumor to hand the watcher the full proof JSON privately. The watcher unwraps
/// with the same `unwrap` below (it reuses the wallet's crypto), so v3 here is
/// consistent end to end.
pub fn wrap_kind(
sender: &Keys,
receiver: &PublicKey,
rumor_kind: Kind,
content: String,
rumor_extra_tags: Vec<Tag>,
) -> Result<Event, String> {
// Rumor: receiver p-tag first, then the extra tags, the same shape
// `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59).
let mut rumor: UnsignedEvent = EventBuilder::new(rumor_kind, content)
.tag(Tag::public_key(*receiver))
.tags(rumor_extra_tags)
.build(sender.public_key());
+64 -17
View File
@@ -474,30 +474,77 @@ const PROBE_ADDRS: [SocketAddr; 2] = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
];
/// Per-target connect wait; a mixnet TCP handshake is a few seconds.
/// Per-target connect wait for the PATIENT probe of an ESTABLISHED tunnel
/// (watchdog keepalive + condemnation). A mixnet TCP handshake is a few seconds,
/// and an exit already in service must NEVER be thrown away over a momentary load
/// spike, so this stays deliberately generous at 8s — the pre-existing budget.
/// (The just-built-tunnel GATE uses the tighter [`FRESH_PROBE_TIMEOUT`]; the two
/// budgets are asymmetric on purpose — see [`probe_fresh`].)
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// Probe rounds before a tunnel is declared dead. A single lost mixnet packet
/// mid-handshake should not condemn a whole tunnel, so an all-miss round is
/// retried once (mirrors the DoT/DoH round loop). Only a tunnel that reaches
/// NEITHER stable target across BOTH rounds is DEAD — this is what stops a
/// Probe rounds before an ESTABLISHED tunnel is declared dead. A single lost
/// mixnet packet mid-handshake should not condemn a whole tunnel, so an all-miss
/// round is retried once (mirrors the DoT/DoH round loop). Only a tunnel that
/// reaches NEITHER stable target across BOTH rounds is DEAD — this is what stops a
/// healthy-but-unlucky tunnel from being thrown away and reselected forever.
const PROBE_ROUNDS: usize = 2;
/// End-to-end exit-liveness probe: try to open a TCP connection THROUGH the tunnel
/// to any of a few stable public addresses (raced, retried a round) and drop the
/// winner immediately. Because TCP over the mixnet RETRANSMITS, a single lost
/// datagram does not spuriously fail a healthy exit; racing several targets over
/// two rounds additionally absorbs a momentarily slow single path — together they
/// stop the false-DEAD reselect churn the old single-target probe caused. Proves
/// the full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session
/// from idling out. Used by the fresh-tunnel gate and the watchdog keepalive.
/// Per-target connect wait for the FAST GATE of a FRESH, just-built tunnel (before
/// it is published). Tighter than the established [`PROBE_TIMEOUT`] because a
/// healthy fresh probe connects FAST: across 15 cold-start trials the SUCCESSFUL
/// exit probe completed in 4651197ms (median 774ms), so 5s is >4x the measured
/// worst case — ample headroom to never false-condemn a slow-but-healthy fresh
/// exit (the build130 single-shot regression we must not reintroduce). The point
/// of the asymmetry: a genuinely DEAD fresh exit (accepts the IPR handshake but
/// delivers nothing) is now condemned in ~10s instead of the ~32s the doubled
/// patient probe cost on this path, which dominated the cold-start latency tail.
const FRESH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Probe rounds for the fresh-tunnel gate. SAME 2-round retry as the established
/// path: a single lost mixnet datagram mid-handshake still gets a second chance
/// before the tunnel is condemned — the transient-loss protection the original
/// trigger-happy single-shot probe lacked. Worst-case fresh-gate budget is
/// therefore FRESH_PROBE_ROUNDS × FRESH_PROBE_TIMEOUT = 10s (vs the old ~32s).
const FRESH_PROBE_ROUNDS: usize = 2;
/// PATIENT end-to-end liveness probe of an ESTABLISHED tunnel, on the generous
/// [`PROBE_TIMEOUT`]/[`PROBE_ROUNDS`] budget (worst case ~16s). Used by the
/// watchdog keepalive and the condemnation exit-DNS check — an exit already in
/// service must never be false-condemned over a momentary hiccup. The FRESH,
/// just-built-tunnel gate uses [`probe_fresh`] instead (a tighter budget). See
/// [`probe_with_budget`] for the shared mechanics.
pub async fn probe(tunnel: &Tunnel) -> bool {
for round in 0..PROBE_ROUNDS {
probe_with_budget(tunnel, PROBE_TIMEOUT, PROBE_ROUNDS).await
}
/// FAST end-to-end liveness GATE for a FRESH, just-built tunnel, run BEFORE it is
/// published, on the tighter [`FRESH_PROBE_TIMEOUT`]/[`FRESH_PROBE_ROUNDS`] budget
/// (worst case ~10s vs the ~32s the doubled patient probe cost on this path). A
/// fresh exit that accepts the IPR handshake yet delivers nothing (a DEAD EXIT) is
/// condemned quickly instead of dominating the cold-start tail — WITHOUT
/// reintroducing the false-condemn of a healthy exit (build130): the 5s per-target
/// timeout is >4x the measured worst-case healthy fresh probe (1197ms) and the
/// 2-round retry still absorbs a single lost datagram. See [`probe_with_budget`].
pub async fn probe_fresh(tunnel: &Tunnel) -> bool {
probe_with_budget(tunnel, FRESH_PROBE_TIMEOUT, FRESH_PROBE_ROUNDS).await
}
/// Shared raced-targets liveness probe on an explicit per-target `timeout` /
/// `rounds` budget: try to open a TCP connection THROUGH the tunnel to any of a few
/// stable public addresses (raced, retried a round) and drop the winner
/// immediately. Because TCP over the mixnet RETRANSMITS, a single lost datagram
/// does not spuriously fail a healthy exit; racing several targets over multiple
/// rounds additionally absorbs a momentarily slow single path — together they stop
/// the false-DEAD reselect churn the old single-target probe caused. Proves the
/// full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session from
/// idling out. Callers pick the budget: [`probe`] (patient, established tunnels)
/// vs [`probe_fresh`] (fast, fresh-tunnel gate) — the racing + multi-round
/// structure is identical, only the timeout/rounds differ.
async fn probe_with_budget(tunnel: &Tunnel, timeout: Duration, rounds: usize) -> bool {
for round in 0..rounds {
let mut inflight = FuturesUnordered::new();
for addr in PROBE_ADDRS {
inflight.push(async move {
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tunnel.tcp_connect(addr)).await,
tokio::time::timeout(timeout, tunnel.tcp_connect(addr)).await,
Ok(Ok(_))
)
});
@@ -508,11 +555,11 @@ pub async fn probe(tunnel: &Tunnel) -> bool {
}
}
debug!(
"probe: no stable target reachable through tunnel (round {}/{PROBE_ROUNDS})",
"probe: no stable target reachable through tunnel (round {}/{rounds})",
round + 1
);
}
debug!("probe: tunnel failed liveness — reached no stable target in {PROBE_ROUNDS} rounds");
debug!("probe: tunnel failed liveness — reached no stable target in {rounds} rounds");
false
}
+41 -24
View File
@@ -45,7 +45,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use log::{debug, error, info, warn};
use parking_lot::RwLock;
use smolmix::{Recipient, Tunnel};
@@ -332,11 +332,18 @@ fn run_tunnel() {
// publishing such a tunnel would blackhole every consumer
// until the watchdog caught it minutes later. Re-select
// immediately instead. (This is a CHEAP early signal; relay
// reachability below is the authoritative one.)
if !probe_fresh(&tunnel).await {
// reachability below is the authoritative one.) Uses the FAST
// fresh-gate budget (~10s worst case) — NOT the patient
// established-tunnel probe (~32s doubled here before) — so a
// dead fresh exit no longer dominates the cold-start tail; see
// `dns::probe_fresh`.
let probe_started = Instant::now();
let alive = super::dns::probe_fresh(&tunnel).await;
let probe_ms = probe_started.elapsed().as_millis();
if !alive {
warn!(
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe after {}ms \
(attempt {attempt}); {}",
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe in {probe_ms}ms \
({}ms total incl. build; attempt {attempt}); {}",
choice.label(),
started.elapsed().as_millis(),
match choice {
@@ -503,18 +510,6 @@ fn run_tunnel() {
});
}
/// Two attempts of the (TCP, retransmitting) liveness probe before rejecting a
/// fresh tunnel — one transient hiccup while the exit settles must not condemn
/// an otherwise healthy exit.
async fn probe_fresh(tunnel: &smolmix::Tunnel) -> bool {
for _ in 0..2 {
if super::dns::probe(tunnel).await {
return true;
}
}
false
}
/// Exit-liveness keepalive period and the consecutive probe failures that
/// declare death (the probe is now a TCP connect through the tunnel, not UDP DNS).
const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60);
@@ -912,14 +907,16 @@ async fn connect_gateway_racing(
// Spawn both so the loser can be aborted cleanly. `cfg` is `Copy`, so each task
// gets the identical anonymity config.
let race_started = Instant::now();
let mut a = tokio::spawn(connect_one(cfg));
let mut b = tokio::spawn(connect_one(cfg));
debug!("[timing] nym: gateway race START — 2 ephemeral draws, first up wins");
// Whichever finishes first; keep `other` to reap (on a win) or fall back to (if
// the first draw errored).
let (first, other) = tokio::select! {
r = &mut a => (r, b),
r = &mut b => (r, a),
// the first draw errored). `winner` tags WHICH draw finished first.
let (first, other, winner) = tokio::select! {
r = &mut a => (r, b, 'A'),
r = &mut b => (r, a, 'B'),
};
// A JoinError (task panic) folds into an error so `other` still gets its turn.
let first = first.unwrap_or_else(|e| {
@@ -932,12 +929,26 @@ async fn connect_gateway_racing(
match first {
// First to finish connected — it WINS. Reap the loser off the hot path.
Ok(client) => {
info!(
"[timing] nym: gateway race WON by draw {winner} in {}ms; reaping loser off the hot path",
race_started.elapsed().as_millis()
);
other.abort();
tokio::spawn(async move {
// If the loser connected before the abort landed, disconnect it so
// no live gateway session leaks; a pending connect was just dropped.
if let Ok(Ok(loser)) = other.await {
loser.disconnect().await;
match other.await {
Ok(Ok(loser)) => {
debug!(
"[timing] nym: gateway race loser had connected before abort — \
disconnecting so no gateway session leaks"
);
loser.disconnect().await;
}
_ => debug!(
"[timing] nym: gateway race loser still pending at reap — dropped \
(no session to close)"
),
}
});
Ok(client)
@@ -945,7 +956,13 @@ async fn connect_gateway_racing(
// First draw failed — a lone client has no dead-draw tail, so just await the
// survivor; if it fails too, surface an error and `run_tunnel` re-selects.
Err(first_err) => match other.await {
Ok(Ok(client)) => Ok(client),
Ok(Ok(client)) => {
info!(
"[timing] nym: gateway race — draw {winner} errored, survivor connected in {}ms",
race_started.elapsed().as_millis()
);
Ok(client)
}
Ok(Err(second_err)) => {
warn!(
"[timing] nym: both raced gateway connects failed \
+196
View File
@@ -266,4 +266,200 @@ mod tests {
"unexpected relay reply: {txt}"
);
}
/// INCIDENT REPRO / VERIFICATION harness: publish a ~2.5KB and a ~66KB
/// kind-1059 EVENT over a SCRATCH scoped exit (address from env
/// `GOBLIN_SCRATCH_EXIT`) to relay.floonet.dev, plus a clearnet control, and
/// report which land (clearnet oracle = ground truth, waits past EOSE so a
/// LATE arrival is still caught). Proves whether the exit pump forwards
/// multi-fragment writes. Run:
/// GOBLIN_SCRATCH_EXIT=<addr> cargo test --lib \
/// nym::streamexit::tests::scratch_exit_publish_bytes -- --ignored --nocapture
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn scratch_exit_publish_bytes() {
use futures::{SinkExt, StreamExt};
use nostr_sdk::JsonUtil;
use nostr_sdk::prelude::*;
use tokio_tungstenite::tungstenite::Message;
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = env_logger::builder()
.is_test(false)
.filter_level(log::LevelFilter::Info)
.filter_module("grim::nym", log::LevelFilter::Debug)
.try_init();
let exit = std::env::var("GOBLIN_SCRATCH_EXIT")
.expect("set GOBLIN_SCRATCH_EXIT to the scratch exit's nym address");
let relay_url = "wss://relay.floonet.dev";
let keys = Keys::generate();
let mk = |n: usize| -> Event {
let nonce = format!("{:016x}", rand::random::<u64>());
EventBuilder::new(Kind::GiftWrap, format!("{nonce}{}", "x".repeat(n)))
.tag(Tag::public_key(keys.public_key()))
.sign_with_keys(&keys)
.expect("sign event")
};
let small = mk(2_000);
let big = mk(64_000);
let clear = mk(2_000);
println!(
"[repro] small id={} wire={}B | big id={} wire={}B | clear id={} wire={}B",
small.id.to_hex(),
small.as_json().len(),
big.id.to_hex(),
big.as_json().len(),
clear.id.to_hex(),
clear.as_json().len()
);
// Clearnet control FIRST (proves the events + relay are fine end to end).
let clear_ok = clearnet_publish(relay_url, &clear).await;
println!("[repro] clearnet publish OK-frame for clear = {clear_ok}");
// Open the SCRATCH scoped exit and run the SAME TLS+ws the wallet uses.
let mut stream = None;
for attempt in 1..=6 {
match open_stream(&exit, Duration::from_secs(90)).await {
Ok(s) => {
println!("[repro] open_stream OK on attempt {attempt}");
stream = Some(s);
break;
}
Err(e) => println!("[repro] open_stream attempt {attempt} failed: {e}"),
}
}
let stream = stream.expect("scratch exit stream opened within retries");
let (mut ws, _resp) = tokio::time::timeout(
Duration::from_secs(45),
tokio_tungstenite::client_async_tls(relay_url, stream),
)
.await
.expect("TLS+ws handshake timed out (dead exit?)")
.expect("TLS+ws handshake through scratch exit failed");
println!("[repro] TLS+ws through scratch exit OK");
for (label, ev) in [("small", &small), ("big", &big)] {
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
println!("[repro] EXIT sending {label} ({} B ws frame)", frame.len());
ws.send(Message::Text(frame.into()))
.await
.expect("ws send over exit");
}
// Keep draining the exit ws in the background so the relay->client OK path
// keeps moving while we measure landing time.
let drainer = tokio::spawn(async move {
let end = tokio::time::Instant::now() + Duration::from_secs(300);
while tokio::time::Instant::now() < end {
match tokio::time::timeout(Duration::from_secs(5), ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
println!("[repro] EXIT relay -> {}", t.as_str())
}
Ok(Some(Ok(_))) => {}
Ok(Some(Err(_))) | Ok(None) => break,
Err(_) => {}
}
}
});
// Measure delivery LATENCY via the clearnet oracle (waits past EOSE).
let t0 = tokio::time::Instant::now();
let probe = Duration::from_secs(180);
let small_id = small.id.to_hex();
let big_id = big.id.to_hex();
let small_fut = async {
let ok = oracle_landed(relay_url, &small_id, probe).await;
println!(
"[repro] ===== EXIT small landed={ok} after {}s =====",
t0.elapsed().as_secs()
);
ok
};
let big_fut = async {
let ok = oracle_landed(relay_url, &big_id, probe).await;
println!(
"[repro] ===== EXIT big landed={ok} after {}s =====",
t0.elapsed().as_secs()
);
ok
};
let (_s, _b) = tokio::join!(small_fut, big_fut);
let clear_landed =
oracle_landed(relay_url, &clear.id.to_hex(), Duration::from_secs(20)).await;
println!("[repro] ===== CLEARNET control clear landed={clear_landed} =====");
drainer.abort();
}
/// Clearnet publish `ev`; returns true on relay `OK ... true`. Positive control.
#[cfg(test)]
async fn clearnet_publish(url: &str, ev: &nostr_sdk::Event) -> bool {
use futures::{SinkExt, StreamExt};
use nostr_sdk::JsonUtil;
use tokio_tungstenite::tungstenite::Message;
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
Ok(x) => x,
Err(e) => {
println!("[oracle] clearnet connect err: {e}");
return false;
}
};
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
if ws.send(Message::Text(frame.into())).await.is_err() {
return false;
}
let id = ev.id.to_hex();
for _ in 0..20 {
match tokio::time::timeout(Duration::from_secs(10), ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
let t = t.as_str();
if t.starts_with("[\"OK\"") {
println!("[oracle] clearnet OK-frame: {t}");
return t.contains(&id) && t.contains("true");
}
}
_ => break,
}
}
false
}
/// Clearnet oracle: REQ for `id_hex`; true iff the relay returns the stored
/// EVENT within `timeout`. Ignores EOSE and keeps the sub OPEN so a LATE
/// arrival (the slow-exit case) is caught the instant the relay stores it.
#[cfg(test)]
async fn oracle_landed(url: &str, id_hex: &str, timeout: Duration) -> bool {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
Ok(x) => x,
Err(e) => {
println!("[oracle] connect err: {e}");
return false;
}
};
let req = format!(r#"["REQ","oracle",{{"ids":["{id_hex}"]}}]"#);
if ws.send(Message::Text(req.into())).await.is_err() {
return false;
}
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return false;
}
match tokio::time::timeout(remaining, ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
if t.as_str().starts_with("[\"EVENT\"") {
return true;
}
}
Ok(Some(Ok(_))) => {}
_ => return false,
}
}
}
}
+38 -35
View File
@@ -94,6 +94,10 @@ pub struct AppConfig {
/// Application update information.
app_update: Option<AppUpdate>,
/// Hide received grin amounts in payment notifications/alerts. Default false
/// so existing configs (and new wallets) keep showing the amount.
hide_amounts: Option<bool>,
/// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway
/// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect
/// can skip re-picking a (possibly dead) random first hop.
@@ -101,15 +105,6 @@ pub struct AppConfig {
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
/// warm reconnect can try the exit that worked last time before auto-selecting.
nym_last_ipr: Option<String>,
/// Last successfully fetched GRIN rate, so the amount preview can paint an
/// instant (stale-marked) fiat value on cold start instead of a blank until the
/// first mixnet fetch lands.
last_rate: Option<f64>,
/// The `vs_currency` the cached [`last_rate`] was priced against.
last_rate_vs: Option<String>,
/// Unix-seconds timestamp the cached [`last_rate`] was fetched at.
last_rate_at: Option<i64>,
}
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
@@ -221,11 +216,9 @@ impl Default for AppConfig {
// update check — payments, relays and identity still stay mixnet-only.
check_updates: Some(true),
app_update: None,
hide_amounts: None,
nym_entry_gateway: None,
nym_last_ipr: None,
last_rate: None,
last_rate_vs: None,
last_rate_at: None,
}
}
}
@@ -535,29 +528,6 @@ impl AppConfig {
w_config.save();
}
/// Get the cached GRIN rate as `(vs_currency, rate, fetched_at)`, if one was
/// ever persisted. Callers decide whether it is fresh enough to use.
pub fn last_rate() -> Option<(String, f64, i64)> {
let r_config = Settings::app_config_to_read();
match (
r_config.last_rate_vs.clone(),
r_config.last_rate,
r_config.last_rate_at,
) {
(Some(vs), Some(rate), Some(at)) => Some((vs, rate, at)),
_ => None,
}
}
/// Persist the most recent GRIN rate for `vs`, fetched at `at` (unix secs).
pub fn set_last_rate(vs: &str, rate: f64, at: i64) {
let mut w_config = Settings::app_config_to_update();
w_config.last_rate_vs = Some(vs.to_string());
w_config.last_rate = Some(rate);
w_config.last_rate_at = Some(at);
w_config.save();
}
/// Check if proxy for network requests is needed.
pub fn use_proxy() -> bool {
let r_config = Settings::app_config_to_read();
@@ -659,4 +629,37 @@ impl AppConfig {
}
w_config.save();
}
/// Whether received grin amounts are hidden in payment notifications/alerts.
pub fn hide_amounts() -> bool {
let r_config = Settings::app_config_to_read();
r_config.hide_amounts.unwrap_or(false)
}
/// Set whether received grin amounts are hidden in notifications/alerts.
pub fn set_hide_amounts(hide: bool) {
let mut w_config = Settings::app_config_to_update();
w_config.hide_amounts = Some(hide);
w_config.save();
}
}
#[cfg(test)]
mod tests {
use super::AppConfig;
/// An old config carrying the now-removed price-cache fields (last_rate /
/// last_rate_vs / last_rate_at) must still load: serde ignores unknown keys,
/// so no migration is needed and the dead fields are simply dropped on the
/// next save.
#[test]
fn loads_config_with_removed_price_cache_fields() {
let mut toml = toml::to_string(&AppConfig::default()).expect("serialize default");
toml.push_str("\nlast_rate = 1.23\nlast_rate_vs = \"usd\"\nlast_rate_at = 1700000000\n");
let parsed = toml::from_str::<AppConfig>(&toml);
assert!(
parsed.is_ok(),
"old config with removed price-cache fields should still load"
);
}
}
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Embedded Tor (arti) client — the DIALING half only. Copied from our sister
//! wallet GRIM's proven, shipping engine (`grim/src/tor/`), stripped to what
//! Goblin needs: connect OUT to the relay's `.onion` (and to clearnet HTTP hosts
//! through a Tor exit). Goblin never HOSTS an onion service (GRIM's receiving
//! half), so the onion-service hosting, keystore-seeding and reverse-proxy code
//! is dropped.
//!
//! Two technical choices are inherited VERBATIM from GRIM because it already paid
//! for them: **arti 0.43** across the arti family, and the **native-tls Tor
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, to sidestep
//! the rustls/ring crypto-provider conflict Goblin fought during the Nym era.
//!
//! The arti client runs on its OWN dedicated tokio runtime (created once, kept
//! alive for the process). `TorClient::connect()` returns a [`DataStream`] that
//! is `AsyncRead + AsyncWrite`; that byte source is handed to the websocket layer
//! ([`super::transport`]) and the HTTP layer ([`super`]), each driven by their
//! own caller runtime — a `DataStream` is runtime-agnostic once the client's
//! circuit tasks are running on the arti runtime.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::{fs, thread};
use arti_client::config::TorClientConfigBuilder;
use arti_client::{DataStream, TorClient, TorClientConfig};
use lazy_static::lazy_static;
use log::{error, info, warn};
use parking_lot::RwLock;
use tor_rtcompat::SpawnExt;
use tor_rtcompat::tokio::TokioNativeTlsRuntime;
/// The Tor runtime type — native-tls, matching GRIM (never rustls).
type Runtime = TokioNativeTlsRuntime;
/// The concrete arti client type.
pub type Client = TorClient<Runtime>;
/// How long a single cold Tor bootstrap may take before we declare it failed and
/// let a later `warm_up()`/`wait_ready()` retry. A cold bootstrap with no cached
/// consensus can take tens of seconds; a warm one (cached dir) is a few.
const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(90);
lazy_static! {
/// Process-lifetime Tor state. The dedicated arti runtime lives here so its
/// worker threads (which drive every circuit) persist for the whole process.
static ref TOR: Tor = Tor::new();
}
struct Tor {
/// The dedicated arti runtime (native-tls). All arti tasks run on this.
runtime: Runtime,
/// The bootstrapped client, once it is up.
client: RwLock<Option<Arc<Client>>>,
/// Guards the background bootstrap so `warm_up()` is idempotent.
launching: AtomicBool,
}
impl Tor {
fn new() -> Self {
Self {
runtime: TokioNativeTlsRuntime::create().expect("create tor runtime"),
client: RwLock::new(None),
launching: AtomicBool::new(false),
}
}
}
// --- Readiness signals (re-pointed from `nym::nymproc`, same semantics) -------
/// Set once arti has bootstrapped (mirrors `TUNNEL_GEN != 0`); cheap to poll.
static READY: AtomicBool = AtomicBool::new(false);
/// Monotonic "transport generation". With Tor there is no exit-reselect churn —
/// arti rebuilds circuits transparently under the `DataStream` — so this simply
/// becomes 1 once bootstrapped and stays there. The relay-gated readiness logic
/// (copied from nym) still works: a relay-liveness report tagged with an older
/// generation can never mark a newer transport ready.
static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
/// The generation on which the nostr client currently has a relay connected AND
/// subscribed, or 0 for "no relay live". A single atomic so [`transport_ready`]
/// can compare it to `TUNNEL_GEN` in one shot.
static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0);
/// Whether a nostr consumer currently wants relays over Tor. Kept for API parity
/// with the nym transport (the UI/service bracket it); Tor needs no exit-health
/// governance, so it is otherwise inert.
static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false);
/// Pre-warm the embedded Tor client in the background so relays / NIP-05 / price
/// are ready by first use. Idempotent — a call while a bootstrap is in flight, or
/// once one has succeeded, is a no-op.
pub fn warm_up() {
if TOR.client.read().is_some() {
return;
}
if TOR.launching.swap(true, Ordering::SeqCst) {
return;
}
thread::spawn(|| {
bootstrap_once();
TOR.launching.store(false, Ordering::SeqCst);
});
}
/// Whether the embedded Tor client has bootstrapped. Cheap and cached — safe to
/// poll from the UI each frame. Distinct from a relay being connected (see
/// [`transport_ready`]): Tor can be up while no relay yet rides it.
pub fn is_ready() -> bool {
READY.load(Ordering::Relaxed)
}
/// The current transport generation. The nostr client reads this right before it
/// dials so it can tag its relay-liveness reports.
pub fn tunnel_generation() -> u64 {
TUNNEL_GEN.load(Ordering::Acquire)
}
/// Relay-gated readiness — the AUTHORITATIVE "ready to receive/send over Tor"
/// signal, distinct from the bootstrap-only [`is_ready`]. True only when Tor is
/// bootstrapped AND a required relay is connected+subscribed on the CURRENT
/// generation, so the UI never shows a false "Connected".
pub fn transport_ready() -> bool {
let generation = TUNNEL_GEN.load(Ordering::Acquire);
generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation && is_ready()
}
/// Client → transport report: a relay is connected+subscribed on `generation`.
/// `fetch_max` so a late report for an older generation can never move liveness
/// backwards over a newer one.
pub fn report_relay_live(generation: u64) {
RELAY_LIVE_GEN.fetch_max(generation, Ordering::AcqRel);
}
/// Client → transport report: no relay is currently live on `generation`. Clears
/// liveness only when `generation` is still the live one.
pub fn report_relay_down(generation: u64) {
let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire);
}
/// Bracket a nostr consumer's lifetime (API parity with the nym transport). Inert
/// for Tor — arti manages its own circuit health — but kept so the service's
/// existing calls compile unchanged.
pub fn set_relay_consumer(active: bool) {
RELAY_CONSUMER.store(active, Ordering::Release);
}
/// External condemnation request (API parity with the nym transport). Under Tor
/// there is no exit to abandon — arti rebuilds circuits itself — so this is a
/// logged no-op rather than triggering a reselect.
pub fn condemn_exit(generation: u64) {
if generation != 0 {
warn!("tor: condemn_exit(gen {generation}) is a no-op (arti rebuilds circuits itself)");
}
}
/// The bootstrapped client, if it is up. Cloning the `Arc` is cheap.
pub fn client() -> Option<Arc<Client>> {
TOR.client.read().clone()
}
/// Wait until the embedded Tor client has bootstrapped, starting it if nothing
/// has yet (lazy init on first use). Returns `false` once `timeout` lapses.
pub async fn wait_ready(timeout: Duration) -> bool {
warm_up();
let deadline = Instant::now() + timeout;
loop {
if is_ready() {
return true;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
/// Open a Tor stream to `host:port`. `host` may be a `.onion` address (dialed as
/// a real onion connection — no exit node) or a clearnet host (dialed through a
/// Tor exit). Returns a [`DataStream`] (`AsyncRead + AsyncWrite`) — the byte
/// source the websocket / HTTP layers wrap. The caller is responsible for its own
/// connect timeout.
pub async fn connect(host: &str, port: u16) -> Result<DataStream, String> {
let client = client().ok_or_else(|| "tor client not bootstrapped".to_string())?;
client
.connect((host, port))
.await
.map_err(|e| format!("tor connect to {host}:{port} failed: {e}"))
}
/// Build the arti client config: fs-backed state + cache in Goblin's base dir,
/// and — crucially — `allow_onion_addrs(true)` so `.onion` targets are dialable
/// (this plus the `onion-service-client` cargo feature is what enables onion
/// connections). Matches GRIM's `build_config`, minus the bridge plumbing Goblin
/// does not use.
fn build_config() -> TorClientConfig {
let mut builder =
TorClientConfigBuilder::from_directories(super::state_path(), super::cache_path());
builder.address_filter().allow_onion_addrs(true);
builder.build().expect("build tor client config")
}
/// One bootstrap attempt, driven on the arti runtime (GRIM's proven pattern:
/// spawn the bootstrap on arti's runtime, poll a flag from this thread). On
/// success the client is published and the readiness signals flip.
fn bootstrap_once() {
// Ensure the state/cache dirs exist (arti creates them, but on a fresh device
// the parent must be present first).
let _ = fs::create_dir_all(super::state_path());
let _ = fs::create_dir_all(super::cache_path());
let config = build_config();
let client = match TorClient::with_runtime(TOR.runtime.clone())
.config(config)
.create_unbootstrapped()
{
Ok(c) => c,
Err(e) => {
error!("tor: could not create client: {e}");
return;
}
};
let started = Instant::now();
let bootstrapping = Arc::new(AtomicBool::new(true));
let success = Arc::new(AtomicBool::new(false));
let bootstrapping_t = bootstrapping.clone();
let success_t = success.clone();
let c = client.clone();
let spawned = TOR.runtime.spawn(async move {
match tokio::time::timeout(BOOTSTRAP_TIMEOUT, c.bootstrap()).await {
Ok(Ok(())) => success_t.store(true, Ordering::Relaxed),
Ok(Err(e)) => error!("tor: bootstrap error: {e}"),
Err(_) => error!(
"tor: bootstrap timed out after {}s",
BOOTSTRAP_TIMEOUT.as_secs()
),
}
bootstrapping_t.store(false, Ordering::Relaxed);
});
if spawned.is_err() {
error!("tor: could not spawn bootstrap task");
return;
}
// Wait for the bootstrap task to finish.
while bootstrapping.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(500));
}
if !success.load(Ordering::Relaxed) {
return;
}
// `create_unbootstrapped()` already hands back an `Arc<TorClient>`, so store it
// as-is (no extra wrapping).
TOR.client.write().replace(client);
// A NEW transport is live: publish generation 1 (relay-liveness left over from
// a prior generation is instantly stale) and flip the bootstrap-ready flag.
TUNNEL_GEN.store(1, Ordering::Release);
READY.store(true, Ordering::Release);
info!(
"tor: bootstrapped and ready in {}ms (gen 1)",
started.elapsed().as_millis()
);
// Eager price fetch the moment Tor is ready (mirrors what the old mixnet
// bootstrap did): prefetch the pairing's rate so the amount preview has a live
// value by first use. One-shot — bootstrap_once only reaches here once.
std::thread::spawn(crate::http::price::eager_refresh);
}
+288
View File
@@ -0,0 +1,288 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Embedded-Tor transport. Everything Goblin sends over the network — nostr relay
//! websockets and every HTTP request (NIP-05, price, relay pool, avatars) — rides
//! Tor, embedded in-process (arti), copied from our sister wallet GRIM's proven,
//! shipping engine. Every relay is reached over a Tor exit to its clearnet host,
//! with the usual hostname-validated TLS for `wss://`: the wallet's own IP is
//! never exposed, while the relay stays a normal public endpoint. (Earlier builds
//! could pin a per-relay `.onion` for a direct onion-circuit money path; that was
//! dropped in build134 — onion services flapped — in favour of Tor-exit only.)
//!
//! This replaces the Nym-mixnet transport (`crate::nym`, left dormant): Tor is
//! free, unmetered, has no token or grant to expire, and GRIM has already proven
//! the whole embedded path on desktop and Android.
//!
//! The Grin blockchain node is NOT routed here — it stays on the clear internet
//! exactly as before; it never sees who pays whom.
mod engine;
mod transport;
pub use engine::{
Client, client, condemn_exit, connect, is_ready, report_relay_down, report_relay_live,
set_relay_consumer, transport_ready, tunnel_generation, wait_ready, warm_up,
};
pub use transport::TorWebSocketTransport;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use log::{debug, warn};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::Settings;
/// How long a single HTTP exchange (one redirect hop) may take end to end.
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the embedded Tor client to bootstrap before giving up on
/// a request. A cold Tor bootstrap can take tens of seconds; a warm one is fast.
const TUNNEL_WAIT: Duration = Duration::from_secs(60);
/// Redirect hops to follow before giving up.
const MAX_REDIRECTS: usize = 5;
/// Default `User-Agent` for every Tor-carried HTTP request. A common desktop
/// browser string (not "goblin-wallet"), so the wallet's traffic is not trivially
/// classifiable as Goblin at the destination. Callers may still override it by
/// passing their own `User-Agent` in the headers list; the endpoints Goblin talks
/// to (GitHub API, CoinGecko, name authorities) all accept a generic UA.
const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/131.0.0.0 Safari/537.36";
// --- Tor data directories -----------------------------------------------------
/// Base Tor data directory (`<base>/tor`).
fn base_path() -> PathBuf {
Settings::base_path(Some("tor".to_string()))
}
/// Tor state directory (consensus, guards, …). Used by [`engine`].
pub(crate) fn state_path() -> String {
let mut base = base_path();
base.push("state");
base.to_str().unwrap().to_string()
}
/// Tor cache directory (directory documents). Used by [`engine`].
pub(crate) fn cache_path() -> String {
let mut base = base_path();
base.push("cache");
base.to_str().unwrap().to_string()
}
// --- HTTP over Tor ------------------------------------------------------------
/// An HTTP request routed over Tor: dial the host over Tor (an onion via a real
/// onion circuit, a clearnet host via a Tor exit — arti resolves the name
/// internally, so nothing leaks a clearnet DNS lookup), then rustls (webpki
/// roots) for https, then HTTP/1.1. Follows redirects. Returns `(status, body)`.
///
/// For now clearnet-over-Tor is fine for the small lookups (names at goblin.st,
/// relay hints, pool refresh, price, avatars); pinning those behind onions is a
/// later pass.
pub async fn http_request_bytes(
method: &str,
url: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
if !wait_ready(TUNNEL_WAIT).await {
warn!("tor http: client not bootstrapped, dropping request");
return None;
}
let mut url = url::Url::parse(&url).ok()?;
let mut method = method.to_uppercase();
let mut body = body;
for _ in 0..=MAX_REDIRECTS {
let (status, resp_body, location) = tokio::time::timeout(
HTTP_TIMEOUT,
request_once(&method, &url, body.clone(), &headers),
)
.await
.map_err(|_| warn!("tor http: request to {} timed out", redacted(&url)))
.ok()??;
match location {
Some(loc) => {
url = url.join(&loc).ok()?;
// 303 (and legacy 301/302) turn into a bodiless GET; 307/308 replay.
if matches!(status, 301..=303) {
method = "GET".to_string();
body = None;
}
debug!(
"tor http: following {status} redirect to {}",
redacted(&url)
);
}
None => return Some((status, resp_body)),
}
}
warn!("tor http: too many redirects for {}", redacted(&url));
None
}
/// String-bodied convenience wrapper around [`http_request_bytes`].
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers)
.await
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
}
/// Host without path/query, for logs (never log full URLs).
fn redacted(url: &url::Url) -> String {
url.host_str().unwrap_or("<no-host>").to_string()
}
/// A single HTTP/1.1 exchange over Tor. Returns the status, the collected body
/// and, for 3xx responses, the `Location` target.
async fn request_once(
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
let host = url.host_str()?.to_string();
let https = url.scheme() == "https";
let port = url.port().unwrap_or(if https { 443 } else { 80 });
let tcp = connect(&host, port)
.await
.map_err(|e| warn!("tor http: connect to {host} failed: {e}"))
.ok()?;
let io: Box<dyn Stream> = if https {
Box::new(tls_connect(&host, tcp).await?)
} else {
Box::new(tcp)
};
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.map_err(|e| warn!("tor http: handshake with {host} failed: {e}"))
.ok()?;
// Drive the connection in the background for this one exchange.
tokio::spawn(async move {
let _ = conn.await;
});
let m = hyper::Method::from_bytes(method.as_bytes()).ok()?;
let path = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
let host_header = if (https && port == 443) || (!https && port == 80) {
host.clone()
} else {
format!("{host}:{port}")
};
let mut req = hyper::Request::builder()
.method(m)
.uri(path)
.header(hyper::header::HOST, host_header)
.header(hyper::header::USER_AGENT, DEFAULT_USER_AGENT);
for (k, v) in headers {
req = req.header(k, v);
}
let req = req
.body(Full::new(Bytes::from(body.unwrap_or_default())))
.ok()?;
let resp = sender
.send_request(req)
.await
.map_err(|e| warn!("tor http: request to {host} failed: {e}"))
.ok()?;
let status = resp.status().as_u16();
let location = if resp.status().is_redirection() {
resp.headers()
.get(hyper::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec();
Some((status, bytes, location))
}
/// Everything hyper (and the TLS layer) needs from a Tor-carried stream, boxable
/// for the plain-http / https split.
pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Stream for T {}
lazy_static::lazy_static! {
/// Shared rustls client config (webpki roots; ring provider installed at
/// startup — see lib.rs), reused by every clearnet-over-Tor https handshake.
/// Never the platform verifier — it panics on Android outside a full app
/// context.
static ref TLS_CONFIG: Arc<rustls::ClientConfig> = {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
};
}
/// The shared rustls client config (cheap `Arc` bump).
pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
TLS_CONFIG.clone()
}
/// TLS-wrap a Tor-carried TCP stream with rustls + webpki roots. The certificate
/// is validated against the HOSTNAME, so a hostile Tor exit cannot MITM a
/// clearnet https fetch.
async fn tls_connect<S>(host: &str, stream: S) -> Option<tokio_rustls::client::TlsStream<S>>
where
S: AsyncRead + AsyncWrite + Send + Unpin,
{
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?;
tokio_rustls::TlsConnector::from(tls_config())
.connect(server_name, stream)
.await
.map_err(|e| warn!("tor http: tls handshake with {host} failed: {e}"))
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_user_agent_is_browser_like_not_goblin() {
// The default UA must read as a common desktop browser, so Goblin's Tor
// traffic is not trivially fingerprintable at the destination.
assert!(DEFAULT_USER_AGENT.starts_with("Mozilla/5.0"));
assert!(DEFAULT_USER_AGENT.contains("Chrome/"));
assert!(DEFAULT_USER_AGENT.contains("Safari/"));
// No line-continuation whitespace leaked into the literal.
assert!(!DEFAULT_USER_AGENT.contains(" "));
assert!(!DEFAULT_USER_AGENT.contains('\n'));
// The old identifying string is gone.
assert!(!DEFAULT_USER_AGENT.to_lowercase().contains("goblin"));
}
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! WebSocket transport for the Nostr relay pool routed over embedded Tor. Every
//! relay is dialed the same way: reach its clearnet host over a Tor exit and run
//! the usual hostname-validated TLS + websocket
//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. The payload and the
//! in-flight destination never touch the clear, and the wallet's own IP is never
//! exposed. (Earlier builds could dial a relay's pinned `.onion` directly over a
//! real onion circuit as a money-path anchor; that was dropped in build134 — the
//! onion services flapped — leaving the Tor-exit path as the only one.)
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
use async_wsocket::{ConnectionMode, Message};
use nostr_relay_pool::transport::error::TransportError;
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
use nostr_sdk::Url;
use nostr_sdk::util::BoxedFuture;
use tokio_tungstenite::tungstenite::Message as TgMessage;
/// A backend transport error (failures outside the websocket layer) carrying
/// `msg` as its display text.
fn terr(msg: impl Into<String>) -> TransportError {
TransportError::backend(std::io::Error::other(msg.into()))
}
/// Per-message / per-frame ceiling for every relay websocket. The relay pool
/// only ever requires `max_message_length >= 131072` (128 KiB) from a relay,
/// and the wallet's own events (gift wraps, kind-0/10050) are far smaller.
/// tungstenite otherwise defaults to a 64 MiB message / 16 MiB frame ceiling;
/// tighten it to a few MiB so a hostile or buggy relay can't stream a giant
/// frame into wallet memory, while keeping ample headroom above any legitimate
/// event.
const WS_MAX_MESSAGE_SIZE: usize = 4 << 20; // 4 MiB
const WS_MAX_FRAME_SIZE: usize = 4 << 20; // 4 MiB
/// The tightened websocket config applied to every relay dial.
fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.max_message_size(Some(WS_MAX_MESSAGE_SIZE))
.max_frame_size(Some(WS_MAX_FRAME_SIZE))
}
/// Nostr websocket transport over embedded Tor.
#[derive(Debug, Clone, Copy, Default)]
pub struct TorWebSocketTransport;
impl WebSocketTransport for TorWebSocketTransport {
fn support_ping(&self) -> bool {
true
}
fn connect<'a>(
&'a self,
url: &'a Url,
_mode: &'a ConnectionMode,
timeout: Duration,
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
Box::pin(async move {
let host = url
.host_str()
.ok_or_else(|| terr("relay url has no host"))?
.to_string();
// The embedded Tor client must be bootstrapped before any dial.
if !crate::tor::wait_ready(timeout).await {
return Err(terr("tor client not bootstrapped"));
}
// Reach the relay's clearnet host over a Tor exit, with the usual
// hostname-validated TLS + websocket for wss (SNI = the relay host).
// This is the single dial path: the wallet's IP never leaves Tor, and a
// send fans out to a recipient's arbitrary public DM relays exactly the
// way it reaches the shared floor relay (relay.floonet.dev).
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
let t = Instant::now();
let stream = tokio::time::timeout(timeout, crate::tor::connect(&host, port))
.await
.map_err(|_| terr("tor connect timeout"))?
.map_err(terr)?;
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls_with_config(
url.as_str(),
stream,
Some(ws_config()),
None,
),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
log::info!(
"[timing] tor: relay {host} CONNECTED via exit — tls+ws {}ms",
t.elapsed().as_millis()
);
Ok(split_ws(ws))
})
}
}
/// Split a websocket into the pool's boxed sink/stream halves, so everything above
/// the byte transport is identical regardless of which Tor circuit carried it.
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
{
let (tx, rx) = ws.split();
let sink: WebSocketSink = Box::new(TorSink(tx)) as WebSocketSink;
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
match msg {
Ok(tg) => tg_to_message(tg).map(Ok),
Err(e) => Some(Err(TransportError::backend(e))),
}
})) as WebSocketStream;
(sink, stream)
}
/// Convert a tungstenite message into an async-wsocket pool message.
/// Returns `None` for raw frames (never surfaced while reading).
fn tg_to_message(msg: TgMessage) -> Option<Message> {
match msg {
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
TgMessage::Close(_) => Some(Message::Close(None)),
TgMessage::Frame(_) => None,
}
}
/// Sink adapter converting pool messages into tungstenite messages.
struct TorSink<S>(S);
impl<S> Sink<Message> for TorSink<S>
where
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
{
type Error = TransportError;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_ready_unpin(cx)
.map_err(TransportError::backend)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.0)
.start_send_unpin(TgMessage::from(item))
.map_err(TransportError::backend)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_flush_unpin(cx)
.map_err(TransportError::backend)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_close_unpin(cx)
.map_err(TransportError::backend)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_config_caps_message_and_frame_size() {
let cfg = ws_config();
// Tightened to the constants, well below tungstenite's 64 MiB / 16 MiB
// defaults so a hostile relay can't stream a giant frame into memory...
assert_eq!(cfg.max_message_size, Some(WS_MAX_MESSAGE_SIZE));
assert_eq!(cfg.max_frame_size, Some(WS_MAX_FRAME_SIZE));
assert!(WS_MAX_MESSAGE_SIZE < 64 << 20);
assert!(WS_MAX_FRAME_SIZE < 16 << 20);
// ...yet with ample headroom above the pool's 128 KiB minimum event size,
// so no legitimate relay traffic is ever truncated.
assert!(WS_MAX_MESSAGE_SIZE >= 131_072);
assert!(WS_MAX_FRAME_SIZE >= 131_072);
}
}
-317
View File
@@ -1,317 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! LIVE two-wallet end-to-end payment over the Floonet path — over the shared
//! exit-backed primary relay, CROSS-NODE. Two real Goblin wallets restored from
//! mainnet mnemonics (seeds via env, NEVER a file) both run on the shipped
//! default relay (`wss://relay.floonet.dev`, each pinned via its own
//! `nostr.toml`) but on DIFFERENT Grin nodes (A on grincoin.org, B on
//! main.gri.mw). One sends a real gift-wrapped Grin payment to the other,
//! asynchronously through the relay. Proves the whole money path a phone would
//! use, plus the outbox model: the sender publishes the wrap to the RECIPIENT's
//! advertised (kind 10050) relay, and settlement posts through two independent
//! nodes.
//! mixnet -> exit -> gift wrap -> S2 -> finalize -> post.
//!
//! Ignored by default (real mainnet funds + a full recovery scan). Run:
//! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \
//! cargo test --lib wallet::e2e::tests::two_goblins_pay_over_floonet -- --ignored --nocapture
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{Duration, Instant};
use grin_util::types::ZeroingString;
use crate::nostr::{Contact, NostrConfig, NostrSendStatus};
use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask};
use crate::wallet::{ConnectionsConfig, ExternalConnection, Mnemonic, Wallet};
/// 0.1 GRIN, in nanograin. Small on purpose (mainnet, real funds).
const AMOUNT: u64 = 100_000_000;
/// Wallet A's mainnet node (recovery scan + tx post).
const NODE_A: &str = "https://grincoin.org";
/// Wallet B's mainnet node — a DIFFERENT operator, so the payment settles
/// across two independent nodes.
const NODE_B: &str = "https://main.gri.mw";
/// Wallet A's relay (pinned via its nostr.toml, advertised in its 10050).
/// The new primary money-path relay, reached over its co-located scoped exit.
const RELAY_A: &str = "wss://relay.floonet.dev";
/// Wallet B's relay — the SAME shared exit-backed primary as A (how the
/// shipped product works: every Goblin wallet defaults to relay.floonet.dev).
/// Both reach it over the co-located scoped exit, so the gift-wrap round-trip
/// rides the fast money path end to end. Nodes still differ (below), so the
/// payment still settles across two independent Grin nodes.
const RELAY_B: &str = "wss://relay.floonet.dev";
/// Build + open a wallet from a 24-word mnemonic on its own external node
/// and its own single-relay nostr.toml override.
fn open_wallet(
name: &str,
phrase: &str,
pw: &ZeroingString,
conn_id: i64,
node_url: &str,
relay: &str,
) -> Wallet {
let mut m = Mnemonic::default();
m.set_mode(PhraseMode::Import);
m.import(&ZeroingString::from(phrase));
assert!(
m.valid(),
"{name}: mnemonic did not validate (bad seed words?)"
);
let conn = ConnectionMethod::External(conn_id, node_url.to_string());
let w = Wallet::create(&name.to_string(), pw, &m, &conn)
.unwrap_or_else(|e| panic!("{name}: wallet create failed: {e}"));
// Pin this wallet to a single relay BEFORE open(): init_nostr loads
// nostr.toml from the wallet data dir on open, and a `relays` override
// both drives the client's relay set and is advertised as the wallet's
// kind 10050 DM inbox (see NostrService::relays / publish_identity).
let wallet_dir = PathBuf::from(w.get_config().get_data_path());
let mut nostr_cfg = NostrConfig::load(wallet_dir.clone());
nostr_cfg.set_relays(vec![relay.to_string()]);
println!(
"[e2e] {name}: node={node_url} relay={relay} (nostr.toml at {})",
wallet_dir.join(NostrConfig::FILE_NAME).display()
);
w.open(pw.clone())
.unwrap_or_else(|e| panic!("{name}: wallet open failed: {e}"));
w
}
/// The persisted form of "added this payee from their nprofile": a contact
/// carrying their DM relay, so payment routing (send_targets -> fetch_dm_relays)
/// uses that relay directly instead of blind kind-10050 discovery over the
/// exit-less indexers. BOTH legs of a cross-relay payment need this seeded.
fn contact_with_relay(npub_hex: &str, relay: &str) -> Contact {
Contact {
ver: 1,
npub: npub_hex.to_string(),
petname: None,
nip05: None,
nip05_verified_at: None,
relays: vec![relay.to_string()],
nip44_v3: false,
hue: 0,
unknown: false,
added_at: 0,
last_paid_at: None,
blocked: false,
}
}
/// Poll `cond` until true or `secs` elapse; log progress via `label`.
fn wait_until(label: &str, secs: u64, mut cond: impl FnMut() -> bool) -> bool {
let start = Instant::now();
let mut last = 0u64;
while start.elapsed() < Duration::from_secs(secs) {
if cond() {
println!("[e2e] {label}: OK in {}s", start.elapsed().as_secs());
return true;
}
let el = start.elapsed().as_secs();
if el >= last + 15 {
last = el;
println!("[e2e] {label}: waiting... {el}s");
}
std::thread::sleep(Duration::from_secs(2));
}
println!("[e2e] {label}: TIMEOUT after {secs}s");
false
}
#[test]
#[ignore]
fn two_goblins_pay_over_floonet() {
let seed_a = std::env::var("GOBLIN_E2E_SEED_A").unwrap_or_default();
let seed_b = std::env::var("GOBLIN_E2E_SEED_B").unwrap_or_default();
if seed_a.trim().is_empty() || seed_b.trim().is_empty() {
println!("[e2e] SKIP: set GOBLIN_E2E_SEED_A and GOBLIN_E2E_SEED_B");
return;
}
// Isolate wallet + nym state under a throwaway HOME. MUST precede any
// grim call (Settings roots at $HOME/.goblin on first deref).
let home = std::env::var("GOBLIN_E2E_HOME").unwrap_or_else(|_| {
std::env::temp_dir()
.join("goblin-e2e-home")
.to_string_lossy()
.into_owned()
});
unsafe {
std::env::set_var("HOME", &home);
}
println!("[e2e] HOME = {home}");
// The app installs these at startup (src/lib.rs); a bare test must too.
let _ = rustls::crypto::ring::default_provider().install_default();
crate::nym::warm_up();
assert!(
wait_until("nym tunnel is_ready", 180, crate::nym::is_ready),
"nym tunnel never came up"
);
// Register a SEPARATE mainnet node per wallet. ExternalConnection ids
// are unix seconds, and add_ext_conn dedupes on id — two conns built in
// the same second would collide — so bump B's id explicitly.
let node_a = ExternalConnection::new(NODE_A.to_string(), Some("grin".to_string()), None);
let conn_a = node_a.id;
ConnectionsConfig::add_ext_conn(node_a);
let mut node_b =
ExternalConnection::new(NODE_B.to_string(), Some("grin".to_string()), None);
node_b.id = conn_a + 1;
let conn_b = node_b.id;
ConnectionsConfig::add_ext_conn(node_b);
let strip = |s: &str| {
s.trim_start_matches("https://")
.trim_start_matches("wss://")
.to_string()
};
println!(
"[e2e] A: node={} relay={} | B: node={} relay={}",
strip(NODE_A),
strip(RELAY_A),
strip(NODE_B),
strip(RELAY_B)
);
let pw = ZeroingString::from("e2e-test-pass");
println!("[e2e] opening wallet A...");
let a = open_wallet("goblin-e2e-a", seed_a.trim(), &pw, conn_a, NODE_A, RELAY_A);
// Wallet id = unix seconds; two creates in the same second collide.
std::thread::sleep(Duration::from_millis(1500));
println!("[e2e] opening wallet B...");
let b = open_wallet("goblin-e2e-b", seed_b.trim(), &pw, conn_b, NODE_B, RELAY_B);
// Nostr services connect, each to its OWN relay (over the exit).
let a_svc = a.nostr_service().expect("A nostr service");
let b_svc = b.nostr_service().expect("B nostr service");
let t_a = Instant::now();
assert!(
wait_until("A nostr connected", 240, || a_svc.is_connected()),
"A never connected to its relay ({RELAY_A})"
);
println!("[e2e] A connected in {}s", t_a.elapsed().as_secs());
let t_b = Instant::now();
assert!(
wait_until("B nostr connected", 240, || b_svc.is_connected()),
"B never connected to its relay ({RELAY_B})"
);
println!("[e2e] B connected in {}s", t_b.elapsed().as_secs());
println!("[e2e] A effective relays = {:?}", a_svc.relays());
println!("[e2e] B effective relays = {:?}", b_svc.relays());
assert_eq!(
a_svc.relays(),
vec![RELAY_A.to_string()],
"A's relay override did not take"
);
assert_eq!(
b_svc.relays(),
vec![RELAY_B.to_string()],
"B's relay override did not take"
);
println!("[e2e] A npub = {}", a_svc.npub());
println!("[e2e] B npub = {}", b_svc.npub());
// Pre-seed each wallet's contact store with the other (npub + DM relay) —
// the realistic "added the payee from their nprofile" path. Payment
// routing then uses the cached DM relay directly, so BOTH legs cross
// relays deterministically (A -> B's relay over the tunnel, B -> A's relay
// over the exit) without the kind-10050 discovery fetch over the exit-less
// indexers that stalled the pure-discovery run.
a_svc
.store
.save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY_B));
b_svc
.store
.save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY_A));
println!("[e2e] seeded contacts: A knows B @ {RELAY_B}, B knows A @ {RELAY_A}");
// Recovery scan: concurrent across both wallets, each against its own
// node. Sender needs spendable.
wait_until("A synced_from_node", 2400, || a.synced_from_node());
wait_until("B synced_from_node", 2400, || b.synced_from_node());
let spendable = |w: &Wallet| -> u64 {
w.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0)
};
let a_bal = spendable(&a);
let b_bal = spendable(&b);
println!("[e2e] spendable: A={a_bal} nano, B={b_bal} nano (need {AMOUNT})");
// Sender = whichever wallet actually has the funds. Either way the wrap
// crosses relays: the sender fetches the recipient's kind 10050 (from
// the recipient's relay + the discovery indexers) and publishes the
// gift wrap THERE — the outbox path this test exists to prove.
let (sender, sender_svc, recv_svc, sender_name) = if a_bal >= AMOUNT + 20_000_000 {
(&a, &a_svc, &b_svc, "A")
} else if b_bal >= AMOUNT + 20_000_000 {
(&b, &b_svc, &a_svc, "B")
} else {
panic!(
"neither wallet has >= {AMOUNT}+fee spendable (A={a_bal}, B={b_bal}); fund one and retry"
);
};
let receiver_hex = recv_svc.public_key().to_hex();
println!("[e2e] sender = {sender_name}; paying {AMOUNT} nano to {receiver_hex}");
// Fire the async payment across the two relays.
let t_send = Instant::now();
sender.task(WalletTask::NostrSend(
AMOUNT,
receiver_hex.clone(),
Some("floonet e2e".to_string()),
vec![],
));
// Watch the sender's meta walk Created -> AwaitingS2 -> Finalized.
// Generous window: two relays + two nodes + mixnet round trips.
let finalized = wait_until("payment finalized", 900, || {
if let Some(err) = sender_svc.last_send_error() {
println!("[e2e] sender last_send_error: {err}");
}
sender_svc
.store
.all_tx_meta()
.iter()
.any(|m| matches!(m.status, NostrSendStatus::Finalized))
});
println!(
"[e2e] send->finalize elapsed {}s; finalized={finalized}",
t_send.elapsed().as_secs()
);
// Dump both stores for the record.
for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] {
for m in svc.store.all_tx_meta() {
println!("[e2e] {who} meta {} -> {:?}", m.slate_id, m.status);
}
}
a.close();
b.close();
assert!(
finalized,
"payment did not reach Finalized within the window (see meta trail above)"
);
println!("[e2e] SUCCESS: cross-relay + cross-node payment finalized over the floonet path");
}
}
+1 -1
View File
@@ -35,5 +35,5 @@ pub use utils::WalletUtils;
mod seed;
pub mod store;
#[cfg(test)]
#[cfg(all(test, feature = "e2e-internal"))]
mod e2e;
+14 -1
View File
@@ -433,7 +433,20 @@ pub enum WalletTask {
/// * amount
/// * receiver public key (hex)
/// * optional note (subject line)
NostrSend(u64, String, Option<String>, Vec<String>),
/// * relay hints
/// * optional proof address (grin1/tgrin1): proof-on-request turns ON when
/// present (frozen contract W2); threaded as `payment_proof_recipient_address`
/// * optional order handle (the `order=` URI param, echoed into delivery events)
/// * optional watcher npub (the `notify=` URI param, gift-wrap target)
NostrSend(
u64,
String,
Option<String>,
Vec<String>,
Option<String>,
Option<String>,
Option<String>,
),
/// Re-dispatch the pending nostr message for transaction.
/// * tx id
NostrResend(u32),
+604 -24
View File
@@ -14,7 +14,9 @@
use crate::AppConfig;
use crate::node::{Node, NodeConfig};
use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore};
use crate::nostr::{
HeldIdentities, HeldIdentityKeys, NostrConfig, NostrIdentity, NostrService, NostrStore,
};
use crate::wallet::seed::WalletSeed;
use crate::wallet::store::TxHeightStore;
use crate::wallet::types::{
@@ -44,7 +46,7 @@ use grin_wallet_libwallet::{
SlatepackAddress, StatusMessage, StoredProofInfo, TxLogEntry, TxLogEntryType, WalletBackend,
WalletInitStatus, WalletInst, WalletLCProvider, address,
};
use log::{error, info};
use log::{error, info, warn};
use num_bigint::BigInt;
use parking_lot::RwLock;
use rand::Rng;
@@ -61,6 +63,41 @@ use std::time::Duration;
use std::{fs, path, thread};
use uuid::Uuid;
/// A held nostr identity as the identity switcher needs to render it. Carries no
/// secret material — the nsec stays encrypted at rest as its own NIP-49 ncryptsec.
#[derive(Clone, Debug)]
pub struct HeldIdentitySummary {
/// Public key, lowercase hex (the stable id passed to `switch_nostr_identity`).
pub pubkey_hex: String,
/// Public key, bech32 npub (for display/copy).
pub npub: String,
/// Claimed @name without the domain, if this identity has one.
pub name: Option<String>,
/// Full NIP-05 identifier ("user@domain") when this identity has a claimed
/// name, for the transaction detail view.
pub nip05: Option<String>,
/// PRIVATE, app-only label the user set for this identity. Local metadata
/// only, never published. Display precedence: tag, else claimed name, else
/// truncated npub.
pub tag: Option<String>,
/// Convenience label from the index (claimed name's local part, or empty).
pub label: String,
/// Whether this is the currently active identity.
pub active: bool,
}
impl HeldIdentitySummary {
/// What the UI shows for this identity: the private tag if set, else the
/// claimed name (bare, no leading @), else the truncated npub.
pub fn display(&self) -> String {
self.tag
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| self.name.clone())
.unwrap_or_else(|| crate::gui::views::goblin::data::short_npub(&self.pubkey_hex))
}
}
/// Contains wallet instance, configuration and state, handles wallet commands.
#[derive(Clone)]
pub struct Wallet {
@@ -250,14 +287,17 @@ impl Wallet {
None
}
/// Create [`HTTPNodeClient`] from provided config.
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
/// Resolve the node API URL and secret for the connection saved in `config`.
/// Shared by [`Wallet::create_node_client`] (wallet open) and
/// [`Wallet::reconnect_node`] (live node switch) so both always target the
/// same node for a given config.
fn node_url_secret(config: &WalletConfig) -> (String, Option<String>) {
let integrated = || {
let api_url = format!("http://{}", NodeConfig::get_api_address());
let api_secret = NodeConfig::get_api_secret(true);
(api_url, api_secret)
};
let (node_api_url, node_secret) = if let Some(id) = config.ext_conn_id {
if let Some(id) = config.ext_conn_id {
if let Some(conn) = ConnectionsConfig::ext_conn(id) {
(conn.url, conn.secret)
} else {
@@ -265,7 +305,12 @@ impl Wallet {
}
} else {
integrated()
};
}
}
/// Create [`HTTPNodeClient`] from provided config.
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
let (node_api_url, node_secret) = Self::node_url_secret(config);
let client = if AppConfig::use_proxy() {
let socks = AppConfig::use_socks_proxy();
let url = if socks {
@@ -447,7 +492,7 @@ impl Wallet {
// 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) {
let legacy = match NostrIdentity::load(&nostr_dir) {
Some(identity) => identity,
None => match NostrIdentity::create_random(password) {
Ok((identity, _)) => {
@@ -463,20 +508,68 @@ impl Wallet {
}
},
};
let keys = match identity.unlock(password) {
Ok(keys) => keys,
Err(e) => {
error!("nostr: identity unlock failed: {e}");
// Adopt the held-identity index (migrating a pre-feature wallet's bare
// identity.json into it) and UNLOCK EVERY held identity now: with the wallet
// open, all identities' keys live in memory so the wallet listens for all of
// them at once (same trust boundary as the grin seed already in memory).
if HeldIdentities::load_or_migrate(&nostr_dir, &legacy).is_none() {
error!("nostr: held-identity index unreadable");
}
let (recv, active_hex) = match self.unlock_all_identities(&nostr_dir, password) {
Some(v) => v,
None => {
error!("nostr: no identity could be unlocked; nostr disabled this session");
return;
}
};
info!("nostr: identity ready: {}", identity.npub);
info!(
"nostr: {} identit(ies) unlocked; active {}",
recv.len(),
active_hex
);
let store = NostrStore::new(config.get_nostr_db_path());
let service = NostrService::new(keys, identity, nostr_config, store, nostr_dir);
let service = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
let mut w_nostr = self.nostr.write();
*w_nostr = Some(service);
}
/// Unlock EVERY held identity with the wallet password, returning the decrypted
/// set plus the active identity's pubkey hex. All held nsecs share the one
/// wallet password. Identities that fail to unlock (corrupt file) are skipped
/// with a warning; `None` only if not a single one could be unlocked. Used at
/// wallet-open and when rebuilding the service after add/import/rotate.
fn unlock_all_identities(
&self,
nostr_dir: &PathBuf,
password: &str,
) -> Option<(Vec<HeldIdentityKeys>, String)> {
let index = HeldIdentities::load(nostr_dir)?;
let mut recv = Vec::new();
for entry in &index.identities {
let Some(identity) = entry.load(nostr_dir) else {
warn!("nostr: identity file unreadable: {}", entry.path);
continue;
};
match identity.unlock(password) {
Ok(keys) => recv.push(HeldIdentityKeys { keys, identity }),
Err(e) => warn!("nostr: identity {} failed to unlock: {e}", entry.pubkey),
}
}
if recv.is_empty() {
return None;
}
// Prefer the recorded active pointer; fall back to the first that unlocked.
let active_hex = if recv
.iter()
.any(|h| h.identity.pubkey_hex().as_deref() == Some(index.active.as_str()))
{
index.active.clone()
} else {
recv[0].identity.pubkey_hex().unwrap_or_default()
};
Some((recv, active_hex))
}
/// Get the nostr service when available.
pub fn nostr_service(&self) -> Option<Arc<NostrService>> {
let r_nostr = self.nostr.read();
@@ -521,7 +614,7 @@ impl Wallet {
.map_err(|_| "Wrong password".to_string())?;
// Generate the replacement identity.
let (mut new_identity, new_keys) = NostrIdentity::create_random(&password)
let (mut new_identity, _new_keys) = NostrIdentity::create_random(&password)
.map_err(|e| format!("key generation failed: {e}"))?;
// Release the username first (the server also deletes its avatar);
@@ -561,7 +654,13 @@ impl Wallet {
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);
// Rebuild the service holding ALL held identities (the primary entry now
// resolves to the new identity), with the new one active.
let (recv, _) = self
.unlock_all_identities(&nostr_dir, &password)
.ok_or_else(|| "identity unlock failed".to_string())?;
let active_hex = new_identity.pubkey_hex().unwrap_or_default();
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
@@ -609,6 +708,7 @@ impl Wallet {
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else if input.starts_with('{') {
// Legacy plaintext identity-backup JSON (pre-.backup-file): decrypt
@@ -626,6 +726,7 @@ impl Wallet {
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else {
NostrIdentity::create_imported(input, &password)
@@ -650,7 +751,14 @@ impl Wallet {
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);
// Rebuild the service holding ALL held identities, with the imported one
// active (the primary entry now resolves to it).
let _ = new_keys;
let (recv, _) = self
.unlock_all_identities(&nostr_dir, &password)
.ok_or_else(|| "identity unlock failed".to_string())?;
let active_hex = new_identity.pubkey_hex().unwrap_or_default();
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
@@ -675,6 +783,324 @@ impl Wallet {
.map_err(|e| format!("backup failed: {e}"))
}
// ── Held nostr identities (one wallet, one balance, many front doors) ──────
//
// One grin seed / one balance, but the wallet can HOLD several nostr
// identities and present a different one at will. Exactly one is ACTIVE: it
// drives the single live gift-wrap subscription and all display, and every
// identity redeems into the SAME shared grin balance. Only the active nsec is
// ever decrypted into memory; the rest rest as ncryptsec on disk. Switching is
// mechanically identical to `rotate_nostr_identity` (stop the service, rebuild
// it on the target key, restart), plus a per-identity catch-up so payments that
// arrived while an identity was dormant are redeemed on switch-in.
/// The held identities for the identity switcher (no secrets). Empty if nostr
/// is disabled/not running.
pub fn nostr_identities(&self) -> Vec<HeldIdentitySummary> {
let nostr_dir = self.get_config().get_nostr_path();
let Some(index) = HeldIdentities::load(&nostr_dir) else {
return Vec::new();
};
index
.identities
.iter()
.filter_map(|entry| {
let identity = entry.load(&nostr_dir)?;
let name = identity
.nip05
.as_deref()
.and_then(|n| n.split('@').next())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Some(HeldIdentitySummary {
pubkey_hex: entry.pubkey.clone(),
npub: identity.npub.clone(),
name,
nip05: identity.nip05.clone(),
tag: identity.private_tag.clone(),
label: entry.label.clone(),
active: entry.pubkey == index.active,
})
})
.collect()
}
/// The active identity's pubkey (hex), or `None` if nostr is not running.
pub fn active_nostr_pubkey(&self) -> Option<String> {
self.nostr_service().map(|s| s.public_key().to_hex())
}
/// Verify a candidate wallet password against the active nostr identity
/// (cheap, in-memory NIP-49 unlock). Lets the identity password modal reject a
/// wrong password up front — before spawning an add/switch worker — the way the
/// wallet-open modal does. `false` when nostr is not running.
pub fn verify_nostr_password(&self, password: &str) -> bool {
self.nostr_service()
.map(|s| s.identity.read().unlock(password).is_ok())
.unwrap_or(false)
}
/// Add a nostr identity to this wallet WITHOUT switching to it: generate a
/// fresh random nsec (`import` is `None`) or import an existing one (`import`
/// is `Some(nsec)`). The new key is encrypted under the wallet password like
/// every held identity, then recorded in the index. Returns the new npub. The
/// caller may follow with `switch_nostr_identity` to make it active.
///
/// The wallet password is proven against the CURRENT identity first, so a
/// wrong password can neither add a mis-encrypted key nor disturb anything.
pub fn add_nostr_identity(
&self,
import: Option<String>,
password: String,
) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
// Prove the wallet password against the current identity before writing a
// new key, so all held identities share the one wallet password.
svc.identity
.read()
.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
// The import blob may be a bare nsec OR a sealed GOBLIN-*.backup file
// (reusing the same open path as `import_nostr_identity`). Either becomes a
// held identity re-encrypted under THIS wallet's password.
let (identity, _keys) = match import {
Some(blob) => {
let blob = blob.trim();
if NostrIdentity::is_encrypted_backup(blob) {
// A .backup: open it (same wallet password, since a backup made
// by this wallet is sealed under it), then re-encrypt under the
// wallet password and restore its name/history.
let (backup, keys) = NostrIdentity::from_encrypted_backup(blob, &password)
.map_err(|_| {
"Couldn't open the backup — it may have been made under a \
different wallet password."
.to_string()
})?;
let mut ident =
NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
.map_err(|e| format!("re-encryption failed: {e}"))?;
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else {
NostrIdentity::create_imported(blob, &password)
.map_err(|_| "Invalid nsec".to_string())?
}
}
None => NostrIdentity::create_random(&password)
.map_err(|e| format!("key generation failed: {e}"))?,
};
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
let mut index = HeldIdentities::load(&nostr_dir)
.ok_or_else(|| "identity index unavailable".to_string())?;
index
.add(&nostr_dir, &identity)
.map_err(|e| e.to_string())?;
let new_npub = identity.npub.clone();
info!("nostr: added held identity {new_npub}");
// Bring the new identity ONLINE: rebuild the service holding ALL identities
// (including the one just added) so it starts listening immediately. The
// active identity is unchanged — adding never switches.
let active_hex = svc.public_key().to_hex();
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
let store = NostrStore::new(config.get_nostr_db_path());
let (recv, _) = self
.unlock_all_identities(&nostr_dir, &password)
.ok_or_else(|| "identity unlock failed".to_string())?;
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
{
let mut w_nostr = self.nostr.write();
*w_nostr = Some(new_svc.clone());
}
new_svc.start(self.clone());
Ok(new_npub)
}
/// INSTANT identity switch: a purely-local change of which held identity is
/// presented and used for sending. Every held identity is already unlocked and
/// already listening, so there is NO password prompt, NO service teardown, and
/// NO catch-up — payments were never missed. Just re-points the running
/// service's active keys/identity and persists the active pointer. Returns the
/// target npub.
pub fn switch_nostr_identity(&self, target_hex: String) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
// Already active? Nothing to do.
if svc.public_key().to_hex() == target_hex {
return Ok(svc.npub());
}
// Re-point the active identity in memory (the target is already unlocked and
// listening); fails only if it isn't a held identity of this wallet.
if !svc.set_active_by_pubkey(&target_hex) {
return Err("identity not held by this wallet".to_string());
}
// Persist the active pointer so the next open lands on it too. The legacy
// identity.json is never overwritten (an older build still opens on #1).
let nostr_dir = self.get_config().get_nostr_path();
if let Some(mut index) = HeldIdentities::load(&nostr_dir) {
let _ = index.set_active(&nostr_dir, &target_hex);
}
let new_npub = svc.npub();
info!("nostr: switched active identity to {}", new_npub);
Ok(new_npub)
}
/// Set (or clear, with an empty string) a held identity's PRIVATE tag — the
/// local, app-only name the user gives it. Persisted in its 0600 identity
/// file (and thus inside future sealed .backups) and updated in the running
/// service so the switcher re-renders immediately. NEVER published to nostr.
/// Local metadata only, so no password is required (the ncryptsec is untouched).
pub fn rename_nostr_identity(&self, target_hex: String, tag: String) -> Result<(), String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
let nostr_dir = self.get_config().get_nostr_path();
let index = HeldIdentities::load(&nostr_dir)
.ok_or_else(|| "identity index unavailable".to_string())?;
let entry = index
.entry(&target_hex)
.ok_or_else(|| "identity not held by this wallet".to_string())?;
let mut identity = entry
.load(&nostr_dir)
.ok_or_else(|| "identity file unreadable".to_string())?;
let tag = tag.trim().to_string();
let tag = if tag.is_empty() { None } else { Some(tag) };
identity.private_tag = tag.clone();
identity
.save_at(&entry.abs_path(&nostr_dir))
.map_err(|e| format!("save failed: {e}"))?;
svc.set_private_tag(&target_hex, tag);
Ok(())
}
/// PERMANENTLY delete a held identity: drop it from the held-identity index,
/// delete its on-disk encrypted file, and rebuild the running service without
/// it (so its pubkey leaves the multi-pubkey gift-wrap subscription and its key
/// leaves the in-memory set). The one shared grin balance and every other
/// identity are untouched. Unrecoverable unless its nsec/.backup was saved.
///
/// Edge cases:
/// - Refuses to delete the LAST identity (a wallet must keep >= 1).
/// - Deleting the ACTIVE identity first switches active to a survivor.
/// - Deleting the legacy primary (the `identity.json` entry, which `init_nostr`
/// falls back to and an older build opens) PROMOTES a survivor into
/// `identity.json` so that fallback still resolves — never leaving a hole
/// that would spawn a fresh identity on next open.
pub fn delete_nostr_identity(
&self,
target_hex: String,
password: String,
) -> Result<(), String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
if !self.verify_nostr_password(&password) {
return Err("Wrong password".to_string());
}
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
let mut index = HeldIdentities::load(&nostr_dir)
.ok_or_else(|| "identity index unavailable".to_string())?;
// (a) Never delete the only identity.
if index.len() <= 1 {
return Err("You must keep at least one identity".to_string());
}
let entry = index
.entry(&target_hex)
.cloned()
.ok_or_else(|| "identity not held by this wallet".to_string())?;
// A survivor to take over the active pointer / primary role.
let survivor = index
.identities
.iter()
.find(|e| e.pubkey != target_hex)
.map(|e| e.pubkey.clone())
.ok_or_else(|| "no other identity".to_string())?;
// (c) Deleting the legacy primary: promote the survivor into identity.json
// so init_nostr's fallback/rollback anchor still resolves. Otherwise just
// delete the target's own file.
if entry.path == "identity.json" {
let survivor_entry = index
.entry(&survivor)
.cloned()
.ok_or_else(|| "survivor missing".to_string())?;
let survivor_id = survivor_entry
.load(&nostr_dir)
.ok_or_else(|| "survivor unreadable".to_string())?;
// Overwrite identity.json with the survivor (its old content = the
// deleted key is gone), and repoint the survivor entry to identity.json.
survivor_id
.save(&nostr_dir)
.map_err(|e| format!("promote failed: {e}"))?;
if survivor_entry.path != "identity.json" {
let old_abs = survivor_entry.abs_path(&nostr_dir);
for e in index.identities.iter_mut() {
if e.pubkey == survivor {
e.path = "identity.json".to_string();
}
}
let _ = std::fs::remove_file(old_abs);
}
} else {
let _ = std::fs::remove_file(entry.abs_path(&nostr_dir));
}
// Remove the target from the index (entries + order); repoint active if it
// was the deleted one.
index.identities.retain(|e| e.pubkey != target_hex);
index.order.retain(|h| h != &target_hex);
if index.active == target_hex {
index.active = survivor.clone();
}
index
.save(&nostr_dir)
.map_err(|e| format!("index save failed: {e}"))?;
// (b) If the deleted identity was active, the survivor becomes active.
let active_hex = if svc.public_key().to_hex() == target_hex {
survivor
} else {
svc.public_key().to_hex()
};
// Rebuild the service WITHOUT the deleted identity: unlock_all_identities now
// excludes it, so it leaves both the subscription and the in-memory key set.
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
let store = NostrStore::new(config.get_nostr_db_path());
let (recv, _) = self
.unlock_all_identities(&nostr_dir, &password)
.ok_or_else(|| "identity unlock failed".to_string())?;
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let new_svc = NostrService::new(recv, &active_hex, 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: deleted held identity {target_hex}");
Ok(())
}
/// Get keychain mask [`SecretKey`].
pub fn keychain_mask(&self) -> Option<SecretKey> {
let r_key = self.keychain_mask.read();
@@ -780,6 +1206,57 @@ impl Wallet {
w_config.save();
}
/// Apply the saved connection to the RUNNING wallet session immediately.
///
/// A node selection persisted by [`Wallet::update_connection`] only reaches
/// an open wallet on the next open, because the node client is baked into the
/// wallet instance at open time. This swaps the live node client's URL and
/// secret in place (no close/reopen, so the password is not needed again),
/// updates the runtime connection so the UI reflects the switch at once, then
/// wakes the sync thread to refresh the balance against the new node.
///
/// If the new node is unreachable the sync simply errors and the honest
/// "can't reach node" state surfaces (with the last-known balance retained),
/// so the user is never stranded on a silent zero.
pub fn reconnect_node(&self) {
// Reflect the switch in the runtime connection right away so the picker
// and status cards stop showing the previous node.
let conn = self.get_config().connection();
{
let mut w_conn = self.connection.write();
*w_conn = conn.clone();
}
// Clear the stale sync error/progress so the surface shows the honest
// "updating" state while the new node is contacted.
self.set_sync_error(false);
self.reset_sync_attempts();
self.info_sync_progress.store(0, Ordering::Relaxed);
// Swap the live node client, then kick a fresh sync. Done on a worker
// thread: locking the instance can briefly contend with an in-flight
// sync, and this is called from the UI thread.
let wallet = self.clone();
thread::spawn(move || {
let has_instance = { wallet.instance.read().is_some() };
if wallet.is_open() && has_instance {
let (url, secret) = Self::node_url_secret(&wallet.get_config());
let instance = { wallet.instance.read().clone().unwrap() };
let mut wallet_lock = instance.lock();
if let Ok(lc) = wallet_lock.lc_provider() {
if let Ok(backend) = lc.wallet_inst() {
let client = backend.w2n_client();
client.set_node_url(&url);
client.set_node_api_secret(secret);
}
}
}
// Resume node polling (may be paused on Android) and wake the sync
// thread so the balance refreshes against the new node now.
wallet.resume_node_polling();
wallet.sync();
});
}
/// Get external connection URL applied to [`WalletInstance`]
/// after wallet opening if sync is running or get it from config.
pub fn get_current_connection(&self) -> ConnectionMethod {
@@ -1756,11 +2233,30 @@ impl Wallet {
/// Change wallet password.
pub fn change_password(&self, old: String, new: String) -> Result<(), Error> {
let r_inst = self.instance.as_ref().read();
let instance = r_inst.clone().unwrap();
let mut wallet_lock = instance.lock();
let lc = wallet_lock.lc_provider()?;
lc.change_password(None, ZeroingString::from(old), ZeroingString::from(new))
{
let r_inst = self.instance.as_ref().read();
let instance = r_inst.clone().unwrap();
let mut wallet_lock = instance.lock();
let lc = wallet_lock.lc_provider()?;
lc.change_password(
None,
ZeroingString::from(old.clone()),
ZeroingString::from(new.clone()),
)?;
}
// The grin seed password changed; re-encrypt EVERY held nostr identity's
// ncryptsec from old to new through the same NIP-49 path, so all front
// doors follow the one wallet password. Best-effort and non-fatal: a
// failure is logged (never swallowed as success), and the grin password
// change already committed above. Takes effect for the running service on
// the next wallet open (it reloads the active identity from disk).
let nostr_dir = self.get_config().get_nostr_path();
if let Some(index) = HeldIdentities::load(&nostr_dir)
&& let Err(e) = index.reencrypt_all(&nostr_dir, &old, &new)
{
error!("nostr: re-encrypting held identities after password change: {e}");
}
Ok(())
}
/// Initiate wallet repair by scanning its outputs.
@@ -1871,6 +2367,23 @@ impl Wallet {
Ok(proof)
}
/// Retrieve a finalized send's payment proof as the `(json, kernel_excess_hex)`
/// pair the proof-on-request delivery needs (frozen contract 4.3.2). `json` is
/// the owner API's verbatim serialization of the proof
/// (`{amount, excess, recipient_address, recipient_sig, sender_address,
/// sender_sig}`); `kernel_excess_hex` is a convenience copy of the excess so
/// the watcher can query the chain before parsing the proof. `None` when no
/// proof exists yet (e.g. a proof-less send, or called before finalize).
pub fn payment_proof_delivery(&self, slate_id: Uuid) -> Option<(String, String)> {
let proof = self
.get_payment_proof(None, Some(slate_id))
.ok()
.flatten()?;
let json = serde_json::to_string(&proof).ok()?;
let kernel_hex = proof.excess.to_hex();
Some((json, kernel_hex))
}
/// Verify payment proof.
fn verify_payment_proof(&self, proof: &PaymentProof) -> Result<(u32, bool, bool), Error> {
let r_inst = self.instance.as_ref().read();
@@ -2422,19 +2935,31 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
w.on_tx_error(*id, Some(e));
}
},
WalletTask::NostrSend(a, receiver, note, relay_hints) => {
WalletTask::NostrSend(a, receiver, note, relay_hints, proof, order, notify) => {
let Some(service) = w.nostr_service() else {
error!("nostr send: service not available");
return;
};
w.send_creating.store(true, Ordering::Relaxed);
service.set_send_phase(crate::nostr::send_phase::WORKING);
match w.send(*a, None) {
// Proof-on-request (frozen contract W2): when the payment context
// carries a `proof=` slatepack address, re-parse it authoritatively
// here (a second, fail-closed gate after the URI parser's shape check)
// and thread it as the native `payment_proof_recipient_address`. Absent
// or unparseable → `None`, a normal proof-less send. This is the ONLY
// path that sets a proof recipient over Nostr.
let proof_addr: Option<SlatepackAddress> = proof
.as_deref()
.and_then(|p| SlatepackAddress::try_from(p.trim()).ok());
let proof_mode = proof_addr.is_some();
match w.send(*a, proof_addr) {
Ok(s) => {
sync_wallet_data(&w, false);
let now = crate::nostr::unix_time();
// Record intent BEFORE the network dispatch so a crash
// is recovered by the service reconcile pass.
// is recovered by the service reconcile pass. The proof context
// (order handle + watcher npub + amount) is persisted now so a
// crash between send and finalize loses nothing.
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
ver: 1,
slate_id: s.id.to_string(),
@@ -2446,6 +2971,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
received_rumor_id: None,
created_at: now,
updated_at: now,
proof_mode,
proof_order: if proof_mode { order.clone() } else { None },
proof_notify: if proof_mode { notify.clone() } else { None },
proof_amount: if proof_mode { Some(*a) } else { None },
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.send_creating.store(false, Ordering::Relaxed);
@@ -2455,9 +2987,34 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
.await
{
Ok(event_id) => {
// Proof-on-request (frozen contract 4.3.1): publish the
// plain "payment sent" receipt HERE at S1 dispatch, the
// same moment the payment envelope was accepted by a relay
// and the wallet UI flips to "sent", not at finalize. This
// closes the buyer's double-send window: an offline merchant
// leaves finalize hours away, and until this receipt lands
// the order page still shows a scannable QR for a payment
// already made. The encrypted PROOF stays at finalize (it
// does not exist before then). On failure we leave
// receipt_sent=false so the reconcile pass retries it.
let mut receipt_sent = false;
if crate::nostr::receipt_due_at_dispatch(
proof_mode,
order.as_deref(),
) {
let ord = order.as_deref().unwrap_or_default();
match service.publish_receipt_sent(ord, *a).await {
Ok(()) => receipt_sent = true,
Err(e) => log::warn!(
"nostr: dispatch receipt publish failed for {}: {e}",
s.id
),
}
}
if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) {
meta.status = crate::nostr::NostrSendStatus::AwaitingS2;
meta.sent_event_id = Some(event_id);
meta.receipt_sent = receipt_sent;
meta.updated_at = crate::nostr::unix_time();
service.store.save_tx_meta(&meta);
}
@@ -2553,6 +3110,15 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
received_rumor_id: None,
created_at: now,
updated_at: now,
// Grin's invoice flow carries no payment proofs (Fact 3), so a
// request we issued never enters proof mode.
proof_mode: false,
proof_order: None,
proof_notify: None,
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
let tx = w.retrieve_tx_by_id(None, Some(s.id));
w.invoice_creating.store(false, Ordering::Relaxed);
@@ -2798,6 +3364,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
received_rumor_id: Some(request.rumor_id.clone()),
created_at: now,
updated_at: now,
proof_mode: false,
proof_order: None,
proof_notify: None,
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
match service
.send_payment_dm(&request.npub, &text, None, &[])
@@ -2842,6 +3415,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
received_rumor_id: Some(request.rumor_id.clone()),
created_at: now,
updated_at: now,
proof_mode: false,
proof_order: None,
proof_notify: None,
proof_amount: None,
proof_delivered: false,
receipt_sent: false,
recipient_pubkey: service.public_key().to_hex(),
});
match service
.send_payment_dm(&request.npub, &text, None, &[])
-315
View File
@@ -1,315 +0,0 @@
// COLD-CONNECT TIMING HARNESS (Build 98 latency investigation). Not part of the
// shipped test suite — it exists to MEASURE, on this machine, how long the real
// Nym transport takes to go from a cold start to "transport ready" (a relay
// connected+subscribed on the current tunnel generation), broken down per stage,
// and to detect the exit-reselect LOOP (watchdog condemning a healthy exit
// because relays were slow to connect through lossy mix-dns).
//
// It drives the SAME `NymWebSocketTransport` the app ships with, over the SAME
// default relay set, arming the relay-consumer governance exactly like
// `client.rs::run_service`, so the watchdog behaves as it does in the app.
//
// Run BEFORE (reproduce the old UDP mix-dns + legacy-watchdog loop) vs AFTER
// (DoT-over-mixnet + robust watchdog), same binary, via env toggles:
//
// # BEFORE (old behavior): UDP mix-dns on + legacy watchdog
// GOBLIN_DNS_UDP=1 GOBLIN_LEGACY_WATCHDOG=1 \
// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1
//
// # AFTER (shipped default): DoT-over-mixnet + robust watchdog
// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1
//
// Grep the captured log for lines tagged "[timing]" and "[TIMELINE]".
use std::time::{Duration, Instant};
use grim::nym::NymWebSocketTransport;
use nostr_sdk::prelude::*;
/// The app's default relay set (src/nostr/relays.rs).
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.goblin.st",
"wss://relay.damus.io",
"wss://nos.lol",
];
/// Overall budget for the measured window. Long enough to observe several
/// reselect cycles if the loop is present (BEFORE), short enough to keep the run
/// bounded. Overridable with GOBLIN_TIMING_WINDOW_SECS.
fn window() -> Duration {
let secs = std::env::var("GOBLIN_TIMING_WINDOW_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(180);
Duration::from_secs(secs)
}
fn init() {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = env_logger::builder()
.is_test(false)
.format_timestamp_millis() // absolute wall-clock ms on every line
.filter_level(log::LevelFilter::Info)
.filter_module("grim::nym", log::LevelFilter::Debug)
.parse_default_env()
.try_init();
}
/// One cold-connect measurement: bring the tunnel up, dial the default relays
/// with the relay-consumer governance armed (as the app does), and record the
/// per-stage timeline + any exit reselects over the window.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn cold_connect_timing() {
init();
let mode_dns = if std::env::var("GOBLIN_DNS_UDP").as_deref() == Ok("1") {
"udp-dns(legacy)"
} else {
"dot-dns"
};
let mode_wd = if std::env::var("GOBLIN_LEGACY_WATCHDOG").as_deref() == Ok("1") {
"legacy-watchdog"
} else {
"robust-watchdog"
};
eprintln!("[TIMELINE] === cold_connect_timing START (dns={mode_dns}, watchdog={mode_wd}) ===");
let t0 = Instant::now();
// Stage A: mixnet tunnel bootstrap (select exit + build + liveness probe).
grim::nym::warm_up();
let mut tunnel_ready_ms = None;
for _ in 0..480 {
if grim::nym::is_ready() {
tunnel_ready_ms = Some(t0.elapsed().as_millis());
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let gen0 = grim::nym::tunnel_generation();
match tunnel_ready_ms {
Some(ms) => eprintln!("[TIMELINE] A. tunnel READY at t+{ms}ms (gen {gen0})"),
None => {
eprintln!(
"[TIMELINE] A. tunnel NEVER became ready within {}s — mixnet bootstrap failed on this machine",
t0.elapsed().as_secs()
);
panic!("mixnet never bootstrapped; cannot measure connect timing");
}
}
// Stage B: dial the default relays over the mixnet, exactly like run_service:
// arm relay-consumer governance so the watchdog treats a relay-dead exit as
// condemnable (this is what produces the loop in the BEFORE case).
grim::nym::set_relay_consumer(true);
let client = Client::builder()
.signer(Keys::generate())
.websocket_transport(NymWebSocketTransport)
.build();
for r in DEFAULT_RELAYS {
let _ = client.add_relay(*r).await;
}
let dial_gen = grim::nym::tunnel_generation();
let connect_started = Instant::now();
client.connect().await;
// Report relay-live on the current generation as soon as a relay connects,
// exactly like run_service's fast-report task — this is what closes the
// watchdog's readiness window in the healthy case.
let mut first_relay_ms = None;
let mut transport_ready_ms = None;
let mut reselects = 0u64;
let mut last_gen = dial_gen;
let mut gen_events: Vec<(u128, u64)> = vec![(t0.elapsed().as_millis(), dial_gen)];
loop {
if connect_started.elapsed() > window() {
break;
}
let gen_now = grim::nym::tunnel_generation();
if gen_now != last_gen {
reselects += 1;
gen_events.push((t0.elapsed().as_millis(), gen_now));
eprintln!(
"[TIMELINE] !! exit RESELECT #{reselects}: gen {last_gen} -> {gen_now} at t+{}ms (the loop)",
t0.elapsed().as_millis()
);
last_gen = gen_now;
// Re-dial on the fresh exit like the status loop does.
client.disconnect().await;
for r in DEFAULT_RELAYS {
let _ = client.add_relay(*r).await;
}
client.connect().await;
}
let connected = client
.relays()
.await
.values()
.any(|r| r.status() == RelayStatus::Connected);
if connected {
// Feed liveness on the CURRENT generation (what run_service does).
grim::nym::report_relay_live(grim::nym::tunnel_generation());
if first_relay_ms.is_none() {
first_relay_ms = Some(t0.elapsed().as_millis());
eprintln!(
"[TIMELINE] B. first relay CONNECTED at t+{}ms (~{}ms after connect())",
t0.elapsed().as_millis(),
connect_started.elapsed().as_millis()
);
}
} else if first_relay_ms.is_some() {
grim::nym::report_relay_down(grim::nym::tunnel_generation());
}
if grim::nym::transport_ready() && transport_ready_ms.is_none() {
transport_ready_ms = Some(t0.elapsed().as_millis());
eprintln!(
"[TIMELINE] C. TRANSPORT READY at t+{}ms (relay live on gen {})",
t0.elapsed().as_millis(),
grim::nym::tunnel_generation()
);
// Once ready, watch a little longer to confirm it STAYS ready (no loop),
// then finish early rather than burn the whole window.
let settle_until = Instant::now() + Duration::from_secs(20);
let mut stayed = true;
while Instant::now() < settle_until {
tokio::time::sleep(Duration::from_millis(500)).await;
if grim::nym::tunnel_generation() != last_gen {
stayed = false; // a reselect during settle — loop still live
break;
}
}
if stayed {
eprintln!("[TIMELINE] transport stayed ready for 20s — no loop");
break;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
grim::nym::set_relay_consumer(false);
client.disconnect().await;
eprintln!("[TIMELINE] === SUMMARY (dns={mode_dns}, watchdog={mode_wd}) ===");
eprintln!(
"[TIMELINE] tunnel_ready: {}",
tunnel_ready_ms
.map(|m| format!("{m}ms"))
.unwrap_or("n/a".into())
);
eprintln!(
"[TIMELINE] first_relay: {}",
first_relay_ms
.map(|m| format!("{m}ms"))
.unwrap_or("NEVER".into())
);
eprintln!(
"[TIMELINE] transport_ready: {}",
transport_ready_ms
.map(|m| format!("{m}ms"))
.unwrap_or("NEVER".into())
);
eprintln!("[TIMELINE] exit reselects during window: {reselects} (0 = no loop)");
eprintln!("[TIMELINE] generation timeline: {gen_events:?}");
eprintln!("[TIMELINE] === cold_connect_timing END ===");
// The measurement itself shouldn't fail the suite; it's diagnostic. But a
// total failure to ever connect is worth surfacing loudly.
assert!(
first_relay_ms.is_some(),
"no relay ever connected within the window"
);
}
/// Prove DNS resolves END TO END over the tunnel (DoT, or DoH fallback) — no
/// clearnet. Loops across exit reselects (the mixnet hands out the odd dead
/// exit) until a healthy exit resolves a real relay host, then asserts success.
/// Watch the log for "dot-dns: resolved" / "doh-dns: resolved".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn dns_resolve_smoke() {
init();
grim::nym::warm_up();
for _ in 0..480 {
if grim::nym::is_ready() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let deadline = Instant::now() + Duration::from_secs(150);
let mut ok = false;
while Instant::now() < deadline {
if let Some(tunnel) = grim::nym::nymproc::tunnel() {
for host in ["relay.damus.io", "goblin.st", "api.coingecko.com"] {
let t = Instant::now();
match grim::nym::dns::resolve(&tunnel, host, 443).await {
Some(addr) => {
eprintln!(
"[DNSPROOF] resolved {host} -> {addr} in {}ms OVER THE TUNNEL",
t.elapsed().as_millis()
);
ok = true;
}
None => eprintln!(
"[DNSPROOF] {host} unresolved on this exit ({}ms) — waiting for a better exit",
t.elapsed().as_millis()
),
}
}
if ok {
break;
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
assert!(
ok,
"DNS never resolved over the tunnel within the window (all exits bad?)"
);
}
/// Probe whether the Nym IPR exit policy lets us open TCP to the DoT port (853)
/// through the tunnel. 443 is the control (known-open — relays + HTTPS ride it).
/// Decides DoT-vs-DoH for the private DNS transport. Run:
/// cargo test --test connect_timing probe_dns_ports -- --ignored --nocapture --test-threads=1
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn probe_dns_ports() {
init();
grim::nym::warm_up();
let mut ready = false;
for _ in 0..480 {
if grim::nym::is_ready() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
assert!(ready, "tunnel never bootstrapped; cannot probe ports");
let tunnel = grim::nym::nymproc::tunnel().expect("tunnel up");
let targets = [
("cloudflare:853 (DoT)", "1.1.1.1:853"),
("quad9:853 (DoT)", "9.9.9.9:853"),
("cloudflare:443 (control)", "1.1.1.1:443"),
];
for (label, addr) in targets {
let sa: std::net::SocketAddr = addr.parse().unwrap();
let t = Instant::now();
match tokio::time::timeout(Duration::from_secs(12), tunnel.tcp_connect(sa)).await {
Ok(Ok(_)) => eprintln!(
"[PORTPROBE] {label} = CONNECTED in {}ms",
t.elapsed().as_millis()
),
Ok(Err(e)) => eprintln!(
"[PORTPROBE] {label} = REFUSED/ERR after {}ms: {e}",
t.elapsed().as_millis()
),
Err(_) => eprintln!(
"[PORTPROBE] {label} = TIMEOUT after {}ms",
t.elapsed().as_millis()
),
}
}
}
-454
View File
@@ -1,454 +0,0 @@
// End-to-end Nostr exchange test against the live Goblin relay.
//
// Proves the NIP-17 payment-message path: gift-wrap send, subscribe, unwrap,
// seal-author verification, subject tag, and Goblin slatepack extraction.
// Network-dependent — run explicitly:
// cargo test --test nostr_e2e -- --ignored --nocapture
use std::time::Duration;
use grim::nostr::protocol;
use nostr_sdk::prelude::*;
const RELAY: &str = "wss://nrelay.us-ea.st";
/// A small but valid-looking slatepack armor block for extraction testing.
const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \
ENDSLATEPACK.";
#[tokio::test]
#[ignore]
async fn nip17_slatepack_roundtrip() {
nip17_roundtrip_over(RELAY).await;
}
/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift
/// wraps transit a top public relay, not only the relay we run.
/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn nip17_roundtrip_damus() {
nip17_roundtrip_over("wss://relay.damus.io").await;
}
/// And over nos.lol, the other large public relay in DEFAULT_RELAYS.
/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn nip17_roundtrip_nos_lol() {
nip17_roundtrip_over("wss://nos.lol").await;
}
/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050
/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob
/// unwraps it, verifies the seal author, and extracts the slatepack + subject.
async fn nip17_roundtrip_over(relay: &str) {
let alice = Keys::generate();
let bob = Keys::generate();
println!("alice: {}", alice.public_key().to_bech32().unwrap());
println!("bob: {}", bob.public_key().to_bech32().unwrap());
// Bob's client: connect, advertise DM relays, subscribe to gift wraps.
let bob_client = Client::new(bob.clone());
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(2)).await;
// Publish Bob's kind-10050 DM relay list so senders find this relay.
let dm_relays = EventBuilder::new(Kind::InboxRelays, "")
.tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()]));
bob_client.send_event_builder(dm_relays).await.unwrap();
let filter = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400));
bob_client.subscribe(filter, None).await.unwrap();
// Alice's client: connect and send a NIP-17 payment DM to Bob.
let alice_client = Client::new(alice.clone());
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(2)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some("lunch :)"));
alice_client
.send_private_msg_to([relay], bob.public_key(), content, tags)
.await
.unwrap();
println!("alice sent gift-wrapped payment DM");
// Bob waits for the gift wrap, unwraps and validates it.
let mut notifications = bob_client.notifications();
let result = tokio::time::timeout(Duration::from_secs(30), async {
loop {
if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await {
if event.kind != Kind::GiftWrap {
continue;
}
let unwrapped = match bob_client.unwrap_gift_wrap(&event).await {
Ok(u) => u,
Err(_) => continue,
};
// Seal-author check (the NIP-17 invariant our ingest enforces).
assert_eq!(
unwrapped.rumor.pubkey, unwrapped.sender,
"rumor author must equal seal signer"
);
assert_eq!(unwrapped.sender, alice.public_key(), "sender must be Alice");
assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage);
return unwrapped;
}
}
})
.await
.expect("timed out waiting for gift wrap");
// The slatepack must round-trip intact, and the subject must survive.
let armor = protocol::extract_slatepack(&result.rumor.content)
.expect("slatepack must extract from rumor");
assert!(armor.starts_with("BEGINSLATEPACK."));
assert!(armor.ends_with("ENDSLATEPACK."));
let subject = protocol::extract_subject(&result.rumor.tags);
assert_eq!(subject.as_deref(), Some("lunch :)"));
println!("✓ NIP-17 slatepack roundtrip verified over {relay}");
bob_client.disconnect().await;
alice_client.disconnect().await;
}
/// Register a fresh name on goblin.st with a real NIP-98 signature, then
/// resolve it back — proves the live identity server end-to-end.
/// Run: cargo test --test nostr_e2e nip05 -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn nip05_registration_roundtrip() {
use base64::Engine;
use sha2::{Digest, Sha256};
use std::process::Command;
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
// Unique-ish name from the pubkey suffix (lowercase alnum).
let name = format!("t{}", &pubkey[..8]);
let server = "https://goblin.st";
let url = format!("{server}/api/v1/register");
let body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string();
// Build the NIP-98 kind-27235 auth event (same shape as the client).
let payload_hash = hex::encode(Sha256::digest(body.as_bytes()));
let event = EventBuilder::new(Kind::HttpAuth, "")
.tag(Tag::custom(TagKind::custom("u"), [url.clone()]))
.tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()]))
.tag(Tag::custom(TagKind::custom("payload"), [payload_hash]))
.sign_with_keys(&keys)
.unwrap();
let auth = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(event.as_json())
);
// POST the registration via curl (avoids pulling an HTTP client dep).
let out = Command::new("curl")
.args([
"-s",
"-X",
"POST",
&url,
"-H",
&format!("Authorization: {auth}"),
"-H",
"Content-Type: application/json",
"-d",
&body,
])
.output()
.expect("curl register");
let resp = String::from_utf8_lossy(&out.stdout);
println!("register response: {resp}");
assert!(
resp.contains("\"nip05\""),
"registration should return a nip05 identifier, got: {resp}"
);
assert!(resp.contains(&format!("{name}@goblin.st")));
// Resolve it back from the well-known endpoint.
let wk = Command::new("curl")
.args([
"-s",
&format!("{server}/.well-known/nostr.json?name={name}"),
])
.output()
.expect("curl well-known");
let wk_body = String::from_utf8_lossy(&wk.stdout);
println!("well-known response: {wk_body}");
let resolved = protocol_parse_pubkey(&wk_body, &name);
assert_eq!(resolved.as_deref(), Some(pubkey.as_str()));
// Clean up: release the test name.
let del_url = format!("{server}/api/v1/register/{name}");
let del_event = EventBuilder::new(Kind::HttpAuth, "")
.tag(Tag::custom(TagKind::custom("u"), [del_url.clone()]))
.tag(Tag::custom(
TagKind::custom("method"),
["DELETE".to_string()],
))
.sign_with_keys(&keys)
.unwrap();
let del_auth = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(del_event.as_json())
);
let _ = Command::new("curl")
.args([
"-s",
"-X",
"DELETE",
&del_url,
"-H",
&format!("Authorization: {del_auth}"),
])
.output();
println!("✓ NIP-05 registration + resolution verified on {server}");
}
/// Minimal well-known pubkey extractor for the test.
fn protocol_parse_pubkey(body: &str, name: &str) -> Option<String> {
let doc: serde_json::Value = serde_json::from_str(body).ok()?;
doc.get("names")?.get(name)?.as_str().map(|s| s.to_string())
}
/// Live avatar pipeline e2e against goblin.st: register → upload a processed
/// PNG (NIP-98 by the owner) → profile shows the hash → GET serves a 256px
/// PNG with the hardened headers → 6th change is rate-limited → release
/// purges both the name and its avatar.
/// Run: cargo test --test nostr_e2e avatar -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn avatar_upload_roundtrip() {
use base64::Engine;
use sha2::{Digest, Sha256};
use std::process::Command;
let server = "https://goblin.st";
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let name = format!("a{}", &pubkey[..8]);
let nip98 = |url: &str, method: &str, body: &[u8]| -> String {
let mut b = EventBuilder::new(Kind::HttpAuth, "")
.tag(Tag::custom(TagKind::custom("u"), [url.to_string()]))
.tag(Tag::custom(TagKind::custom("method"), [method.to_string()]));
if !body.is_empty() {
b = b.tag(Tag::custom(
TagKind::custom("payload"),
[hex::encode(Sha256::digest(body))],
));
}
let ev = b.sign_with_keys(&keys).unwrap();
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(ev.as_json())
)
};
// Register the name first.
let reg_url = format!("{server}/api/v1/register");
let reg_body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string();
let out = Command::new("curl")
.args([
"-s",
"-X",
"POST",
&reg_url,
"-H",
&format!(
"Authorization: {}",
nip98(&reg_url, "POST", reg_body.as_bytes())
),
"-H",
"Content-Type: application/json",
"-d",
&reg_body,
])
.output()
.expect("curl register");
assert!(
String::from_utf8_lossy(&out.stdout).contains("\"nip05\""),
"register failed: {}",
String::from_utf8_lossy(&out.stdout)
);
// Build a real PNG via the client pipeline (also strips metadata).
let raw = {
use ::image::{ImageEncoder, RgbaImage};
let img = RgbaImage::from_fn(640, 480, |x, y| {
::image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255])
});
let mut v = Vec::new();
::image::DynamicImage::ImageRgba8(img)
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
.unwrap();
v
};
let png = grim::nostr::avatar::process_avatar_bytes(&raw).expect("process");
let png_path = std::env::temp_dir().join(format!("{name}.png"));
std::fs::write(&png_path, &png).unwrap();
let av_url = format!("{server}/api/v1/avatar/{name}");
// Upload (raw bytes; payload hash over the PNG).
let out = Command::new("curl")
.args([
"-s",
"-X",
"POST",
&av_url,
"-H",
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
"-H",
"Content-Type: application/octet-stream",
"--data-binary",
&format!("@{}", png_path.display()),
])
.output()
.expect("curl upload");
let resp = String::from_utf8_lossy(&out.stdout);
println!("upload: {resp}");
let hash = serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from))
.expect("upload should return a hash");
// Profile exposes the hash.
let prof = Command::new("curl")
.args(["-s", &format!("{server}/api/v1/profile/{name}")])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&prof.stdout).contains(&hash),
"profile should carry the avatar hash"
);
// GET serves a 256px PNG with hardened headers.
let head = Command::new("curl")
.args(["-sI", &format!("{server}/api/v1/avatar/{hash}.png")])
.output()
.unwrap();
let head = String::from_utf8_lossy(&head.stdout).to_lowercase();
assert!(head.contains("content-type: image/png"), "headers: {head}");
assert!(head.contains("nosniff"), "missing nosniff: {head}");
assert!(
head.contains("immutable"),
"missing immutable cache: {head}"
);
let got = Command::new("curl")
.args(["-s", &format!("{server}/api/v1/avatar/{hash}.png")])
.output()
.unwrap();
assert!(got.stdout.starts_with(&[0x89, b'P', b'N', b'G']));
let served = ::image::load_from_memory(&got.stdout).expect("served bytes decode");
assert_eq!((served.width(), served.height()), (256, 256));
// Daily limit: 4 more changes succeed (1 done = 5 total), the 6th is 429.
for i in 0..4 {
// Vary the pixels so each upload is a distinct hash.
let raw = {
use ::image::{ImageEncoder, RgbaImage};
let img = RgbaImage::from_pixel(64, 64, ::image::Rgba([i as u8 * 40, 10, 10, 255]));
let mut v = Vec::new();
::image::DynamicImage::ImageRgba8(img)
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
.unwrap();
v
};
let png = grim::nostr::avatar::process_avatar_bytes(&raw).unwrap();
std::fs::write(&png_path, &png).unwrap();
let out = Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"POST",
&av_url,
"-H",
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
"--data-binary",
&format!("@{}", png_path.display()),
])
.output()
.unwrap();
println!("change {}: {}", i + 2, String::from_utf8_lossy(&out.stdout));
}
// 6th change → 429.
let png = grim::nostr::avatar::process_avatar_bytes(&{
use ::image::{ImageEncoder, RgbaImage};
let img = RgbaImage::from_pixel(48, 48, ::image::Rgba([200, 200, 0, 255]));
let mut v = Vec::new();
::image::DynamicImage::ImageRgba8(img)
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
.unwrap();
v
})
.unwrap();
std::fs::write(&png_path, &png).unwrap();
let out = Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"POST",
&av_url,
"-H",
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
"--data-binary",
&format!("@{}", png_path.display()),
])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"429",
"6th avatar change in 24h must be rate-limited"
);
// Release the name → avatar purged.
let del_url = format!("{server}/api/v1/register/{name}");
let _ = Command::new("curl")
.args([
"-s",
"-X",
"DELETE",
&del_url,
"-H",
&format!("Authorization: {}", nip98(&del_url, "DELETE", &[])),
])
.output();
let after = Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
&format!("{server}/api/v1/profile/{name}"),
])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&after.stdout),
"404",
"profile should 404 after release"
);
let _ = std::fs::remove_file(&png_path);
println!("✓ avatar upload/serve/limit/release-purge verified on {server}");
}
-517
View File
@@ -1,517 +0,0 @@
// THROWAWAY transport-validation harness (G14). Not part of the shipped test
// suite — it exists to prove the migrated transport (in-process smolmix mixnet
// tunnel + mandatory mix-dns) actually DELIVERS NIP-17 gift wraps over real
// relays, using the SAME `NymWebSocketTransport` the app now ships with as its
// only transport. Unlike tests/nostr_e2e.rs (which uses the default clearnet
// nostr-sdk client), every websocket here is dialed through the mixnet and
// every relay hostname is resolved over the tunnel (mix-dns).
//
// Network + mixnet dependent — run explicitly:
// cargo test --test xrelay_smoke -- --ignored --nocapture --test-threads=1
//
// What to look for in the logs (proof, not just green):
// * "nym: tunnel ready ... (allocated ip ..., probe ok)" — tunnel up, exit auto-selected
// * "mix-dns: resolved <host> -> <ip> ..." — each relay resolved OVER the tunnel
// * "v3 delivered + decrypted" — a real 0x03 wrap crossed the wire
use std::time::{Duration, Instant};
use grim::nostr::{protocol, wrapv3};
use grim::nym::NymWebSocketTransport;
use nostr_sdk::prelude::*;
/// A small but valid-looking slatepack armor block (same fixture the in-tree
/// wrapv3 unit test uses), so extraction is exercised end to end.
const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \
ENDSLATEPACK.";
const SUBJECT: &str = "lunch :)";
/// Install the ring crypto provider (the app does this in `grim::start()`; a
/// test binary must do it itself or the first TLS handshake panics — Build
/// 65/66 rule) and route logs to stdout at debug so the tunnel + mix-dns lines
/// are visible under --nocapture. Both are idempotent.
fn init() {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = env_logger::builder()
.is_test(false)
.filter_level(log::LevelFilter::Info)
.filter_module("grim::nym", log::LevelFilter::Debug)
.parse_default_env() // honor RUST_LOG if set
.try_init();
}
/// Bring the shared in-process mixnet tunnel up before any relay dial, exactly
/// like the real service loop (client.rs `run_service`). Panics if the mixnet
/// never bootstraps — that IS the blocker the on-chain test would hit.
async fn ensure_tunnel() {
grim::nym::warm_up();
let started = Instant::now();
for _ in 0..240 {
if grim::nym::is_ready() {
eprintln!(
"[harness] mixnet tunnel ready after ~{}ms",
started.elapsed().as_millis()
);
return;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
panic!(
"BLOCKER: mixnet tunnel never became ready after {}s — smolmix bootstrap failed \
(see nym: log lines above). On-chain payment test cannot proceed.",
started.elapsed().as_secs()
);
}
/// Build a Goblin-style client for `keys` over the real mixnet transport —
/// byte-for-byte the builder from `src/nostr/client.rs::run_service`.
fn goblin_client(keys: &Keys) -> Client {
Client::builder()
.signer(keys.clone())
.websocket_transport(NymWebSocketTransport)
.build()
}
/// Advertise a kind-10050 DM-relay list for `who` pointing at `inbox_relays`,
/// carrying the v3 encryption capability, so the wire shape matches what a real
/// Goblin peer publishes (client.rs `publish_identity`). Best-effort.
async fn advertise_inbox(client: &Client, inbox_relays: &[&str]) {
let mut tags: Vec<Tag> = inbox_relays
.iter()
.map(|r| Tag::custom(TagKind::custom("relay"), [r.to_string()]))
.collect();
tags.push(Tag::custom(
TagKind::custom("encryption"),
[wrapv3::ENCRYPTION_CAPABILITY.to_string()],
));
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let targets: Vec<String> = inbox_relays.iter().map(|s| s.to_string()).collect();
match client.sign_event_builder(builder).await {
Ok(ev) => {
if let Err(e) = client.send_event_to(&targets, &ev).await {
eprintln!("[harness] warn: advertise 10050 failed: {e}");
}
}
Err(e) => eprintln!("[harness] warn: sign 10050 failed: {e}"),
}
}
/// Wait up to `timeout` for a kind-1059 gift wrap addressed to `me` on the
/// notification stream, unwrap it through Goblin's version-dispatched
/// `wrapv3::unwrap` (proves the 0x03 path over the wire), and return the sender
/// + rumor. Any other event is ignored.
async fn recv_and_unwrap(
client: &Client,
me: &Keys,
timeout: Duration,
) -> Result<(PublicKey, UnsignedEvent), String> {
let mut notifications = client.notifications();
tokio::time::timeout(timeout, async {
loop {
if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await {
if event.kind != Kind::GiftWrap {
continue;
}
match wrapv3::unwrap(me, &event).await {
Ok(u) => return (u.sender, u.rumor),
// A wrap we cannot open (someone else's) — keep waiting.
Err(e) => {
eprintln!("[harness] ignoring undecryptable wrap: {e}");
continue;
}
}
}
}
})
.await
.map_err(|_| "timed out waiting for gift wrap".to_string())
}
/// Assert the received rumor is exactly the payment DM Alice sent.
fn assert_payment(sender: PublicKey, alice: &Keys, rumor: &UnsignedEvent, content: &str) {
assert_eq!(sender, alice.public_key(), "sender must be Alice");
assert_eq!(
rumor.pubkey,
alice.public_key(),
"rumor author == seal signer"
);
assert_eq!(rumor.kind, Kind::PrivateDirectMessage);
assert_eq!(
rumor.content, content,
"payment content must survive the wire"
);
let armor = protocol::extract_slatepack(&rumor.content).expect("slatepack must extract");
assert!(armor.starts_with("BEGINSLATEPACK.") && armor.ends_with("ENDSLATEPACK."));
assert_eq!(
protocol::extract_subject(&rumor.tags).as_deref(),
Some(SUBJECT)
);
}
/// RELAY-GATED READINESS (the point of the G14 hardening): `transport_ready()`
/// must be FALSE while only the tunnel is up, and become TRUE only once a relay
/// is actually connected+subscribed on the CURRENT tunnel generation — the
/// signal that governs the "Connected over Nym" UI and the exit-health window.
///
/// The bare `nostr_sdk::Client` used here is not the app's `NostrService`, so it
/// doesn't feed the readiness signal on its own; we drive the SAME report the
/// service loop makes (`report_relay_live(tunnel_generation())`) exactly when a
/// relay has connected+subscribed, and assert the gate flips only then. Proves
/// the cross-module contract: tunnel-up alone is NOT ready; a live relay on the
/// current generation IS.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn transport_ready_is_relay_gated() {
init();
ensure_tunnel().await;
let generation = grim::nym::tunnel_generation();
assert!(
generation != 0,
"a live tunnel must have a non-zero generation"
);
// Clear any liveness a prior test left on this (process-global) generation,
// so the assertion is order-independent.
grim::nym::report_relay_down(generation);
assert!(
grim::nym::is_ready(),
"precondition: tunnel (is_ready) must be up"
);
assert!(
!grim::nym::transport_ready(),
"BUG: transport_ready must be FALSE on a warm tunnel with no live relay \
(this is exactly the false 'Connected over Nym' the hardening fixes)"
);
// Bring one relay to connected+subscribed over the mixnet, like the service.
let relay = "wss://relay.damus.io";
let bob = Keys::generate();
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
// Wait for the websocket handshake to actually complete over Nym, then feed
// the readiness signal the way `run_service`'s status tick does. A generous
// budget: a relay handshake over the mixnet is variable (seen 10-30s).
let mut connected = false;
for _ in 0..120 {
if bob_client
.relays()
.await
.values()
.any(|r| r.status() == RelayStatus::Connected)
{
connected = true;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(connected, "BLOCKER: relay never connected over the mixnet");
grim::nym::report_relay_live(generation);
assert!(
grim::nym::transport_ready(),
"transport_ready must be TRUE once a relay is live on the current generation"
);
// A report tagged with an OLDER generation must not keep us 'ready' after a
// (hypothetical) reselect: simulate the generation moving on and confirm the
// stale report no longer counts.
grim::nym::report_relay_live(generation - 1);
// Still ready: the current-generation liveness stands (fetch_max floor).
assert!(
grim::nym::transport_ready(),
"a stale-generation report must not lower current readiness"
);
eprintln!("[harness] relay-gated readiness verified at gen {generation}");
bob_client.disconnect().await;
}
/// CONDEMN + RESELECT (deterministic simulation of a relay-dead exit): with a
/// nostr consumer present but NO relay ever reported live on the current exit,
/// nymproc must condemn the exit within its grace window and rebuild on a fresh
/// auto-selected one (the generation advances), then recover. Proves the
/// exit-health state machine — the whole point of requirement 2 — end to end
/// without needing a naturally bad-for-relays exit (which can't be forced
/// deterministically). In the real app the NostrService DOES report relay-live,
/// so a HEALTHY exit is never condemned (see `v3_cross_relay`).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn dead_for_relays_exit_is_condemned_and_reselected() {
init();
ensure_tunnel().await;
let gen0 = grim::nym::tunnel_generation();
assert!(gen0 != 0, "a live tunnel must have a non-zero generation");
eprintln!(
"[harness] arming relay consumer at gen {gen0}; withholding relay-live to simulate a relay-dead exit"
);
// Arm relay-reachability governance but never report a live relay: nymproc
// must treat this exit as dead-for-our-purposes and reselect.
grim::nym::set_relay_consumer(true);
// Budget generously: condemnation itself takes RELAY_GRACE (~25s), then a
// FRESH mixnet bootstrap follows (variable, seen 5-70s), so allow ~150s for
// the generation to advance.
let started = Instant::now();
let mut advanced = 0u64;
for _ in 0..300 {
let g = grim::nym::tunnel_generation();
if g > gen0 {
advanced = g;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Disarm FIRST so a failed assert can't leave governance armed for later tests.
grim::nym::set_relay_consumer(false);
assert!(
advanced > gen0,
"BLOCKER: a relay-dead exit was not condemned+reselected within {}s (gen stuck at {gen0})",
started.elapsed().as_secs()
);
eprintln!(
"[harness] exit condemned + reselected: gen {gen0} -> {advanced} in ~{}s",
started.elapsed().as_secs()
);
// Recovery: with governance disarmed, the freshly-built tunnel settles ready.
let mut ready = false;
for _ in 0..80 {
if grim::nym::is_ready() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(ready, "tunnel must recover ready after the reselect");
eprintln!(
"[harness] tunnel recovered ready after reselect at gen {}",
grim::nym::tunnel_generation()
);
}
/// SINGLE-RELAY: a NIP-44 v3 gift wrap round-trips between two fresh Goblin
/// identities over ONE relay, entirely through the smolmix tunnel + mix-dns.
/// Proves the migrated transport delivers the v3 path against a real relay.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v3_roundtrip_single_relay() {
init();
ensure_tunnel().await;
let relay = "wss://relay.damus.io";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] single-relay {relay}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[relay]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
let alice_client = goblin_client(&alice);
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
assert_eq!(wrap.kind, Kind::GiftWrap);
let sent = Instant::now();
alice_client
.send_event_to(vec![relay.to_string()], &wrap)
.await
.expect("publish v3 wrap over mixnet");
eprintln!("[harness] alice published v3 wrap; waiting for delivery...");
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v3 gift wrap never delivered single-relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v3 delivered + decrypted single-relay in {} ms over {relay}",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
/// SINGLE-RELAY v2: the unchanged nostr-sdk NIP-44 v2 gift-wrap path
/// (`send_private_msg_to`) delivered over the SAME smolmix transport, unwrapped
/// through Goblin's version-dispatched `wrapv3::unwrap` (which routes 0x02 to
/// the sdk). Proves the migrated transport is payload-version agnostic — a
/// v2-only peer is unaffected over the mixnet.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v2_roundtrip_single_relay() {
init();
ensure_tunnel().await;
let relay = "wss://relay.damus.io";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] single-relay v2 {relay}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[relay]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
let alice_client = goblin_client(&alice);
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
// nostr-sdk builds a v2 (0x02) gift wrap here.
let sent = Instant::now();
alice_client
.send_private_msg_to([relay], bob.public_key(), content.clone(), tags)
.await
.expect("publish v2 wrap over mixnet");
eprintln!("[harness] alice published v2 wrap; waiting for delivery...");
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v2 gift wrap never delivered single-relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v2 delivered + decrypted single-relay in {} ms over {relay}",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
/// CROSS-RELAY (the redundancy direction): Bob's inbox is nos.lol ONLY; Alice's
/// home is damus. Alice publishes the SAME v3 wrap redundantly to BOTH relays;
/// Bob, subscribed only on nos.lol, still receives + decrypts it. Proves
/// delivery does not depend on a single shared relay and that the v3 path works
/// over the real mixnet transport across two relays with no overlap in what the
/// two identities read.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v3_cross_relay() {
init();
ensure_tunnel().await;
let alice_home = "wss://relay.damus.io";
let bob_inbox = "wss://nos.lol";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] cross-relay: alice_home={alice_home} bob_inbox={bob_inbox}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
// Bob lives ONLY on nos.lol and advertises it as his inbox.
let bob_client = goblin_client(&bob);
bob_client.add_relay(bob_inbox).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[bob_inbox]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
// Alice's home is damus; she also connects to Bob's inbox to deposit there.
let alice_client = goblin_client(&alice);
alice_client.add_relay(alice_home).await.unwrap();
alice_client.add_relay(bob_inbox).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
// Redundant publish to BOTH relays; Bob reads only nos.lol.
let sent = Instant::now();
alice_client
.send_event_to(vec![alice_home.to_string(), bob_inbox.to_string()], &wrap)
.await
.expect("publish v3 wrap to both relays over mixnet");
eprintln!(
"[harness] alice published v3 wrap to [{alice_home}, {bob_inbox}]; bob reads only {bob_inbox}"
);
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v3 gift wrap never crossed to bob's inbox relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v3 delivered + decrypted CROSS-RELAY in {} ms (alice@{alice_home} -> bob@{bob_inbox})",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
+17
View File
@@ -59,6 +59,22 @@
</Extension>
</ProgId>
</Component>
<!-- Register the goblin: URL protocol so web "Open in Goblin" pay
buttons launch the wallet with the payment link as argv[1],
which routes to the prefilled send-review screen. -->
<Component Id="GoblinUrlProtocol" Guid="a4f1c2e8-9b3d-4e6a-8c7f-2d1b5e9a0c34">
<RegistryKey Root="HKCR" Key="goblin">
<RegistryValue Type="string" Value="URL:Goblin Protocol" KeyPath="yes"/>
<RegistryValue Name="URL Protocol" Type="string" Value=""/>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[#goblin.exe]&quot; &quot;%1&quot;"/>
</RegistryKey>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="[#goblin.exe]"/>
</RegistryKey>
</RegistryKey>
</Component>
</DirectoryRef>
<!-- Step 2: Add the shortcut to your installer package -->
@@ -77,6 +93,7 @@
<Feature Id="MainApplication" Title="Goblin" Level="1">
<ComponentRef Id="goblin.exe" />
<ComponentRef Id="GoblinUrlProtocol" />
<ComponentRef Id="License" />
<!-- Step 3: Tell WiX to install the shortcut -->
<ComponentRef Id="ApplicationShortcutDesktop" />