1
0
forked from GRIN/grim

1141 Commits

Author SHA1 Message Date
2ro 4d45a330dc goblin: reserve the network fee in the send flow (GRIM parity)
The live Goblin send UI guarded only on amount > spendable, so a send of
the whole balance was offered as valid with no room for the network fee.
The wallet then built a slate needing amount + fee but holding only amount,
so the send failed with NotEnoughFunds or sat unconfirmed.

GRIM's send modal reserves the fee: it refuses amount + fee > max and caps
its max at spendable - fee. Mirror that here. Both the amount screen and the
review page now price the exact amount (one CalculateFee per amount, as GRIM
does) and refuse the send unless amount + fee fits the spendable balance;
hold-to-send stays disabled until the fee is priced, so a full-balance
payment is never dispatched with no room for its fee.

Adds amount_fits / max_sendable helpers with unit tests covering the
send-max boundary.
build152
2026-07-06 16:14:19 -04:00
2ro b51aac1e46 goblin: mark deferred audit items (M5, M6, L2, L3, L4) with TODO tags
Comments only, no behavior change: tag the five deferred audit findings at the
code they refer to so they are findable later. M5 (widen display escaping to
category-based) and M6 (template echo vs re-serialization) in authuri; L2
(redact secrets from Debug) at NostrIdentity; L3 (carry secrets as ZeroingString
end-to-end) at get_nostr_nsec; L4 (reset AdvancedState on back) in the goblin
on_back handler.
2026-07-06 08:09:40 -04:00
2ro ef4a7a5d19 goblin: auto-clear a revealed nsec from the clipboard after a delay
Copying a revealed nsec (or a post-rotation new key) left the secret sitting in
the clipboard indefinitely. Add copy_secret_to_buffer: it copies now and, after
a short delay, clears the clipboard only if it still holds exactly that secret
(compare-then-clear), so whatever the user copied since is never clobbered. Both
nsec copy sites now use it. Desktop clears through the long-lived clipboard owner
on a background thread; Android does the same over its JNI clipboard via the
established non-GUI app-handle pattern; other platforms fall back to a plain copy.
2026-07-06 08:09:40 -04:00
2ro 20e21d0364 goblin: make password-change identity re-encryption all-or-nothing and rebind the live service
reencrypt_all was best-effort per file: a partial failure left some identities
on the old password and some on the new, a split the user cannot open with one
password. Make it all-or-nothing: stage every re-encryption to a sibling temp
file and only commit (rename into place) once every identity re-encrypts
cleanly; any phase-1 failure deletes the temps and leaves every identity on the
old password.

Surface the failure: change_password now returns a hard error when the
re-encryption fails instead of logging and reporting success.

Keep the running NostrService in sync after a successful change by refreshing
its in-memory identity blobs to the new password, so same-session gated ops
(which re-unlock with the new password) work without a wallet reopen. The
decrypted keys are unchanged by a password change, so listening and sending are
untouched. Adds an all-or-nothing test.
2026-07-06 08:09:15 -04:00
2ro ab029b2647 goblin: bind the login callback host to the approved domain
The login URI parser checked the callback scheme but not that its host belongs
to the displayed domain, so a site could show d=magick.market while pointing cb
at its own host and harvest a signed login event for a domain it does not
control. Apply the same domain binding the authorize URI already enforces:
share authuri's domain_bound as pub(crate) and reject a login URI whose callback
host is neither the domain nor a label-boundary subdomain of it. The
http://localhost dev callback stays exempt. Adds binding tests.
2026-07-06 08:08:39 -04:00
2ro 436674e08a goblin: forward-compatible parsing for proof-address and held-identity registries
Add serde field defaults to every field of ProofAddrRegistry, HeldIdentities,
and HeldEntry so a file written by a different build parses instead of failing:
an unknown extra field is ignored and a dropped non-essential field falls back
to its default. The counter and version defaults are supplied by helper
functions so next defaults to 1 (never 0, which is the app's default address)
and ver to 1. This is parse-side only; the on-disk write path is unchanged.
Adds forward-compat tests for both registries.
2026-07-06 08:08:39 -04:00
2ro d804d2e126 goblin: serialize proof-address allocation, atomic persist, refuse corrupt registry
The per-sale proof-address allocator did an unlocked read-modify-write of the
persisted counter, so two concurrent mints could read the same next index and
hand out the same derivation index, an address reuse that mis-addresses a
payment proof. Serialize the whole load-bump-persist behind a process-wide
lock so every index is unique.

Persist the counter atomically: write a sibling temp file in the same
directory, flush, then rename over the target, so a crash mid-write cannot
leave a torn file.

Refuse a corrupt registry: a file that exists but fails to read or parse now
returns an error instead of silently resetting the counter to 1 (which would
re-hand-out already-minted indices and reuse an address). Only a missing file
defaults to the fresh-wallet counter. Adds a concurrency test asserting unique
indices across racing threads, plus missing-vs-corrupt coverage.
2026-07-06 08:08:39 -04:00
2ro d4cc7dbe98 goblin: Authorize, one-shot site-requested event signing with per-event approval (Build 151)
goblin:authorize?e=<base64url template>&d=<domain>&cb=<https callback>&c=<64-hex nonce>
(and the nostr: QR twin) asks the wallet to sign exactly one Nostr event.
Fail-closed parser beside loginuri: strict three-key template (kind, content,
tags; any pubkey/created_at/id/sig injection rejects), kind allowlist 1/6/7/30023
(22242 only via login), 4096/2048 size caps, unpadded base64url only, delegation
tags rejected, and the new cb-host-must-belong-to-d binding. Approval modal
mirrors the 150 login machinery: Authorize <domain>? verb, plain-language kind
rendering, 240-char escaped preview with truncation marker and show-full view,
identity picker, wallet password gate, single pending request, 120 s expiry,
single-use. Wallet alone sets pubkey/created_at/id/sig, NIP-01 canonical only;
delivery POSTs {c, d, event} to cb with the shared HttpClient and 15 s timeout,
consumed regardless of outcome, quiet toasts. Dispatchers check authorize before
pay on both the deep-link and QR paths. goblin.authorize.* strings in all six
locales; 29 new authuri tests.
2026-07-06 07:34:03 -04:00
2ro 4b0071fa91 goblin: add-identity sheet gets real Generate/Import buttons, Cancel beneath (Build 150) 2026-07-06 03:27:56 -04:00
2ro b7adae89c7 goblin: sign in with Goblin, one-time login approval + 22242 signer (Build 150) 2026-07-06 03:24:09 -04:00
2ro 3c09baf25a goblin: batch invoice URI with one approval, per-sale addresses (Build 149)
URI schema: pay/invoice URIs gain an optional `count=N` parameter
(goblin:<npub|nprofile>?amount=X&count=N[&memo=...]) asking the wallet to
issue N payment requests in one go. Default 1 when absent (single flow
unchanged), capped at MAX_BATCH_COUNT = 20, fail-closed to 1 on zero/garbage,
first occurrence wins — all payuri conventions, unit-tested.

A count>=2 deep link whose recipient is a direct key (npub/nprofile) with an
amount opens ONE batch-approval modal — "Approve N invoices", with the payer,
per-invoice amount, total, and memo — instead of the send flow. On approve,
the wallet mints N fresh per-sale proof addresses (Wallet::mint_proof_address;
a mint failure stops the batch, already-issued requests stand) and fires the
same NostrRequest task the single flow uses, once per invoice; the requests
appear in activity as they dispatch. Anything else (count on a name that
needs discovery, or no amount) degrades to the single flow with count
ignored. The minted address is recorded on each request's metadata
(TxNostrMeta::proof_address, serde-default None) so the per-sale machinery
can read it; WalletTask::NostrRequest gains the optional address and the
single flow passes None. New goblin.batch.* strings in all six locales.
2026-07-06 02:03:00 -04:00
2ro ea07fd2081 goblin: per-sale proof-address allocator + mint API (Build 149)
App side of the index-0 unpinning. Wallet::mint_proof_address allocates the
next fresh derivation index from a persisted, never-reusing counter
(src/wallet/proof_addrs.rs; proof_addrs.json in the wallet dir — an index
carries no secret, the keys live in the wallet seed) and derives its
slatepack/proof address through the wallet's existing indexed derivation
(address_from_derivation_path), exactly as the app's default index-0 address
is derived. Index 0 stays the default app address; nothing changes for normal
receives. Allocation starts at 1, is monotonic and persisted before use (a
crash burns an index, never reuses one), and is capped at
MAX_PROOF_ADDRESS_INDEX = 1023 — the receive-side scan bound of the matching
grin-wallet submodule patch (kept as a local constant so this crate also
builds against the unpatched upstream submodule; the wallet-side patch, which
detects the addressed index on receive and signs the payment proof with the
matching key, lives in the wallet submodule on a local branch pending the
owner's decision on where that patch is pinned).

Tests: allocation monotonic + persistent from 1, refuses past the scan bound,
corrupt counter degrades to address reuse (the old single-address world),
never a fund risk.
2026-07-06 01:56:06 -04:00
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