1
0
forked from GRIN/grim

Compare commits

...

15 Commits

Author SHA1 Message Date
2ro d7ec692d03 pay: remove top-right tap-to-settings avatar from pay page
Build157 put GoblinView::avatar_self (a tap-through to Settings) top-right
on both the home and pay headers. Per owner request, drop it from the PAY
page only; the home header avatar is unchanged. The scan-QR glyph and the
rest of the pay layout are left in place. Settings remains reachable from
the bottom nav bar (Tab::Me) and the wide-mode sidebar.
2026-07-08 02:22:53 -04:00
2ro 9bdc0e2c60 locales: apply firm zh-CN audit corrections + mirror to zh-TW
Fix 21 keys (23 substitutions) flagged in the zh-CN Goblin-string audit,
and apply the equivalent Traditional corrections to the matching zh-TW keys
so both stay correct and consistent:
- 7 leftover-English strings (you/username/handle/kind) translated
- 3 stray formal 您 -> informal 你 (plus Nostr -> nostr where en is lowercase)
- 7 terminology tweaks (机构->授权方, kind->类型, 活动->动态, 费用->手续费)
- 4 half-width -> full-width commas
Judgment-call rows (review_btn/title, pairing.title) intentionally not applied.
2026-07-08 02:22:53 -04:00
2ro e623271476 locales: add Traditional Chinese (zh-TW)
Full native-quality Traditional Chinese (Taiwan) locale covering all 910
keys. Shared GRIM-inherited keys use jasperli2026's zh-TC values verbatim;
goblin.* namespace translated from the corrected Simplified source into
Taiwan-idiom Traditional (OpenCC s2twp plus a manual Taiwan post-fix and
review pass), keeping Jasper's voice and crypto-wallet register. Cangjie
keyboard labels reused from zh-CN; lang_name set to 繁體中文.

Wired into the i18n drift test (OTHER_LOCALES) and README attribution;
rust_i18n auto-loads it and the language picker labels it via lang_name.
System locale zh-TW auto-selects; news folds zh-TW to zh (shared with
Simplified) as noted in news_locale_code.
2026-07-08 02:22:53 -04:00
2ro 8bbb7853ef locales: adopt corrected Simplified Chinese from jasperli2026 (keep Cangjie keyboard + Goblin title) 2026-07-08 02:22:53 -04:00
2ro f2781fb5a9 locales: fix zh-CN lang_name to native 简体中文
The Chinese locale self-label read 英语 (the word for "English"). Use the
native Simplified Chinese name so the language picker row is correct.
2026-07-08 02:22:53 -04:00
2ro 52e8ff6236 settings: clear Android bottom inset so last button is not clipped
Append ui.add_space(View::get_bottom_inset()) at the end of
SettingsContent::ui. The settings screen supplies no bottom inset (its
tab panel is hidden and the CentralPanel frame uses bottom:0), so the
final FADERS Settings button clipped under 3-button nav / the gesture
pill. get_bottom_inset() is 0 off Android, so desktop is unaffected.
2026-07-08 02:22:53 -04:00
2ro 1521b22cdd nostr/pool: remove inert co-located-exit scaffolding
Drop the PoolRelay.exit field and the exit_for/exit_for_host/has_exit
helpers left over from the retired Nym-era co-located scoped exit. The
deleted src/nym was their only real caller; the Tor build never consumes
them. PoolRelay has no deny_unknown_fields, so a gist that still carries
a stray per-relay "exit" key keeps deserializing and the key is ignored
(covered explicitly in the unknown-fields test).
2026-07-08 02:22:53 -04:00
2ro 35c9ea3bbc Remove dormant Nym/mixnet transport, commit fully to Tor
The wallet pivoted from the Nym mixnet to Tor in build134; the Nym code
had been left on disk behind an optional, always-off "nym" feature and
never ran since. Delete it entirely: no Nym/mixnet fallback and no
Nym/mixnet mentions remain. Tor is the sole transport.

- Delete src/nym/ (mod, transport, streamexit, dns, nymproc) and its
  `#[cfg(feature = "nym")] pub mod nym;` declaration in lib.rs.
- Cargo.toml: drop the `nym` feature and the commented-out Nym-only
  path deps (nym-sdk, smolmix, hickory-proto); rewrite the mixnet-era
  comments on the rustls/tokio-rustls/arti/openssl deps to the current
  Tor reality. No active dependency changed: rustls, tokio-rustls and
  webpki-roots stay (used by the Tor HTTPS client in tor/mod.rs).
- settings/config.rs: remove the persisted nym_entry_gateway /
  nym_last_ipr fields and their getters/setters. No serde
  deny_unknown_fields, so existing on-disk configs that still carry
  those keys keep deserializing (the removed keys are ignored and
  dropped on next save; same path the price-cache-removal test guards).
- Rename the Nym/mixnet-named i18n keys (values already said "Tor")
  across all 9 locales and their t!() call sites: connected_nym ->
  connected_tor, nym_ready -> tor_ready, connecting_nym ->
  connecting_tor, mixnet_routing -> tor_routing, over_mixnet ->
  over_tor. Displayed strings unchanged.
- Rewrite the Nym/mixnet-referencing comments in the active Tor files
  (tor/engine.rs, tor/mod.rs, nostr/client.rs, nostr/pool.rs,
  nostr/mod.rs, lib.rs, wallet/wallet.rs, node/node.rs,
  gui widgets/mod/onboarding, Android BackgroundService) to describe
  Tor; drop the now-broken [crate::nym::*] intra-doc links. No active
  code behavior changed.

The pool's `exit` schema slot and exit_for/exit_for_host/has_exit
helpers are kept (comments de-Nym'd): they are inert under Tor but let
a pool document that carries an exit still parse.
2026-07-08 02:22:53 -04:00
2ro 079fdd4841 docs: README default relay set matches code (Tor-friendly relays, drop damus/nos.lol) 2026-07-07 19:21:05 -04:00
2ro 4337580806 goblin: full wallet backup (seed + all identities) with restore
The .backup file now seals the money seed AND every held identity in one
encrypted, versioned envelope, and the caption's promise ("Contains your
wallet and all identities.") is finally true.

Format (nostr/identity.rs): a JSON envelope tagged goblin_backup:2. The
seed phrase is sealed with seal_secret_text() and each identity with the
existing per-identity to_encrypted_backup() (so every element is itself a
valid v1 identity envelope). Both layers reuse the in-tree NIP-49 ncryptsec
+ NIP-44 primitives under one password: no new crypto, no new dependency.
The plaintext seed is assembled and sealed in memory (the zeroizing
recovery string drops at scope end) and never written to disk. Old v1
single-identity backups keep restoring unchanged; is_full_backup() is
checked before the v1 path so the two never collide.

Build (wallet.rs): create_full_backup() gathers the seed via the existing
recovery API (which proves the wallet password) plus every unlocked held
identity. restore_full_backup_identities() reinstates them into a freshly
created wallet, re-encrypting each under the new wallet password, making the
backup's active identity active, and reusing the held-index/service-rebuild
plumbing. The identity-only import paths now reject a full backup with a
clear pointer to wallet creation.

Restore (onboarding.rs): the restore-from-seed step gains a .backup picker;
unlocking it decrypts the seed into the standard 24-word creation grid and
stashes the identities, which a worker reinstates once the wallet opens.

Copy: backup caption, title, blurb, and saved-sub updated across all nine
locales; two new restore strings added in all nine.

Tests: full_backup_roundtrips_seed_identities_and_active,
old_single_identity_backup_is_not_a_full_backup,
seal_secret_text_roundtrips_and_is_opaque; i18n drift + news tests green.
2026-07-07 19:21:05 -04:00
2ro 5ea549abf4 goblin: restructure Advanced page into Advanced Nostr Settings + Danger Zone
Group the Advanced settings page under two grey/red section kickers:

- ADVANCED NOSTR SETTINGS: the Nostr key, and directly below it the
  single always-everything .backup download (one button, no checklist).
  Moved the .backup off the main Settings identity card so it has one
  home (no duplicated exposure). Caption states what the file truly
  holds: the current identity's key and username.
- DANGER ZONE (red kicker): Delete wallet, now password-gated. The old
  tap-twice confirm is replaced by a wallet-password gate
  (get_recovery), with an inline "Download backup" button before the
  password field wired to the same seal action. Copy states the delete
  also removes every identity and urges a backup first.

Add w::kicker_danger; BackupState.anchor_delete routes the seal form to
whichever section opened it. New strings in all nine locales; clear the
backup password from memory on leaving the page.
2026-07-07 19:21:05 -04:00
2ro 5fecba6ff5 goblin: make the self top-right avatar mode-aware (anon-only yellow)
Build 157 hard-coded the flat Goblin-yellow censored tile for the user's
own top-right avatar on the home and pay headers, regardless of anonymous
mode. The owner clarified: anonymous mode is the ONLY thing that turns an
avatar yellow.

Replace the mode-blind w::avatar_self widget with a mode-aware
GoblinView::avatar_self helper: anon ON renders the censored yellow tile
(w::avatar_censored), anon OFF renders this identity's normal gradient/
picture identicon (w::avatar_any), matching every other surface. Remove
the now-unused free avatar_self widget from widgets.rs.

All other avatar_censored call sites (identity switcher, activity rows)
remain correctly gated behind the anonymous-mode flag.
2026-07-07 19:21:05 -04:00
2ro debad91e29 nostr: raise news cap to 18 so English variant survives multi-language batches
A single news post now ships as nine per-`d` language variants and the
publisher emits English first, giving it the oldest `created_at` in the
batch. With the store capped at 8 events across all d-tags, the English
event was always the 9th-newest and got evicted — English readers saw no
news. The catch-up/subscription filter also capped fetches at 4, so fresh
installs only ever received the newest four events.

Raise NEWS_CAP from 8 to 18 (two full nine-language posts) and bump the
news filter limit from 4 to 18 to match. Selection logic is unchanged.
Extend the reconcile_news test with a nine-variant batch that proves the
untagged English event survives under the real cap.
2026-07-07 19:21:05 -04:00
2ro 567c054fe6 goblin: drop the Activity top-right self avatar (owner veto)
Activity returns to a plain title with no top-right avatar; the flat
yellow Grin-mark self avatar stays on the Home and Pay headers only.
2026-07-07 14:22:46 -04:00
2ro 54af417ec0 goblin: Grin-mark anon/self avatars + Export archive caption, archive under Advanced privacy
Anonymous-mode censored avatar now composites the Grin mark (the same path,
90% scale, and 67%-black ink a normal gradient avatar draws) over the flat
#FED60E tile instead of the Goblin head. The user's own top-right avatar on
the Home, Pay, and Activity surfaces always renders as that same flat yellow
+ Grin-mark tile (new avatar_self), never a picture or per-identity gradient;
Activity gains the settings avatar to match Home and Pay.

Export archive gains a caption describing exactly what it copies (contacts,
payment history, and requests, to the clipboard as JSON), localized across all
nine locales. The standalone Archive section is removed from the main settings
page; Export archive and Wipe payment history now live inside Advanced privacy.
2026-07-07 14:22:46 -04:00
36 changed files with 2257 additions and 3567 deletions
+15 -39
View File
@@ -17,8 +17,8 @@ path = "src/main.rs"
name="grim"
crate-type = ["rlib"]
# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr +
# grin tree leaves a large symbol table that's dead weight for users (~16 MB on
# Desktop/CI release binaries ship stripped of debug symbols — the nostr + grin
# tree leaves a large symbol table that's dead weight for users (~16 MB on
# Linux). opt-level stays at the default 3 for wallet/runtime speed.
[profile.release]
strip = true
@@ -32,15 +32,8 @@ 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 build uses the Tor transport only.
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 = []
@@ -130,13 +123,13 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
base64 = "0.22"
hex = "0.4"
## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
## so we install ring explicitly at startup (see lib.rs). Direct dep just to make
## rustls is pulled by our TLS (tungstenite, ring). We install the ring provider
## explicitly at startup (see lib.rs) so rustls 0.23 selects a default
## deterministically. Direct dep just to make
## `rustls::crypto::ring::default_provider()` reachable. NOTHING here may pull
## rustls-platform-verifier — it panics on Android outside a full app context.
rustls = { version = "0.23", features = ["ring"] }
## TLS-over-tunnel for the mixnet HTTP client (webpki roots, never the platform
## TLS for the HTTPS-over-Tor client (webpki roots, never the platform
## verifier — see the rustls note above).
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
webpki-roots = "1"
@@ -145,31 +138,15 @@ webpki-roots = "1"
## 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).
## NOT rustls — this keeps arti's TLS on native-tls and off the rustls provider
## entirely (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 ("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"
@@ -200,12 +177,11 @@ arboard = "3.2.0"
rfd = "0.17.2"
interprocess = { version = "2.2.1", features = ["tokio"] }
## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. Upstream Grim
## got a vendored, statically-linked OpenSSL for free through arti's `static`
## feature; dropping arti for Nym took that with it, breaking Android/cross
## builds (no system OpenSSL for the target) and leaving desktop dynamically
## linked to libssl. Restore the vendored build for exactly those two targets so
## each is self-contained. Windows (SChannel) and macOS (Security.framework)
## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. arti's `static`
## feature already vendors a statically-linked OpenSSL, but we pin the vendored
## build explicitly for exactly those two targets so each is self-contained even
## if that transitive path changes (no system OpenSSL is assumed for the target).
## Windows (SChannel) and macOS (Security.framework)
## don't use OpenSSL at all, so they must NOT pull it — building openssl-src
## there is both pointless and fragile (the Windows MSVC runner's bash perl is
## missing modules its Configure needs).
+3 -1
View File
@@ -38,7 +38,7 @@ Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN nod
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**.
Both parties only need one relay in common. The default set is the Floonet relay (`relay.floonet.dev`) plus Tor-friendly public relays (`relay.0xchat.com`, `offchain.pub`) that accept connections from Tor exits, and the set is editable in **Settings → Relays**.
## Build
@@ -77,3 +77,5 @@ Apache License v2.0.
🤖 Built with AI pair-programming assistance (Claude)
The underlying cross-platform GRIN wallet engine is the upstream **Grim** project.
Simplified and Traditional Chinese translations by jasperli2026 (https://github.com/jasperli2026).
@@ -78,7 +78,7 @@ public class BackgroundService extends Service {
if (Build.VERSION.SDK_INT > 25) {
startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
}
// Goblin's background job is the light Nostr-over-Nym payment
// Goblin's background job is the light Nostr-over-Tor payment
// listen (the "Listening for payments" status); the heavy
// integrated node is never STARTED from this notification --
// Goblin defaults to an external node, so the GRIM "Enable"
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonym"
connected_nym: "Über Tor verbunden"
nym_ready: "Tor bereit · Relays…"
connecting_nym: "Verbinde mit Tor…"
connected_tor: "Über Tor verbunden"
tor_ready: "Tor bereit · Relays…"
connecting_tor: "Verbinde mit Tor…"
cant_reach_node: "Node nicht erreichbar"
node_synced: "Node synchronisiert"
syncing: "Synchronisiere…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Wallet wechseln"
advanced: "Erweitert"
privacy: "Privatsphäre"
mixnet_routing: "Tor-Routing"
tor_routing: "Tor-Routing"
messages_lookups: "Nachrichten & Abfragen"
auto_accept: "Automatisch annehmen"
pairing: "Preiswährung"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Gelb"
archive: "Archiv"
export_archive: "Archiv exportieren"
export_archive_caption: "Kopiert deine Kontakte, deinen Zahlungsverlauf und Anfragen als JSON in die Zwischenablage."
wipe_history: "Zahlungsverlauf löschen"
wipe_history_confirm: "Zum Löschen erneut tippen — kann nicht rückgängig gemacht werden"
about: "Über"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Eine .backup-Datei wählen"
backup_read_failed: "Datei konnte nicht gelesen werden."
backup_saved: "Sicherung gespeichert"
backup_saved_sub: "Bewahre die .backup-Datei sicher auf — wer sie UND dein Passwort hat, kann deine Identität wiederherstellen."
backup_file_title: "Identität sichern"
backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit Benutzername und Schlüssel. Gib dein Wallet-Passwort ein, um sie zu versiegeln."
backup_saved_sub: "Bewahre die .backup-Datei sicher auf. Wer sie UND dein Passwort hat, kann dein gesamtes Wallet wiederherstellen."
backup_file_title: "Wallet sichern"
backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit deinem Wallet-Seed und allen Identitäten. Gib dein Wallet-Passwort ein, um sie zu versiegeln."
backup_write_failed: "Datei konnte nicht gespeichert werden."
create_backup: "Sicherung erstellen"
registered: "%{name} registriert"
@@ -619,7 +620,7 @@ goblin:
password: "Wallet-Passwort"
wrong_password: "Falsches Passwort."
delete: "Wallet löschen"
delete_desc: "Diese Wallet dauerhaft von diesem Gerät entfernen. Ohne deinen Seed sind Guthaben nicht wiederherstellbar."
delete_desc: "Diese Wallet und jede ihrer Identitäten dauerhaft von diesem Gerät entfernen. Sichere zuerst ein Backup. Ohne deinen Seed sind Guthaben nicht wiederherstellbar."
delete_confirm: "Zum Löschen erneut tippen"
manage_node: "Node-Verbindung verwalten"
repair_confirm: "Ja, jetzt reparieren"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "nsec kopieren"
show_qr: "QR anzeigen"
hide_qr: "QR ausblenden"
nostr_section: "Erweiterte Nostr-Einstellungen"
backup_caption: "Enthält dein Wallet und alle Identitäten."
danger_zone: "Gefahrenzone"
delete_warning: "Dies löscht deine Wiederherstellungsphrase und jede Identität auf diesem Gerät. Alles, was du nicht gesichert hast, ist unwiederbringlich verloren."
download_backup: "Backup herunterladen"
delete_final: "Endgültig löschen"
privacy:
title: "Netzwerk-Privatsphäre"
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."
@@ -640,7 +647,7 @@ goblin:
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 Tor"
over_tor: "Ü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."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Wallet wiederherstellen"
wrote_them_down: "Ich habe sie aufgeschrieben"
fill_every_word: "Fülle jedes Wort aus — tippe ein Wort an, um es zu bearbeiten, oder füge die Phrase ein."
unlock_backup: "Backup entsperren"
backup_unlocked: "Backup entsperrt. Deine Identitäten werden beim Öffnen des Wallets wiederhergestellt."
confirm:
kicker: "SCHRITT 2 VON 3 · WALLET"
title: "Jetzt beweise es"
@@ -846,9 +855,10 @@ goblin:
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
title: "Deine Zahlungsidentität"
key_being_made: "Schlüssel wird erstellt…"
connected_nym: "über Tor verbunden"
connecting_nym: "verbinde über Tor…"
connected_tor: "über Tor verbunden"
connecting_tor: "verbinde über Tor…"
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
restoring: "Deine Identitäten werden wiederhergestellt…"
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"
username_blurb: "Freunde zahlen an deinen Namen statt an einen langen Schlüssel. Optional — jederzeit beanspruchbar."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonymous"
connected_nym: "Connected over Tor"
nym_ready: "Tor ready · relays…"
connecting_nym: "Connecting to Tor…"
connected_tor: "Connected over Tor"
tor_ready: "Tor ready · relays…"
connecting_tor: "Connecting to Tor…"
cant_reach_node: "Can't reach node"
node_synced: "Node synced"
syncing: "Syncing…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Switch wallet"
advanced: "Advanced"
privacy: "Privacy"
mixnet_routing: "Tor routing"
tor_routing: "Tor routing"
messages_lookups: "Messages & lookups"
auto_accept: "Auto-accept"
pairing: "Price currency"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Yellow"
archive: "Archive"
export_archive: "Export archive"
export_archive_caption: "Copies your contacts, payment history, and requests to the clipboard as JSON."
wipe_history: "Wipe payment history"
wipe_history_confirm: "Tap again to wipe — this can't be undone"
about: "About"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Choose a .backup file"
backup_read_failed: "Couldn't read that file."
backup_saved: "Backup saved"
backup_saved_sub: "Keep the .backup file safe — anyone with it AND your password can restore your identity."
backup_file_title: "Back up identity"
backup_file_blurb: "Creates one encrypted .backup file with your username and key. Enter your wallet password to seal it."
backup_saved_sub: "Keep the .backup file safe. Anyone with it AND your password can restore your whole wallet."
backup_file_title: "Back up wallet"
backup_file_blurb: "Creates one encrypted .backup file with your wallet seed and every identity. Enter your wallet password to seal it."
backup_write_failed: "Couldn't save the file."
create_backup: "Create backup"
registered: "Registered %{name}"
@@ -619,7 +620,7 @@ goblin:
password: "Wallet password"
wrong_password: "Wrong password."
delete: "Delete wallet"
delete_desc: "Permanently remove this wallet from this device. Without your seed, funds can't be recovered."
delete_desc: "Permanently remove this wallet and every identity on this device. Back up first. Without your seed, funds can't be recovered."
delete_confirm: "Tap again to delete"
manage_node: "Manage node connection"
repair_confirm: "Yes, repair now"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "Copy nsec"
show_qr: "Show QR"
hide_qr: "Hide QR"
nostr_section: "Advanced nostr settings"
backup_caption: "Contains your wallet and all identities."
danger_zone: "Danger zone"
delete_warning: "This erases your recovery phrase and every identity on this device. Anything you haven't backed up is gone for good."
download_backup: "Download backup"
delete_final: "Delete permanently"
privacy:
title: "Network privacy"
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."
@@ -640,7 +647,7 @@ goblin:
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 Tor"
over_tor: "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."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Restore wallet"
wrote_them_down: "I wrote them down"
fill_every_word: "Fill every word — tap a word to edit it, or paste the phrase."
unlock_backup: "Unlock backup"
backup_unlocked: "Backup unlocked. Your identities restore when the wallet opens."
confirm:
kicker: "STEP 2 OF 3 · WALLET"
title: "Now prove it"
@@ -846,9 +855,10 @@ goblin:
kicker: "STEP 3 OF 3 · IDENTITY"
title: "Your payment identity"
key_being_made: "key being made…"
connected_nym: "connected over Tor"
connecting_nym: "connecting over Tor…"
connected_tor: "connected over Tor"
connecting_tor: "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."
restoring: "Restoring your identities…"
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"
username_blurb: "Friends pay your name instead of a long key. Optional — claim one any time."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anónimo"
connected_nym: "Conectado por Tor"
nym_ready: "Tor listo · relés…"
connecting_nym: "Conectando a Tor…"
connected_tor: "Conectado por Tor"
tor_ready: "Tor listo · relés…"
connecting_tor: "Conectando a Tor…"
cant_reach_node: "No se puede conectar con el nodo"
node_synced: "Nodo sincronizado"
syncing: "Sincronizando…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Cambiar de billetera"
advanced: "Avanzado"
privacy: "Privacidad"
mixnet_routing: "Enrutamiento por Tor"
tor_routing: "Enrutamiento por Tor"
messages_lookups: "Mensajes y consultas"
auto_accept: "Aceptación automática"
pairing: "Moneda de referencia"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Amarillo"
archive: "Archivo"
export_archive: "Exportar archivo"
export_archive_caption: "Copia tus contactos, historial de pagos y solicitudes al portapapeles en formato JSON."
wipe_history: "Borrar historial de pagos"
wipe_history_confirm: "Toca de nuevo para borrar — esto no se puede deshacer"
about: "Acerca de"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Elegir un archivo .backup"
backup_read_failed: "No se pudo leer ese archivo."
backup_saved: "Respaldo guardado"
backup_saved_sub: "Mantén el archivo .backup a salvo — cualquiera que lo tenga JUNTO CON tu contraseña puede restaurar tu identidad."
backup_file_title: "Respaldar identidad"
backup_file_blurb: "Crea un archivo .backup cifrado con tu nombre de usuario y clave. Ingresa tu contraseña de billetera para sellarlo."
backup_saved_sub: "Mantén el archivo .backup a salvo. Cualquiera que lo tenga JUNTO CON tu contraseña puede restaurar toda tu billetera."
backup_file_title: "Respaldar billetera"
backup_file_blurb: "Crea un archivo .backup cifrado con la semilla de tu billetera y todas tus identidades. Ingresa tu contraseña de billetera para sellarlo."
backup_write_failed: "No se pudo guardar el archivo."
create_backup: "Crear respaldo"
registered: "%{name} registrado"
@@ -619,7 +620,7 @@ goblin:
password: "Contraseña de la billetera"
wrong_password: "Contraseña incorrecta."
delete: "Eliminar billetera"
delete_desc: "Elimina permanentemente esta billetera de este dispositivo. Sin tu frase de recuperación, los fondos no se pueden recuperar."
delete_desc: "Elimina permanentemente esta billetera y todas sus identidades de este dispositivo. Haz una copia de seguridad primero. Sin tu frase de recuperación, los fondos no se pueden recuperar."
delete_confirm: "Toca de nuevo para eliminar"
manage_node: "Administrar conexión del nodo"
repair_confirm: "Sí, reparar ahora"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "Copiar nsec"
show_qr: "Mostrar QR"
hide_qr: "Ocultar QR"
nostr_section: "Ajustes avanzados de nostr"
backup_caption: "Contiene tu billetera y todas tus identidades."
danger_zone: "Zona de peligro"
delete_warning: "Esto borra tu frase de recuperación y todas las identidades de este dispositivo. Todo lo que no hayas respaldado se perderá para siempre."
download_backup: "Descargar copia de seguridad"
delete_final: "Eliminar permanentemente"
privacy:
title: "Privacidad de red"
intro: "Goblin envía su tráfico privado a través de Tor, lo que oculta tu IP del relé — el cifrado oculta el resto, de modo que un relé no puede vincular un pago contigo."
@@ -640,7 +647,7 @@ goblin:
usernames_blurb: "Búsquedas de nombres NIP-05 hacia y desde goblin.st."
price_avatars: "Precio"
price_avatars_blurb: "La tasa de cambio en vivo que se muestra junto a los montos."
over_mixnet: "Por Tor"
over_tor: "Por Tor"
direct_connection: "Conexión directa"
grin_node: "Nodo de Grin"
grin_node_blurb: "Sincronización de bloques y transmisión de tu transacción a la red. Estos son datos públicos de la cadena, iguales para todos, y no están vinculados a tu identidad."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Restaurar billetera"
wrote_them_down: "Ya las anoté"
fill_every_word: "Completa cada palabra — toca una palabra para editarla, o pega la frase."
unlock_backup: "Desbloquear respaldo"
backup_unlocked: "Respaldo desbloqueado. Tus identidades se restauran cuando se abre la billetera."
confirm:
kicker: "PASO 2 DE 3 · BILLETERA"
title: "Ahora demuéstralo"
@@ -846,9 +855,10 @@ goblin:
kicker: "PASO 3 DE 3 · IDENTIDAD"
title: "Tu identidad de pago"
key_being_made: "creando clave…"
connected_nym: "conectado por Tor"
connecting_nym: "conectando por Tor…"
connected_tor: "conectado por Tor"
connecting_tor: "conectando por Tor…"
fresh_key_blurb: "Una clave de pago que no forma parte de tu frase de recuperación — rótala cuando quieras para mantener la privacidad, sin tocar tus fondos."
restoring: "Restaurando tus identidades…"
clean_slate_blurb: "¿Quieres empezar de cero? Cambia a una clave totalmente nueva cuando quieras — el nuevo tú no está vinculado al anterior. Misma billetera, cara nueva."
pick_username: "Elige un nombre de usuario — opcional"
username_blurb: "Tus amigos pagan a tu nombre en lugar de a una clave larga. Opcional — reclama uno cuando quieras."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonyme"
connected_nym: "Connecté via Tor"
nym_ready: "Tor prêt · relais…"
connecting_nym: "Connexion à Tor…"
connected_tor: "Connecté via Tor"
tor_ready: "Tor prêt · relais…"
connecting_tor: "Connexion à Tor…"
cant_reach_node: "Nœud injoignable"
node_synced: "Nœud synchronisé"
syncing: "Synchronisation…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Changer de portefeuille"
advanced: "Avancé"
privacy: "Confidentialité"
mixnet_routing: "Routage par Tor"
tor_routing: "Routage par Tor"
messages_lookups: "Messages et recherches"
auto_accept: "Acceptation auto"
pairing: "Devise des prix"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Jaune"
archive: "Archive"
export_archive: "Exporter l'archive"
export_archive_caption: "Copie vos contacts, votre historique de paiement et vos demandes dans le presse-papiers au format JSON."
wipe_history: "Effacer l'historique des paiements"
wipe_history_confirm: "Appuyez à nouveau pour effacer — action irréversible"
about: "À propos"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Choisir un fichier .backup"
backup_read_failed: "Impossible de lire ce fichier."
backup_saved: "Sauvegarde enregistrée"
backup_saved_sub: "Conservez le fichier .backup en lieu sûr — quiconque l'a AVEC votre mot de passe peut restaurer votre identité."
backup_file_title: "Sauvegarder l'identité"
backup_file_blurb: "Crée un fichier .backup chiffré avec votre nom d'utilisateur et votre clé. Saisissez le mot de passe du portefeuille pour le sceller."
backup_saved_sub: "Conservez le fichier .backup en lieu sûr. Quiconque l'a AVEC votre mot de passe peut restaurer tout votre portefeuille."
backup_file_title: "Sauvegarder le portefeuille"
backup_file_blurb: "Crée un fichier .backup chiffré avec la graine de votre portefeuille et toutes vos identités. Saisissez le mot de passe du portefeuille pour le sceller."
backup_write_failed: "Impossible d'enregistrer le fichier."
create_backup: "Créer la sauvegarde"
registered: "%{name} enregistré"
@@ -619,7 +620,7 @@ goblin:
password: "Mot de passe du portefeuille"
wrong_password: "Mot de passe incorrect."
delete: "Supprimer le portefeuille"
delete_desc: "Supprimer définitivement ce portefeuille de cet appareil. Sans votre seed, les fonds sont irrécupérables."
delete_desc: "Supprimer définitivement ce portefeuille et toutes ses identités de cet appareil. Faites d'abord une sauvegarde. Sans votre seed, les fonds sont irrécupérables."
delete_confirm: "Touchez à nouveau pour supprimer"
manage_node: "Gérer la connexion au nœud"
repair_confirm: "Oui, réparer maintenant"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "Copier le nsec"
show_qr: "Afficher le QR"
hide_qr: "Masquer le QR"
nostr_section: "Paramètres nostr avancés"
backup_caption: "Contient votre portefeuille et toutes vos identités."
danger_zone: "Zone de danger"
delete_warning: "Ceci efface votre phrase de récupération et toutes les identités de cet appareil. Tout ce qui n'a pas été sauvegardé est perdu définitivement."
download_backup: "Télécharger la sauvegarde"
delete_final: "Supprimer définitivement"
privacy:
title: "Confidentialité réseau"
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."
@@ -640,7 +647,7 @@ goblin:
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 Tor"
over_tor: "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é."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Restaurer le portefeuille"
wrote_them_down: "Je les ai notés"
fill_every_word: "Remplissez chaque mot — touchez un mot pour le modifier, ou collez la phrase."
unlock_backup: "Déverrouiller la sauvegarde"
backup_unlocked: "Sauvegarde déverrouillée. Vos identités seront restaurées à l'ouverture du portefeuille."
confirm:
kicker: "ÉTAPE 2 SUR 3 · PORTEFEUILLE"
title: "Maintenant prouvez-le"
@@ -846,9 +855,10 @@ 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 Tor"
connecting_nym: "connexion via Tor…"
connected_tor: "connecté via Tor"
connecting_tor: "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."
restoring: "Restauration de vos identités…"
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"
username_blurb: "Vos amis paient votre nom au lieu d'une longue clé. Facultatif — réclamez-en un à tout moment."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "Tor経由で接続済み"
nym_ready: "Tor準備完了 · リレー…"
connecting_nym: "Torに接続中…"
connected_tor: "Tor経由で接続済み"
tor_ready: "Tor準備完了 · リレー…"
connecting_tor: "Torに接続中…"
cant_reach_node: "ノードに接続できません"
node_synced: "ノード同期済み"
syncing: "同期中…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "ウォレットを切り替え"
advanced: "詳細設定"
privacy: "プライバシー"
mixnet_routing: "Torルーティング"
tor_routing: "Torルーティング"
messages_lookups: "メッセージと検索"
auto_accept: "自動承認"
pairing: "価格通貨"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "イエロー"
archive: "アーカイブ"
export_archive: "アーカイブをエクスポート"
export_archive_caption: "連絡先、支払い履歴、リクエストを JSON 形式でクリップボードにコピーします。"
wipe_history: "支払い履歴を消去"
wipe_history_confirm: "もう一度タップして消去 — 元に戻せません"
about: "アプリについて"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: ".backupファイルを選択"
backup_read_failed: "そのファイルを読み込めませんでした。"
backup_saved: "バックアップを保存しました"
backup_saved_sub: ".backupファイルは安全に保管してくださいこのファイルとパスワードの両方を持つ人は誰でもアイデンティティを復元できます。"
backup_file_title: "アイデンティティをバックアップ"
backup_file_blurb: "ユーザー名とキーを含む暗号化された.backupファイルを1つ作成します。ウォレットのパスワードを入力して保護してください。"
backup_saved_sub: ".backupファイルは安全に保管してくださいこのファイルとパスワードの両方を持つ人は誰でもウォレット全体を復元できます。"
backup_file_title: "ウォレットをバックアップ"
backup_file_blurb: "ウォレットのシードとすべてのアイデンティティを含む暗号化された.backupファイルを1つ作成します。ウォレットのパスワードを入力して保護してください。"
backup_write_failed: "ファイルを保存できませんでした。"
create_backup: "バックアップを作成"
registered: "%{name}を登録しました"
@@ -619,7 +620,7 @@ goblin:
password: "ウォレットのパスワード"
wrong_password: "パスワードが間違っています。"
delete: "ウォレットを削除"
delete_desc: "この端末からこのウォレットを完全に削除します。リカバリーフレーズがなければ資金は復元できません。"
delete_desc: "この端末からこのウォレットとすべてのアイデンティティを完全に削除します。先にバックアップしてください。リカバリーフレーズがなければ資金は復元できません。"
delete_confirm: "もう一度タップして削除"
manage_node: "ノード接続を管理"
repair_confirm: "はい、今すぐ修復する"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "nsecをコピー"
show_qr: "QRを表示"
hide_qr: "QRを非表示"
nostr_section: "詳細なnostr設定"
backup_caption: "ウォレットとすべてのアイデンティティが含まれます。"
danger_zone: "危険ゾーン"
delete_warning: "この端末のリカバリーフレーズとすべてのアイデンティティが消去されます。バックアップしていないものは完全に失われます。"
download_backup: "バックアップをダウンロード"
delete_final: "完全に削除"
privacy:
title: "ネットワークプライバシー"
intro: "Goblinはプライベートな通信をTor経由で送信し、リレーからIPアドレスを隠します — 暗号化がそれ以外の情報を隠すため、リレーが支払いをあなたに結び付けることはできません。"
@@ -640,7 +647,7 @@ goblin:
usernames_blurb: "goblin.stとの間で行われるNIP-05の名前検索です。"
price_avatars: "価格"
price_avatars_blurb: "金額の隣に表示されるリアルタイムの法定通貨レートです。"
over_mixnet: "Tor経由"
over_tor: "Tor経由"
direct_connection: "直接接続"
grin_node: "Grinノード"
grin_node_blurb: "ブロックの同期と、取引をネットワークにブロードキャストする処理です。これは誰にとっても同じ公開されたチェーンデータであり、あなたのアイデンティティとは結び付いていません。"
@@ -836,6 +843,8 @@ goblin:
restore_wallet: "ウォレットを復元"
wrote_them_down: "書き留めました"
fill_every_word: "すべての単語を入力してください — 単語をタップして編集するか、フレーズを貼り付けてください。"
unlock_backup: "バックアップを解除"
backup_unlocked: "バックアップを解除しました。ウォレットを開くとアイデンティティが復元されます。"
confirm:
kicker: "ステップ2/3 · ウォレット"
title: "確認してみましょう"
@@ -847,9 +856,10 @@ goblin:
kicker: "ステップ3/3 · アイデンティティ"
title: "あなたの支払いアイデンティティ"
key_being_made: "キーを作成中…"
connected_nym: "Tor経由で接続済み"
connecting_nym: "Tor経由で接続中…"
connected_tor: "Tor経由で接続済み"
connecting_tor: "Tor経由で接続中…"
fresh_key_blurb: "リカバリーフレーズの一部ではない支払い用のキーです — 資金に影響を与えることなく、いつでもローテーションしてプライバシーを保てます。"
restoring: "アイデンティティを復元しています…"
clean_slate_blurb: "まっさらな状態にしたいですか? いつでもまったく新しいキーに切り替えられます — 新しいあなたは古いあなたと結び付きません。同じウォレットで、新しい顔になります。"
pick_username: "ユーザー名を選択 — 任意"
username_blurb: "友人は長いキーの代わりに、あなたの名前で支払います。任意です — いつでも登録できます。"
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "익명"
connected_nym: "Tor로 연결됨"
nym_ready: "Tor 준비 완료 · 릴레이…"
connecting_nym: "Tor에 연결하는 중…"
connected_tor: "Tor로 연결됨"
tor_ready: "Tor 준비 완료 · 릴레이…"
connecting_tor: "Tor에 연결하는 중…"
cant_reach_node: "노드에 연결할 수 없음"
node_synced: "노드 동기화됨"
syncing: "동기화하는 중…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "지갑 전환"
advanced: "고급"
privacy: "프라이버시"
mixnet_routing: "Tor 라우팅"
tor_routing: "Tor 라우팅"
messages_lookups: "메시지 및 조회"
auto_accept: "자동 수락"
pairing: "기준 통화"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "옐로우"
archive: "보관함"
export_archive: "보관함 내보내기"
export_archive_caption: "연락처, 결제 내역, 요청을 JSON 형식으로 클립보드에 복사합니다."
wipe_history: "결제 내역 지우기"
wipe_history_confirm: "지우려면 다시 탭하세요 — 되돌릴 수 없습니다"
about: "정보"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: ".backup 파일 선택"
backup_read_failed: "해당 파일을 읽을 수 없습니다."
backup_saved: "백업 저장됨"
backup_saved_sub: ".backup 파일을 안전하게 보관하세요 이 파일과 비밀번호를 모두 가진 사람은 신원을 복원할 수 있습니다."
backup_file_title: "신원 백업"
backup_file_blurb: "사용자 이름과 키가 담긴 암호화된 .backup 파일을 만듭니다. 지갑 비밀번호를 입력해 봉인하세요."
backup_saved_sub: ".backup 파일을 안전하게 보관하세요. 이 파일과 비밀번호를 모두 가진 사람은 지갑 전체를 복원할 수 있습니다."
backup_file_title: "지갑 백업"
backup_file_blurb: "지갑 시드와 모든 신원이 담긴 암호화된 .backup 파일을 만듭니다. 지갑 비밀번호를 입력해 봉인하세요."
backup_write_failed: "파일을 저장할 수 없습니다."
create_backup: "백업 만들기"
registered: "%{name} 등록됨"
@@ -619,7 +620,7 @@ goblin:
password: "지갑 비밀번호"
wrong_password: "비밀번호가 틀렸습니다."
delete: "지갑 삭제"
delete_desc: "이 기기에서 이 지갑을 영구적으로 삭제합니다. 복구 문구가 없으면 자금을 복구할 수 없습니다."
delete_desc: "이 기기에서 이 지갑과 모든 신원을 영구적으로 삭제합니다. 먼저 백업하세요. 복구 문구가 없으면 자금을 복구할 수 없습니다."
delete_confirm: "다시 눌러서 삭제"
manage_node: "노드 연결 관리"
repair_confirm: "예, 지금 복구"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "nsec 복사"
show_qr: "QR 표시"
hide_qr: "QR 숨기기"
nostr_section: "고급 nostr 설정"
backup_caption: "지갑과 모든 신원이 포함됩니다."
danger_zone: "위험 구역"
delete_warning: "이 기기의 복구 문구와 모든 신원이 삭제됩니다. 백업하지 않은 항목은 영구적으로 사라집니다."
download_backup: "백업 다운로드"
delete_final: "영구 삭제"
privacy:
title: "네트워크 프라이버시"
intro: "Goblin은 개인 트래픽을 Tor를 통해 전송해 릴레이로부터 내 IP를 숨깁니다 — 암호화가 나머지를 숨겨서 릴레이가 결제를 나와 연결 지을 수 없습니다."
@@ -640,7 +647,7 @@ goblin:
usernames_blurb: "goblin.st와 주고받는 NIP-05 이름 조회입니다."
price_avatars: "가격"
price_avatars_blurb: "금액 옆에 표시되는 실시간 환율입니다."
over_mixnet: "Tor 경유"
over_tor: "Tor 경유"
direct_connection: "직접 연결"
grin_node: "Grin 노드"
grin_node_blurb: "블록 동기화와 거래를 네트워크에 전파하는 과정입니다. 이는 모두에게 동일한 공개 체인 데이터이며 신원과 연결되지 않습니다."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "지갑 복원"
wrote_them_down: "다 적었습니다"
fill_every_word: "모든 단어를 채우세요 — 단어를 탭해서 수정하거나, 문구를 붙여넣으세요."
unlock_backup: "백업 잠금 해제"
backup_unlocked: "백업이 잠금 해제되었습니다. 지갑을 열면 신원이 복원됩니다."
confirm:
kicker: "2/3단계 · 지갑"
title: "이제 확인해 보세요"
@@ -846,9 +855,10 @@ goblin:
kicker: "3/3단계 · 신원"
title: "나의 결제 신원"
key_being_made: "키를 만드는 중…"
connected_nym: "Tor로 연결됨"
connecting_nym: "Tor로 연결하는 중…"
connected_tor: "Tor로 연결됨"
connecting_tor: "Tor로 연결하는 중…"
fresh_key_blurb: "복구 문구에 속하지 않는 결제 키입니다 — 자금은 그대로 둔 채 언제든 교체해 프라이버시를 지키세요."
restoring: "신원을 복원하는 중…"
clean_slate_blurb: "새 출발을 원하시나요? 언제든 완전히 새로운 키로 바꾸세요 — 새로운 나는 이전과 연결되지 않습니다. 같은 지갑, 새로운 얼굴입니다."
pick_username: "사용자 이름 선택 — 선택 사항"
username_blurb: "친구들이 긴 키 대신 내 이름으로 결제합니다. 선택 사항이며 — 언제든 등록할 수 있습니다."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Аноним"
connected_nym: "Подключено через Tor"
nym_ready: "Tor готов · реле…"
connecting_nym: "Подключение к Tor…"
connected_tor: "Подключено через Tor"
tor_ready: "Tor готов · реле…"
connecting_tor: "Подключение к Tor…"
cant_reach_node: "Нет связи с узлом"
node_synced: "Узел синхронизирован"
syncing: "Синхронизация…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Сменить кошелёк"
advanced: "Дополнительно"
privacy: "Приватность"
mixnet_routing: "Маршрутизация через Tor"
tor_routing: "Маршрутизация через Tor"
messages_lookups: "Сообщения и поиск"
auto_accept: "Автоприём"
pairing: "Валюта цены"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Жёлтая"
archive: "Архив"
export_archive: "Экспорт архива"
export_archive_caption: "Копирует ваши контакты, историю платежей и запросы в буфер обмена в формате JSON."
wipe_history: "Стереть историю платежей"
wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить"
about: "О приложении"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Выбрать файл .backup"
backup_read_failed: "Не удалось прочитать файл."
backup_saved: "Резервная копия сохранена"
backup_saved_sub: "Храните файл .backup в безопасности — любой, у кого есть он И ваш пароль, может восстановить вашу личность."
backup_file_title: "Резервная копия личности"
backup_file_blurb: "Создаёт один зашифрованный файл .backup с именем и ключом. Введите пароль кошелька, чтобы запечатать его."
backup_saved_sub: "Храните файл .backup в безопасности. Любой, у кого есть он И ваш пароль, может восстановить весь ваш кошелёк."
backup_file_title: "Резервная копия кошелька"
backup_file_blurb: "Создаёт один зашифрованный файл .backup с seed-фразой кошелька и всеми личностями. Введите пароль кошелька, чтобы запечатать его."
backup_write_failed: "Не удалось сохранить файл."
create_backup: "Создать копию"
registered: "Зарегистрировано %{name}"
@@ -619,7 +620,7 @@ goblin:
password: "Пароль кошелька"
wrong_password: "Неверный пароль."
delete: "Удалить кошелёк"
delete_desc: "Безвозвратно удалить этот кошелёк с этого устройства. Без seed-фразы средства не восстановить."
delete_desc: "Безвозвратно удалить этот кошелёк и все его личности с этого устройства. Сначала сделайте резервную копию. Без seed-фразы средства не восстановить."
delete_confirm: "Нажмите ещё раз для удаления"
manage_node: "Управление подключением к узлу"
repair_confirm: "Да, восстановить сейчас"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "Копировать nsec"
show_qr: "Показать QR"
hide_qr: "Скрыть QR"
nostr_section: "Расширенные настройки nostr"
backup_caption: "Содержит ваш кошелёк и все личности."
danger_zone: "Опасная зона"
delete_warning: "Это удалит вашу фразу восстановления и все личности на этом устройстве. Всё, что не сохранено в резервной копии, будет потеряно навсегда."
download_backup: "Скачать резервную копию"
delete_final: "Удалить навсегда"
privacy:
title: "Сетевая приватность"
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
@@ -640,7 +647,7 @@ goblin:
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
price_avatars: "Цена"
price_avatars_blurb: "Текущий курс рядом с суммами."
over_mixnet: "Через Tor"
over_tor: "Через Tor"
direct_connection: "Прямое соединение"
grin_node: "Узел Grin"
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Восстановить кошелёк"
wrote_them_down: "Я записал их"
fill_every_word: "Заполните каждое слово — коснитесь слова для редактирования или вставьте фразу."
unlock_backup: "Разблокировать копию"
backup_unlocked: "Копия разблокирована. Ваши личности восстановятся при открытии кошелька."
confirm:
kicker: "ШАГ 2 ИЗ 3 · КОШЕЛЁК"
title: "Теперь подтвердите"
@@ -846,9 +855,10 @@ goblin:
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
title: "Ваша платёжная личность"
key_being_made: "ключ создаётся…"
connected_nym: "подключено через Tor"
connecting_nym: "подключение через Tor…"
connected_tor: "подключено через Tor"
connecting_tor: "подключение через Tor…"
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
restoring: "Восстановление ваших личностей…"
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
pick_username: "Выберите имя — необязательно"
username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент."
+21 -11
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonim"
connected_nym: "Tor üzerinden bağlı"
nym_ready: "Tor hazır · relaylar…"
connecting_nym: "Tor'a bağlanılıyor…"
connected_tor: "Tor üzerinden bağlı"
tor_ready: "Tor hazır · relaylar…"
connecting_tor: "Tor'a bağlanılıyor…"
cant_reach_node: "Düğüme ulaşılamıyor"
node_synced: "Düğüm eşitlendi"
syncing: "Eşitleniyor…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Cüzdan değiştir"
advanced: "Gelişmiş"
privacy: "Gizlilik"
mixnet_routing: "Tor yönlendirme"
tor_routing: "Tor yönlendirme"
messages_lookups: "Mesajlar ve aramalar"
auto_accept: "Otomatik kabul"
pairing: "Fiyat para birimi"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "Sarı"
archive: "Arşiv"
export_archive: "Arşivi dışa aktar"
export_archive_caption: "Kişilerinizi, ödeme geçmişinizi ve istekleri JSON olarak panoya kopyalar."
wipe_history: "Ödeme geçmişini sil"
wipe_history_confirm: "Silmek için tekrar dokun — geri alınamaz"
about: "Hakkında"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "Bir .backup dosyası seç"
backup_read_failed: "Dosya okunamadı."
backup_saved: "Yedek kaydedildi"
backup_saved_sub: ".backup dosyasını güvende tut — hem ona hem de parolana sahip olan kimliğini geri yükleyebilir."
backup_file_title: "Kimliği yedekle"
backup_file_blurb: "Kullanıcı adın ve anahtarınla tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir."
backup_saved_sub: ".backup dosyasını güvende tut. Hem ona hem de parolana sahip olan tüm cüzdanını geri yükleyebilir."
backup_file_title: "Cüzdanı yedekle"
backup_file_blurb: "Cüzdan tohumun ve tüm kimliklerinle tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir."
backup_write_failed: "Dosya kaydedilemedi."
create_backup: "Yedek oluştur"
registered: "%{name} kaydedildi"
@@ -619,7 +620,7 @@ goblin:
password: "Cüzdan parolası"
wrong_password: "Yanlış parola."
delete: "Cüzdanı sil"
delete_desc: "Bu cüzdanı bu cihazdan kalıcı olarak kaldır. Tohumun olmadan fonlar kurtarılamaz."
delete_desc: "Bu cüzdanı ve tüm kimliklerini bu cihazdan kalıcı olarak kaldır. Önce yedek al. Tohumun olmadan fonlar kurtarılamaz."
delete_confirm: "Silmek için tekrar dokun"
manage_node: "Düğüm bağlantısını yönet"
repair_confirm: "Evet, şimdi onar"
@@ -631,6 +632,12 @@ goblin:
copy_nsec: "nsec'i kopyala"
show_qr: "QR göster"
hide_qr: "QR gizle"
nostr_section: "Gelişmiş nostr ayarları"
backup_caption: "Cüzdanını ve tüm kimliklerini içerir."
danger_zone: "Tehlikeli bölge"
delete_warning: "Bu, kurtarma kelimelerinizi ve bu cihazdaki tüm kimlikleri siler. Yedeklemediğiniz her şey kalıcı olarak kaybolur."
download_backup: "Yedeği indir"
delete_final: "Kalıcı olarak sil"
privacy:
title: "Ağ gizliliği"
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."
@@ -640,7 +647,7 @@ goblin:
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: "Tor üzerinden"
over_tor: "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."
@@ -835,6 +842,8 @@ goblin:
restore_wallet: "Cüzdanı geri yükle"
wrote_them_down: "Onları yazdım"
fill_every_word: "Her kelimeyi doldur — düzenlemek için bir kelimeye dokun ya da ifadeyi yapıştır."
unlock_backup: "Yedeği aç"
backup_unlocked: "Yedek açıldı. Kimliklerin cüzdan açıldığında geri yüklenir."
confirm:
kicker: "ADIM 2 / 3 · CÜZDAN"
title: "Şimdi kanıtla"
@@ -846,9 +855,10 @@ goblin:
kicker: "ADIM 3 / 3 · KİMLİK"
title: "Ödeme kimliğin"
key_being_made: "anahtar oluşturuluyor…"
connected_nym: "Tor üzerinden bağlı"
connecting_nym: "Tor üzerinden bağlanılıyor…"
connected_tor: "Tor üzerinden bağlı"
connecting_tor: "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."
restoring: "Kimliklerin geri yükleniyor…"
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ı"
username_blurb: "Arkadaşların uzun bir anahtar yerine adına öder. İsteğe bağlı — istediğin an al."
+225 -215
View File
@@ -1,4 +1,4 @@
lang_name: 英语
lang_name: 简体中文
copy: 复制
paste: 粘贴
continue: 继续
@@ -9,10 +9,10 @@ close: 关闭
change: 更改
show: 显示
delete: 删除
clear:
clear:
create: 创建
id: 标识
kernel:
id: 标识
kernel:
settings: 设置
language: 语言
scan: 扫描
@@ -22,67 +22,67 @@ repeat: 重复
scan_result: 扫描结果
back: 返回
share: 分享
theme: '主题:'
theme: '主题'
dark: 深色
light:
light:
file: 文件
choose_file: 选择文件
choose_folder: 选择文件夹
crash_report: 崩溃报告
crash_report_warning: 上次应用程序意外关闭,可以报告开发人员崩溃事件.
crash_report_warning: 应用上次意外关闭,可以将崩溃报告分享给开发者。
confirmation: 确认
enter_url: 输入 URL
max_short: 最大數量
files_location: 檔案位置
moving_files: 檔案移動
wrong_path_error: 指定錯誤路徑
check_updates: 啟動時請查看更新
update_available: 最新消息已发布
changelog: '更新日誌:'
enter_url: 输入网址
max_short: 最大
files_location: 文件位置
moving_files: 正在移动文件
wrong_path_error: 指定的路径错误
check_updates: 启动时检查更新
update_available: 有可用更新
changelog: '更新日志:'
wallets:
await_conf_amount: 等待确认
await_fin_amount: 等待确定中
locked_amount: 锁定帐户
txs_empty: '手动接收资金或通过传输接收资金 %{message} or %{transport} 更改钱包设置, 请按屏幕底部的按钮 %{settings} 按钮.'
await_conf_amount: 等待确认
await_fin_amount: 等待最终确认
locked_amount: 锁定
txs_empty: '手动或通过传输方式接收资金,请使用屏幕底部的 %{message} %{transport} 按钮;要更改钱包设置,请按 %{settings} 按钮'
title: Goblin
create_desc: 创建或种子单词导入有钱包.
create_desc: 创建新钱包或从已保存的助记词导入有钱包
add: 添加钱包
name: '用户名:'
pass: '密码:'
pass_empty: 输入钱包密码
current_pass: '前密码:'
new_pass: '新密码:'
min_tx_conf_count: '确认交易的最低数量:'
name: '名称:'
pass: '密码'
pass_empty: 输入钱包密码
current_pass: '前密码'
new_pass: '新密码'
min_tx_conf_count: '交易的最少确认数:'
recover: 恢复
recovery_phrase: 助记词
words_count: '字数:'
enter_word: '输入单词 #%{number}:'
words_count: '单词数量:'
enter_word: '输入%{number} 个单词:'
not_valid_word: 输入的单词无效
not_valid_phrase: 输入的助记词无效
create_phrase_desc: 安全地写下并保存助记词.
restore_phrase_desc: 从已保存的助记词中输入.
setup_conn_desc: 选择钱包连接网络的方式.
create_phrase_desc: 安全地写下并保存你的助记词
restore_phrase_desc: 输入你保存的助记词中的单词。
setup_conn_desc: 选择钱包连接网络的方式
conn_method: 连接方式
ext_conn: '外部连接:'
ext_conn: '外部连接'
add_node: 添加节点
node_url: '节点网址:'
node_secret: 'API 密钥 (可选):'
node_url: '节点网址'
node_secret: 'API 密钥(可选):'
invalid_url: 输入的网址无效
open: 打开钱包
wrong_pass: 输入的密码错误
locked: 已锁定
unlocked: 解锁
enable_node: '通过选择屏幕底部的按钮 %{settings} 启用集成节点以使用钱包或更改连接设置.'
node_loading: '集成节点同步后钱包会加载,你可选择屏幕底部的按钮 %{settings} 更改连接.'
loading: 正在加载
closing: 正在关闭
unlocked: 解锁
enable_node: '请启用集成节点以使用钱包,或通过选择屏幕底部的 %{settings} 更改连接设置'
node_loading: '钱包将在集成节点同步完成后加载,你可以通过选择屏幕底部的 %{settings} 更改连接方式。'
loading: 加载
closing: 关闭
checking: 检查中
default_wallet: 默认钱包
new_account_desc: '输入新帐户的名称:'
wallet_loading: 加载钱包
wallet_closing: 关闭钱包
wallet_checking: 检查钱包
tx_loading: 加载事务
new_account_desc: '输入新账户名称'
wallet_loading: 正在加载钱包
wallet_closing: 正在关闭钱包
wallet_checking: 正在检查钱包
tx_loading: 正在加载交易
default_account: 默认账户
accounts: 账户
tx_sent: 已发送
@@ -91,82 +91,82 @@ wallets:
tx_receiving: 接收中
tx_confirming: 等待确认
tx_canceled: 已取消
tx_cancelling: 取消
tx_finalizing: 完成
tx_posting: 过账交易
tx_cancelling: 取消
tx_finalizing: 最终确认中
tx_posting: 提交中
tx_confirmed: 已确认
tx_stale: 等待时间过长
txs: 所有交易
txs: 交易记录
tx: 交易
messages: 消息
transport: 传输
input_slatepack_desc: '输入收到的 Slatepack 消息创建响应或完成请求:'
parse_slatepack_err: '读取消息时出错,请检查输入:'
pay_balance_error: '账户余额不足以支付 %{amount} ツ 和网络费用.'
parse_i1_slatepack_desc: '要支付 %{amount} ツ 请将此消息发送给接收者:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:'
parse_i3_slatepack_desc: '发布交易以完成 %{amount} ツ的接收 ツ:'
parse_s1_slatepack_desc: '要接收 %{amount} ツ 请将此消息发送给发件人:'
parse_s2_slatepack_desc: '完成交易以发送 %{amount} ツ:'
parse_s3_slatepack_desc: '发布交易以完成 %{amount} ツ的发送:'
resp_slatepack_err: '创建响应时出错,请检查输入数据或重试:'
resp_exists_err: 交易已存在.
resp_canceled_err: 交易已被取消.
create_request_desc: '创建发送或接收资金的请求:'
send_request_desc: '已创建发送请求 %{amount} ツ. 将此消息发送给接收者:'
send_slatepack_err: 创建发送资金请求时出错,请检查输入数据或重试.
invoice_desc: '已创建接收请求 %{amount} ツ. 将此消息发送给发送者:'
invoice_slatepack_err: 发票开具时出错,请检查输入数据或重试.
finalize_slatepack_err: '完结时出错,请检查输入数据或重试:'
finalize: 完成
input_slatepack_desc: '输入收到的 Slatepack 消息创建响应或完成请求'
parse_slatepack_err: '读取消息时发生错误,请检查输入内容:'
pay_balance_error: '账户余额不足以支付 %{amount} ツ 和网络手续费。'
parse_i1_slatepack_desc: '要支付 %{amount} ツ请将此消息发送给接收方:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ'
parse_i3_slatepack_desc: '提交交易以完成 %{amount} ツ 的接收'
parse_s1_slatepack_desc: '要接收 %{amount} ツ请将此消息发送给发送方:'
parse_s2_slatepack_desc: '完成交易以发送 %{amount} ツ'
parse_s3_slatepack_desc: '提交交易以完成 %{amount} ツ 的发送'
resp_slatepack_err: '创建响应时发生错误,请检查输入数据或重试'
resp_exists_err: 交易已存在
resp_canceled_err: 交易已被取消
create_request_desc: '创建发送或接收资金的请求'
send_request_desc: '已创建发送 %{amount} ツ 的请求。请将此消息发送给接收方:'
send_slatepack_err: 创建发送资金请求时发生错误,请检查输入数据或重试
invoice_desc: '已创建接收 %{amount} ツ 的请求。请将此消息发送给发送方:'
invoice_slatepack_err: 开具发票时发生错误,请检查输入数据或重试
finalize_slatepack_err: '最终确认时发生错误,请检查输入数据或重试'
finalize: 最终确认
use_dandelion: 使用蒲公英
enter_amount_send: '你有 %{amount} ツ. 输入发送金额:'
enter_amount_receive: '输入接收金额:'
enter_amount_send: '你有 %{amount} ツ输入发送金额'
enter_amount_receive: '输入接收金额'
recovery: 恢复
repair_wallet: 修复钱包
repair_desc: 检查钱包,必要时修复和恢复丢失的输出. 此操作需要时间.
repair_unavailable: 需要与节点建立有效连接并完成钱包同步.
repair_desc: 检查钱包,必要时修复和恢复丢失的输出此操作需要一定时间
repair_unavailable: 需要活跃的节点连接并完成钱包同步
delete: 删除钱包
delete_conf: 确定要删除钱包吗?
delete_desc: 确保已保存恢复助记,以便日后使用资金。.
wallet_loading_err: '同步钱包时出错,你可以通过选择屏幕底部的按钮 %{settings} 来重试或更改连接设置.'
delete_conf: 确定要删除钱包吗?
delete_desc: 确保已保存助记,以便日后访问资金。
wallet_loading_err: '钱包同步时发生错误,你可以重试或通过选择屏幕底部的 %{settings} 更改连接设置'
wallet: 钱包
send: 发送
receive: 接收
settings: 钱包设置
tx_send_cancel_conf: '确定要取消 %{amount} ツ的发送吗?'
tx_receive_cancel_conf: '确定要取消 %{amount} ツ的接收吗?'
rec_phrase_not_found: 找不到恢复助记词.
restore_wallet_desc: 如果常规修复没有帮助,通过删除所有文件来恢复钱包.您将需要重新打开您的钱包.
fee_base_desc: '费用 (基值%{value}):'
payment_proof: 付款證
payment_proof_desc: '輸入已收款證明以驗證交易:'
payment_proof_valid: '入的付款證明有效:'
payment_proof_error: '入的付款證明無效:'
tx_delete_confirmation: 你確定要從歷史紀錄中刪除這筆交易
tx_send_cancel_conf: '确定要取消发送 %{amount} ツ 吗?'
tx_receive_cancel_conf: '确定要取消接收 %{amount} ツ 吗?'
rec_phrase_not_found: 未找到助记词
restore_wallet_desc: 如果常规修复无效,可通过删除所有文件来恢复钱包,你需要重新打开钱包
fee_base_desc: '手续费(基础值%{value}):'
payment_proof: 支付证
payment_proof_desc: '输入收到的支付证明以验证交易'
payment_proof_valid: '入的支付证明有效'
payment_proof_error: '入的支付证明无效:'
tx_delete_confirmation: 确定要从历史记录中删除该交易
transport:
desc: '使用传输同步接收或发送消息:'
desc: '使用传输方式同步收发消息'
tor_network: Tor 网络
connected: 已连接
connecting: 正在连接
disconnecting: 断开连接
connecting: 连接
disconnecting: 断开
conn_error: 连接错误
disconnected: 已断开连接
receiver_address: '接收者的地址:'
incorrect_addr_err: '输入的地址不正确:'
tor_send_error: 通过 Tor 发送时出错,请确接收方在线, 交易已取消.
tor_autorun_desc: 是否在开钱包时启动 Tor 服务以同步接收交易.
tor_sending: 通过 Tor 发送
disconnected: 已断开
receiver_address: '接收地址'
incorrect_addr_err: '输入的地址不正确'
tor_send_error: 通过 Tor 发送时发生错误,请确接收方在线交易已取消
tor_autorun_desc: 是否在开钱包时启动 Tor 服务以同步接收交易
tor_sending: 正在通过 Tor 发送
tor_settings: Tor 设置
bridges:
bridges_desc: 如果常规连接不正常,设置网桥,可以绕过 Tor 网络审查.
bin_file: '二进制文件:'
conn_line: '连接线:'
bridges:
bridges_desc: 如果常规连接无法使用,设置网桥以绕过 Tor 网络审查
bin_file: '二进制文件'
conn_line: '连接线路:'
bridges_disabled: 网桥已禁用
bridge_name: '网桥%{b}'
bridge_name: '网桥 %{b}'
network:
self: 网络
type: '网络类型:'
type: '网络类型'
mainnet: 主网
testnet: 测试网
connections: 连接
@@ -176,143 +176,143 @@ network:
settings: 节点设置
enable_node: 启用节点
autorun: 自动运行
disabled_server: '按屏幕左上角的按钮 %{dots}启用集成节点或添加其他连接方法.'
no_ips: T您的系统上没有可用的 IP 地址,服务器无法启动,请检查您的网络连接.
disabled_server: '请启用集成节点,或按屏幕左上角的 %{dots} 添加其他连接方式。'
no_ips: 的系统上没有可用的 IP 地址,无法启动服务器,请检查网络连接
available: 可用
not_available: 不可用
availability_check: 检查是否可用
android_warning: Android 用户注意 .要成功同步集成节点,必须在手机系统设置中允许访问通知并取消 Grim 应用程序的电池使用限制.这是在后台正运行应用程序的必要操作.
availability_check: 可用性检查
android_warning: 安卓用户注意要成功同步集成节点,必须在手机系统设置中允许 Grim 应用发送通知,并移除电池使用限制这是应用在后台正运行所必需的操作
sync_status:
node_restarting: 节点正在重新启动
node_down: 节点已关闭
node_restarting: 节点正在重
node_down: 节点已停止
initial: 节点正在启动
no_sync: 节点正在运行
awaiting_peers: 等待网络对点
header_sync: 正下载标题
header_sync_percent: '正在下载标题: %{percent}%'
tx_hashset_pibd: 下载状态 (PIBD)
tx_hashset_pibd_percent: '下载状态 (PIBD): %{percent}%'
awaiting_peers: 等待对等节
header_sync: 下载区块头
header_sync_percent: '正在下载区块头:%{percent}%'
tx_hashset_pibd: 正在下载状态PIBD
tx_hashset_pibd_percent: '正在下载状态PIBD):%{percent}%'
tx_hashset_download: 正在下载状态
tx_hashset_download_percent: '下载状态: %{percent}%'
tx_hashset_setup_history: '正在准备状态(历史记录): %{percent}%'
tx_hashset_setup_position: '正在准备状态(位置): %{percent}%'
tx_hashset_download_percent: '正在下载状态:%{percent}%'
tx_hashset_setup_history: '正在准备状态(历史):%{percent}%'
tx_hashset_setup_position: '正在准备状态(位置)%{percent}%'
tx_hashset_setup: 正在准备状态
tx_hashset_range_proofs_validation: '验证状态(范围证明): %{percent}%'
tx_hashset_kernels_validation: '正在验证状态(核心): %{percent}%'
tx_hashset_save: 最终确链状态
body_sync: 下载区块
body_sync_percent: '下载区块中: %{percent}%'
tx_hashset_range_proofs_validation: '正在验证状态(范围证明)%{percent}%'
tx_hashset_kernels_validation: '正在验证状态(内核):%{percent}%'
tx_hashset_save: 正在最终确链状态
body_sync: 正在下载区块
body_sync_percent: '正在下载区块:%{percent}%'
shutdown: 节点正在关闭
network_node:
header: 标题
header: 区块头
block: 区块
hash: 哈希
hash: 哈希
height: 高度
difficulty: 难度
time: 时间
main_pool: 主池
stem_pool: stem
stem_pool:
data: 数据
size: 大小 (GB)
peers: 网络对点
error_clean: 点数据已损坏,需要重新同步.
size: 大小GB
peers: 等节
error_clean: 点数据已损坏,需要重新同步
resync: 重新同步
error_p2p_api: '%{p2p_api} 服务器初始化时出错,请选择屏幕底部的按钮 %{p2p_api} 检查 %{settings}设置.'
error_config: '配置初始化时出错,请选择屏幕底部的按钮 %{settings} 检查设置.'
error_unknown: '初始化时出错,请选择屏幕底部的按钮 %{settings} 检查集成节点设置,或重新同步.'
error_p2p_api: '%{p2p_api} 服务器初始化时发生错误,请通过选择屏幕底部的 %{settings} 检查 %{p2p_api} 设置'
error_config: '配置初始化时发生错误,请通过选择屏幕底部的 %{settings} 检查设置'
error_unknown: '初始化时发生未知错误,请通过选择屏幕底部的 %{settings} 检查集成节点设置,或重新同步'
network_metrics:
loading: 指标在同步后可用
emission:
inflation: 货膨
supply: 供应
block_time: Block time
loading: 指标在同步完成后可用
emission: 行量
inflation: 通胀
supply: 供应
block_time: 出块时间
reward: 奖励
difficulty_window: '难度窗口 %{size}'
network_mining:
loading: 同步后即可挖矿
info: '挖矿服务器已启用,可以通过选择屏幕底部的按钮 %{settings} 更改其设置。连接设备后,数据会更新.'
restart_server_required: 需要重启服务器才能应用更改.
rewards_wallet: 奖励钱包
server: 阶层服务器
loading: 挖矿将在同步完成后可用
info: '挖矿服务器已启用,可以通过选择屏幕底部的 %{settings} 更改其设置。数据在设备连接时更新'
restart_server_required: 需要重启服务器应用更改
rewards_wallet: 奖励接收钱包
server: Stratum 服务器
address: 地址
miners: 矿工
devices: 设备
blocks_found: 找到区块
hashrate: '哈希率 (C%{bits})'
blocks_found: 找到区块
hashrate: '算力(C%{bits}'
connected: 已连接
disconnected: 已断开连接
disconnected: 已断开
network_settings:
change_value: 更改值
stratum_ip: ' IP 地址:'
stratum_port: '端口:'
stratum_ip: 'Stratum IP 地址'
stratum_port: 'Stratum 端口'
port_unavailable: 指定的端口不可用
restart_node_required: 需要重启节点才能应用更改.
restart_node_required: 需要重启节点应用更改
choose_wallet: 选择钱包
stratum_wallet_warning: 必须打开钱包才能获得奖励.
stratum_wallet_warning: 必须打开钱包才能接收奖励
enable: 启用
disable: 禁用
restart: 新启动
restart:
server: 服务器
api_ip: 'API IP 地址:'
api_port: 'API 端口:'
api_secret: '其它API 和 V2 所有者 API 令牌:'
foreign_api_secret: '外部 API 令牌:'
api_ip: 'API IP 地址'
api_port: 'API 端口'
api_secret: 'Rest API 和 V2 所有者 API 令牌'
foreign_api_secret: '外部 API 令牌'
disabled: 已禁用
enabled: 已启用
ftl: '未来时间限制 (FTL):'
ftl_description: 限制未来多长时间, 相对于节点本地时间,以秒为单位, 新区块时间戳可以被接受.
ftl: '未来时间限制FTL):'
ftl_description: 相对于节点本地时间(秒),新区块时间戳可以超前的最大限制,超过则区块不被接受
not_valid_value: 输入的值无效
full_validation: 验证
full_validation_description: 在处理每个区块时是否运行全链验证(同步期间除外).
archive_mode: 档模式
archive_mode_desc: 全部存档模式运行节点(同步需要更多磁盘空间和时间).
attempt_time: '尝试挖矿时间 (秒):'
attempt_time_desc: 在停止并从池中重新收集交易之前尝试特定标题进行挖矿的时间
min_share_diff: '可接受的最低份额难度:'
full_validation: 验证
full_validation_description: 是否在处理每个区块时运行完整链验证同步期间除外)。
archive_mode: 档模式
archive_mode_desc: 完整归档模式运行节点(同步需要更多磁盘空间和时间)
attempt_time: '挖矿尝试时间(秒):'
attempt_time_desc: 在停止并重新从交易池收集交易之前尝试特定区块头上挖矿的时间
min_share_diff: '可接受的最低份额难度'
reset_settings_desc: 将节点设置重置为默认值
reset_settings: 重置设置
reset: 重置
tx_pool: 交易池
pool_fee: '接受到矿池的基本费用:'
reorg_period: '重组缓存保留期(分钟为单位):'
max_tx_pool: '池中最大交易数:'
max_tx_stempool: 'stem池中最大交易数:'
max_tx_weight: '可以选择构建区块交易的最大总权重:'
epoch_duration: '纪元持续时间(以秒为单位):'
pool_fee: '交易池接受的基础手续费:'
reorg_period: '重组缓存保留期(分钟):'
max_tx_pool: '交易池中最大交易数'
max_tx_stempool: '池中最大交易数'
max_tx_weight: '可被选中构建区块的最大交易总权重'
epoch_duration: '纪元持续时间(秒):'
embargo_timer: '禁止计时器(以秒为单位):'
aggregation_period: '聚合周期(以秒为单位):'
stem_probability: 'stem助记词概率:'
stem_txs: stem交易
aggregation_period: '聚合周期(秒):'
stem_probability: '茎阶段概率'
stem_txs: 茎阶段交易
p2p_server: P2P 服务器
p2p_port: 'P2P 端口:'
add_seed: 添加 DNS 种子
seed_address: 'DNS 种子地址:'
add_peer: 添加网络对点
peer_address: '网络对点地址:'
peer_address_error: '正确格式输入 IP 地址或 DNS 名称(确保指定主机可用),例如:192.168.0.11234 或 example.com:5678'
p2p_port: 'P2P 端口'
add_seed: 添加 DNS 种子节点
seed_address: 'DNS 种子节点地址'
add_peer: 添加对等节
peer_address: '对等节点地址'
peer_address_error: '请输入正确格式 IP 地址或 DNS 名称(确保指定主机可用),例如:192.168.0.1:1234 或 example.com:5678'
default: 默认
allow_list: 允许列表
allow_list_desc: 仅连接到此列表中的网络对点.
allow_list_desc: 仅连接到此列表中的对等节点。
deny_list: 拒绝列表
deny_list_desc: 切勿连接到此列表中的网络对点.
deny_list_desc: 永不连接到此列表中的对等节点。
favourites: 收藏夹
favourites_desc: 连接的首选网络对点列表.
ban_window: '被封禁的网络对点应该保持被封禁多长时间(以秒为单位):'
ban_window_desc: 止的决定由节点 根据从网络对点收到的数据的正确性做出的.
max_inbound_count: '入站网络对点连接的最大数量:'
max_outbound_count: '最大出站网络对点连接数:'
reset_data_desc: 重置节点数据。只有在出现同步问题时才需谨慎使用.
favourites_desc: 优先连接的对等节点列表
ban_window: '被封禁对等节点的封禁时长(秒):'
ban_window_desc: 禁决定由节点根据从对等节点接收数据的正确性做出
max_inbound_count: '最大入站对等连接数:'
max_outbound_count: '最大出站对等连接数'
reset_data_desc: 重置节点数据。请谨慎使用,仅在同步出现问题时使用
reset_data: 重置数据
modal:
cancel: 取消
save: 保存
add: 添加
modal_exit:
description: 确定要退出应用程序吗?
exit: 退出
description: 确定要退出应用吗?
exit: 退出
app_settings:
proxy: 代理
proxy_desc: 是否值得对来自应用程序的网络请求使用代理.
proxy_desc: 是否为应用的网络请求使用代理
keyboard:
1: 1
2: 2
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "已通过 Tor 连接"
nym_ready: "Tor 就绪 · 连接中继…"
connecting_nym: "正在连接 Tor…"
connected_tor: "已通过 Tor 连接"
tor_ready: "Tor 就绪 · 连接中继…"
connecting_tor: "正在连接 Tor…"
cant_reach_node: "无法连接节点"
node_synced: "节点已同步"
syncing: "同步中…"
@@ -420,7 +420,7 @@ goblin:
nostr: "nostr"
identity: "身份"
fee_none: "无"
network_fee: "网络费"
network_fee: "网络手续费"
privacy: "隐私"
privacy_value: "Mimblewimble + Tor"
transaction: "交易"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "切换钱包"
advanced: "高级"
privacy: "隐私"
mixnet_routing: "Tor 路由"
tor_routing: "Tor 路由"
messages_lookups: "消息和查询"
auto_accept: "自动接受"
pairing: "价格货币"
@@ -502,6 +502,7 @@ goblin:
theme_yellow: "黄色"
archive: "存档"
export_archive: "导出存档"
export_archive_caption: "将你的联系人、付款记录和请求以 JSON 格式复制到剪贴板。"
wipe_history: "清除付款记录"
wipe_history_confirm: "再次点按以清除 — 无法撤销"
about: "关于"
@@ -512,7 +513,7 @@ goblin:
third_party: "第三方"
grim: "GRIM(上游钱包)"
grin_node: "Grin 节点"
sp_intro: "高级功能 — 像 GRIM 那样手动交换原始 slatepack。仅在无法通过 username 收付款时使用。"
sp_intro: "高级功能 — 像 GRIM 那样手动交换原始 slatepack。仅在无法通过用户名收付款时使用。"
sp_receive_group: "接收或确认"
sp_receive_blurb: "粘贴别人给你的 slatepack。Goblin 会接收付款、支付账单,或确认并广播到链上。"
sp_process: "处理 slatepack"
@@ -550,9 +551,9 @@ goblin:
choose_backup_file: "选择 .backup 文件"
backup_read_failed: "无法读取该文件。"
backup_saved: "备份已保存"
backup_saved_sub: "妥善保管 .backup 文件——同时拥有它和你密码的人都能恢复你的身份。"
backup_file_title: "备份身份"
backup_file_blurb: "创建一个包含你的用户名和密钥的加密 .backup 文件。输入钱包密码以封存它。"
backup_saved_sub: "妥善保管 .backup 文件同时拥有它和你密码的人都能恢复你的整个钱包。"
backup_file_title: "备份钱包"
backup_file_blurb: "创建一个包含你的钱包助记词和所有身份的加密 .backup 文件。输入钱包密码以封存它。"
backup_write_failed: "无法保存文件。"
create_backup: "创建备份"
registered: "已注册 %{name}"
@@ -586,10 +587,10 @@ goblin:
notif_private_requested: "新的付款请求。打开 Goblin 查看。"
username:
title: "用户名"
authority: "名称机构"
authority: "名称授权方"
authority_blurb: "保持默认,或指向其他实例以使用其托管的名称。"
learn_more: "了解更多"
custom: "自定义机构"
custom: "自定义授权方"
advprivacy:
intro: "控制通知会透露的内容,并在此设备上隐藏你的余额和历史记录。"
notifications: "通知"
@@ -598,8 +599,8 @@ goblin:
hide_details: "隐藏全部详情"
hide_details_sub: "仅显示私密提醒,不含名称或金额"
anon: "匿名模式"
anon_toggle: "模糊余额和动"
anon_sub: "余额和动显示为圆点,点按后才会显示"
anon_toggle: "模糊余额和动"
anon_sub: "余额和动显示为圆点,点按后才会显示"
advanced:
title: "高级"
intro: "来自 GRIM 的底层钱包工具。通常你用不到这些。"
@@ -619,18 +620,24 @@ goblin:
password: "钱包密码"
wrong_password: "密码错误。"
delete: "删除钱包"
delete_desc: "从此设备永久移除该钱包。没有助记词,资金将无法找回。"
delete_desc: "从此设备永久移除该钱包及其所有身份。请先备份。没有助记词,资金将无法找回。"
delete_confirm: "再次点击以删除"
manage_node: "管理节点连接"
repair_confirm: "是的,立即修复"
repair_confirm_note: "修复会重新扫描链,可能需要几分钟。"
restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。"
nostr_key: "Nostr 密钥"
nostr_key_desc: "的 nsec,即 Nostr 身份的私钥。复制它或显示二维码,即可登录 magick.market 等 Nostr 应用。持有它的人即可控制的身份,请妥善保管。"
nostr_key_desc: "的 nsec,即 nostr 身份的私钥。复制它或显示二维码,即可登录 magick.market 等 nostr 应用。持有它的人即可控制的身份,请妥善保管。"
reveal_nsec: "显示密钥"
copy_nsec: "复制 nsec"
show_qr: "显示二维码"
hide_qr: "隐藏二维码"
nostr_section: "高级 nostr 设置"
backup_caption: "包含你的钱包和所有身份。"
danger_zone: "危险区域"
delete_warning: "这将删除此设备上的恢复助记词和所有身份。未备份的内容将永久丢失。"
download_backup: "下载备份"
delete_final: "永久删除"
privacy:
title: "网络隐私"
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
@@ -640,7 +647,7 @@ goblin:
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
price_avatars: "价格"
price_avatars_blurb: "金额旁显示的实时法币汇率。"
over_mixnet: "经由 Tor"
over_tor: "经由 Tor"
direct_connection: "直接连接"
grin_node: "Grin 节点"
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
@@ -672,7 +679,7 @@ goblin:
nips:
title: "nostr 与 NIPs"
intro1: "Goblin 使用 nostr — 一种通过简单中继服务器传递签名消息的开放协议。你的钱包拥有自己的 nostr 身份:一个独立的随机密钥,刻意与你的资金和助记词保持独立。每笔付款都作为身份之间的端到端加密私信传输,slatepack 就包含在其中。"
intro2: "goblin.st 是 Goblin 的名称服务:注册用户名会在此发布名称 → 密钥的映射(NIP-05),让人们可以付款给 you 而不必使用冗长的 npub。用户名是公开的;付款内容则永不公开。NIPs 是该协议的构建模块 — 点击任意一项可阅读规范。"
intro2: "goblin.st 是 Goblin 的名称服务:注册用户名会在此发布名称 → 密钥的映射(NIP-05),让人们可以付款给而不必使用冗长的 npub。用户名是公开的;付款内容则永不公开。NIPs 是该协议的构建模块 — 点击任意一项可阅读规范。"
n05_title: "名称"
n05_blurb: "将 username@goblin.st 映射到你的密钥,让用户名像地址一样使用。"
n17_title: "私密消息"
@@ -734,7 +741,7 @@ goblin:
cat_identity: "资料和列表"
cat_delete: "删除"
cat_http: "上传和 HTTP 认证"
cat_unknown: "其他事件类型(kind %{n}"
cat_unknown: "其他事件类型(类型 %{n}"
login_excluded: "登录权限绝不会授予网站。"
money_line: "涉及资金时始终需要你的密码。绝不会静默花费或出售。"
duration: "仅本次会话有效。可随时在设置,受信任网站中结束。"
@@ -744,7 +751,7 @@ goblin:
failed: "无法连接 %{domain}。会话未启动。"
announce_failed: "已信任,但 %{domain} 尚未确认会话。"
notice_volume: "某个受信任网站签署量过大。"
notice_decrypt: "某个受信任网站正在读取的消息。"
notice_decrypt: "某个受信任网站正在读取的消息。"
money:
title: "确认"
headline: "在 %{domain} 中确认?"
@@ -793,7 +800,7 @@ goblin:
private_money_head: "私密货币"
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
send_like_message_head: "像发消息一样付款"
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
send_like_message_body: "向用户名或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
yours_alone_head: "完全属于你"
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
get_started: "开始使用"
@@ -835,27 +842,30 @@ goblin:
restore_wallet: "恢复钱包"
wrote_them_down: "我已抄好"
fill_every_word: "填写每个词 — 点击某个词进行编辑,或粘贴整个短语。"
unlock_backup: "解锁备份"
backup_unlocked: "备份已解锁。钱包打开时将恢复你的身份。"
confirm:
kicker: "步骤 2 / 3 · 钱包"
title: "现在来验证"
enter_hint: "输入你刚抄下的词。点击某个词进行输入。"
paste: "粘贴"
create_wallet: "创建钱包"
keep_going: "继续 — 每个词,按顺序。"
keep_going: "继续 — 每个词按顺序。"
identity:
kicker: "步骤 3 / 3 · 身份"
title: "你的付款身份"
key_being_made: "正在生成密钥…"
connected_nym: "已通过 Tor 连接"
connecting_nym: "正在通过 Tor 连接…"
connected_tor: "已通过 Tor 连接"
connecting_tor: "正在通过 Tor 连接…"
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
restoring: "正在恢复你的身份…"
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
pick_username: "选择用户名 — 可选"
username_blurb: "朋友支付给你的名称,而不是一长串密钥。可选——随时认领。"
username_field_hint: "你的用户名"
working: "处理中…"
claim_username: "注册用户名"
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
available_when_connected: "Tor 连接后可用 — 或跳过稍后注册。"
youre: "你是 %{name}"
claimed_title: "%{name} 已归你所有"
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
@@ -874,9 +884,9 @@ goblin:
tab_my_code: "我的二维码"
request_from: "向谁请求"
send_to: "发送给"
search_hint: "handle、npub 或名称"
search_hint: "用户名、npub 或名称"
suggested: "%{icon} 建议"
no_contacts: "暂无联系人。通过 handle 查找某人。"
no_contacts: "暂无联系人。通过用户名查找某人。"
no_profile: "无资料"
tag_contact: "联系人"
tag_on_nostr: "在 nostr 上"
@@ -885,13 +895,13 @@ goblin:
unverified_body: "此密钥未发布 nostr 资料 — 它可能是全新的、匿名的或输错的。发送前请仔细核对是否正确。"
keep_looking: "继续查找"
pay_anyway: "仍然付款"
scan_not_recipient: "该二维码不是 goblin 收款方 — 应为 npub 或 handle"
scan_not_recipient: "该二维码不是 goblin 收款方 — 应为 npub 或用户名"
scan_prompt: "将 goblin 二维码对准取景框以激活"
scan_to_pay_me: "扫码向我付款"
share_btn: "%{icon} 分享"
share_message: "在 Goblin 上向我付款 — %{handle}\n%{link}\nnpub%{npub}"
none_found: "未找到与 %{label} 匹配的人"
enter_recipient: "输入 handle、npub 或名称"
enter_recipient: "输入用户名、npub 或名称"
amount_title: "金额"
to_name: "发送给 %{name}"
not_enough: "你的 grin 余额不足"
@@ -916,8 +926,8 @@ goblin:
row_they_pay: "对方支付"
row_they_pay_val: "仅当对方同意时"
row_delivery: "传输"
row_delivery_val: "NIP-44 加密,经由 Tor"
row_network_fee: "网络费"
row_delivery_val: "NIP-44 加密经由 Tor"
row_network_fee: "网络手续费"
row_network_fee_val: "从你的余额中扣除"
row_privacy: "隐私"
row_privacy_val: "Mimblewimble + Tor"
+956
View File
@@ -0,0 +1,956 @@
lang_name: 繁體中文
copy: 複製
paste: 貼上
continue: 繼續
complete: 完成
error: 錯誤
retry: 重試
close: 關閉
change: 變更
show: 顯示
delete: 刪除
clear: 清除
create: 建立
id: 識別碼
kernel: 核心
settings: 設定
language: 語言
scan: 掃描
qr_code: QR 條碼
scan_qr: 掃描 QR 條碼
repeat: 重複
scan_result: 掃描結果
back: 返回
share: 分享
theme: '主題:'
dark: 深色
light: 淺色
file: 檔案
choose_file: 選擇檔案
choose_folder: 選擇資料夾
crash_report: 當機報告
crash_report_warning: 應用程式上次意外關閉,你可以將當機報告分享給開發者。
confirmation: 確認
enter_url: 輸入網址
max_short: 最大
files_location: 檔案位置
moving_files: 正在移動檔案
wrong_path_error: 指定的路徑錯誤
check_updates: 啟動時檢查更新
update_available: 有可用更新!
changelog: '更新日誌:'
wallets:
await_conf_amount: 等待確認
await_fin_amount: 等待最終確認
locked_amount: 已鎖定
txs_empty: '要手動或透過傳輸方式接收資金,請使用畫面底部的 %{message} 或 %{transport} 按鈕;要變更錢包設定,請按 %{settings} 按鈕。'
title: 錢包
create_desc: 建立新錢包或從已儲存的助記詞匯入現有錢包。
add: 新增錢包
name: '名稱:'
pass: '密碼:'
pass_empty: 輸入錢包密碼
current_pass: '目前密碼:'
new_pass: '新密碼:'
min_tx_conf_count: '交易的最少確認數:'
recover: 復原
recovery_phrase: 助記詞
words_count: '單字數量:'
enter_word: '輸入第 %{number} 個單字:'
not_valid_word: 輸入的單字無效
not_valid_phrase: 輸入的助記詞無效
create_phrase_desc: 請安全地寫下並儲存你的助記詞。
restore_phrase_desc: 輸入你儲存的助記詞中的單字。
setup_conn_desc: 選擇錢包連接網路的方式。
conn_method: 連線方式
ext_conn: '外部連線:'
add_node: 新增節點
node_url: '節點網址:'
node_secret: 'API 金鑰(選擇性):'
invalid_url: 輸入的網址無效
open: 開啟錢包
wrong_pass: 輸入的密碼錯誤
locked: 已鎖定
unlocked: 已解鎖
enable_node: '請啟用整合節點以使用錢包,或透過選擇畫面底部的 %{settings} 變更連線設定。'
node_loading: '錢包將在整合節點同步完成後載入,你可以透過選擇畫面底部的 %{settings} 變更連線方式。'
loading: 載入中
closing: 關閉中
checking: 檢查中
default_wallet: 預設錢包
new_account_desc: '輸入新帳戶名稱:'
wallet_loading: 正在載入錢包
wallet_closing: 正在關閉錢包
wallet_checking: 正在檢查錢包
tx_loading: 正在載入交易
default_account: 預設帳戶
accounts: 帳戶
tx_sent: 已傳送
tx_received: 已接收
tx_sending: 傳送中
tx_receiving: 接收中
tx_confirming: 等待確認
tx_canceled: 已取消
tx_cancelling: 取消中
tx_finalizing: 最終確認中
tx_posting: 提交中
tx_confirmed: 已確認
tx_stale: 等待時間過長
txs: 交易記錄
tx: 交易
messages: 訊息
transport: 傳輸
input_slatepack_desc: '輸入收到的 Slatepack 訊息以建立回應或完成請求:'
parse_slatepack_err: '讀取訊息時發生錯誤,請檢查輸入內容:'
pay_balance_error: '帳戶餘額不足以支付 %{amount} ツ 和網路手續費。'
parse_i1_slatepack_desc: '要支付 %{amount} ツ,請將此訊息傳送給接收方:'
parse_i2_slatepack_desc: '完成交易以接收 %{amount} ツ:'
parse_i3_slatepack_desc: '提交交易以完成 %{amount} ツ 的接收:'
parse_s1_slatepack_desc: '要接收 %{amount} ツ,請將此訊息傳送給發送方:'
parse_s2_slatepack_desc: '完成交易以傳送 %{amount} ツ:'
parse_s3_slatepack_desc: '提交交易以完成 %{amount} ツ 的傳送:'
resp_slatepack_err: '建立回應時發生錯誤,請檢查輸入資料或重試:'
resp_exists_err: 該交易已存在。
resp_canceled_err: 該交易已被取消。
create_request_desc: '建立傳送或接收資金的請求:'
send_request_desc: '你已建立傳送 %{amount} ツ 的請求。請將此訊息傳送給接收方:'
send_slatepack_err: 建立傳送資金請求時發生錯誤,請檢查輸入資料或重試。
invoice_desc: '你已建立接收 %{amount} ツ 的請求。請將此訊息傳送給發送方:'
invoice_slatepack_err: 開立發票時發生錯誤,請檢查輸入資料或重試。
finalize_slatepack_err: '最終確認時發生錯誤,請檢查輸入資料或重試:'
finalize: 最終確認
use_dandelion: 使用蒲公英(Dandelion
enter_amount_send: '你有 %{amount} ツ。輸入傳送金額:'
enter_amount_receive: '輸入接收金額:'
recovery: 復原
repair_wallet: 修復錢包
repair_desc: 檢查錢包,必要時修復和還原遺失的輸出。此操作需要一些時間。
repair_unavailable: 你需要活躍的節點連線並完成錢包同步。
delete: 刪除錢包
delete_conf: 確定要刪除錢包嗎?
delete_desc: 請確保你已儲存助記詞,以便日後存取資金。
wallet_loading_err: '錢包同步時發生錯誤,你可以重試或透過選擇畫面底部的 %{settings} 變更連線設定。'
wallet: 錢包
send: 傳送
receive: 接收
settings: 錢包設定
tx_send_cancel_conf: '確定要取消傳送 %{amount} ツ 嗎?'
tx_receive_cancel_conf: '確定要取消接收 %{amount} ツ 嗎?'
rec_phrase_not_found: 找不到助記詞。
restore_wallet_desc: 如果一般修復無效,可透過刪除所有檔案來還原錢包,你需要重新開啟錢包。
fee_base_desc: '手續費(基礎值%{value}):'
payment_proof: 支付證明
payment_proof_desc: '輸入收到的支付證明以驗證交易:'
payment_proof_valid: '輸入的支付證明有效:'
payment_proof_error: '輸入的支付證明無效:'
tx_delete_confirmation: 確定要從歷史記錄中刪除該交易嗎?
transport:
desc: '使用傳輸方式同步收發訊息:'
tor_network: Tor 網路
connected: 已連線
connecting: 連線中
disconnecting: 中斷中
conn_error: 連線錯誤
disconnected: 已中斷
receiver_address: '接收方位址:'
incorrect_addr_err: '輸入的位址不正確:'
tor_send_error: 透過 Tor 傳送時發生錯誤,請確認接收方在線上,交易已取消。
tor_autorun_desc: 是否在開啟錢包時啟動 Tor 服務以同步接收交易。
tor_sending: 正在透過 Tor 傳送
tor_settings: Tor 設定
bridges: 橋接
bridges_desc: 如果一般連線無法使用,設定橋接以繞過 Tor 網路審查。
bin_file: '二進位檔案:'
conn_line: '連線線路:'
bridges_disabled: 橋接已停用
bridge_name: '橋接 %{b}'
network:
self: 網路
type: '網路類型:'
mainnet: 主網路
testnet: 測試網路
connections: 連線
node: 整合節點
metrics: 指標
mining: 挖礦
settings: 節點設定
enable_node: 啟用節點
autorun: 自動執行
disabled_server: '請啟用整合節點,或按畫面左上角的 %{dots} 新增其他連線方式。'
no_ips: 你的系統上沒有可用的 IP 位址,無法啟動伺服器,請檢查網路連線。
available: 可用
not_available: 不可用
availability_check: 可用性檢查
android_warning: Android 使用者請注意。要成功同步整合節點,你必須在手機系統設定中允許 Grim 應用程式傳送通知,並移除電池使用限制。這是應用程式在背景正常運作所必需的操作。
sync_status:
node_restarting: 節點正在重新啟動
node_down: 節點已停止
initial: 節點正在啟動
no_sync: 節點正在執行
awaiting_peers: 等待對等節點
header_sync: 正在下載區塊頭
header_sync_percent: '正在下載區塊頭:%{percent}%'
tx_hashset_pibd: 正在下載狀態(PIBD
tx_hashset_pibd_percent: '正在下載狀態(PIBD):%{percent}%'
tx_hashset_download: 正在下載狀態
tx_hashset_download_percent: '正在下載狀態:%{percent}%'
tx_hashset_setup_history: '正在準備狀態(歷史):%{percent}%'
tx_hashset_setup_position: '正在準備狀態(位置):%{percent}%'
tx_hashset_setup: 正在準備狀態
tx_hashset_range_proofs_validation: '正在驗證狀態(範圍證明):%{percent}%'
tx_hashset_kernels_validation: '正在驗證狀態(核心):%{percent}%'
tx_hashset_save: 正在最終確認鏈狀態
body_sync: 正在下載區塊
body_sync_percent: '正在下載區塊:%{percent}%'
shutdown: 節點正在關閉
network_node:
header: 區塊頭
block: 區塊
hash: 雜湊
height: 高度
difficulty: 難度
time: 時間
main_pool: 主池
stem_pool: 莖池
data: 資料
size: 大小(GB
peers: 對等節點
error_clean: 節點資料已損毀,需要重新同步。
resync: 重新同步
error_p2p_api: '%{p2p_api} 伺服器初始化時發生錯誤,請透過選擇畫面底部的 %{settings} 檢查 %{p2p_api} 設定。'
error_config: '設定初始化時發生錯誤,請透過選擇畫面底部的 %{settings} 檢查設定。'
error_unknown: '初始化時發生未知錯誤,請透過選擇畫面底部的 %{settings} 檢查整合節點設定,或重新同步。'
network_metrics:
loading: 指標將在同步完成後可用
emission: 發行量
inflation: 通膨率
supply: 供應量
block_time: 出塊時間
reward: 獎勵
difficulty_window: '難度視窗 %{size}'
network_mining:
loading: 挖礦將在同步完成後可用
info: '挖礦伺服器已啟用,你可以透過選擇畫面底部的 %{settings} 變更其設定。資料在裝置連線時更新。'
restart_server_required: 需要重新啟動伺服器以套用變更。
rewards_wallet: 獎勵接收錢包
server: Stratum 伺服器
address: 位址
miners: 礦工
devices: 裝置
blocks_found: 已找到區塊
hashrate: '算力(C%{bits}'
connected: 已連線
disconnected: 已中斷
network_settings:
change_value: 變更值
stratum_ip: 'Stratum IP 位址:'
stratum_port: 'Stratum 連接埠:'
port_unavailable: 指定的連接埠無法使用
restart_node_required: 需要重新啟動節點以套用變更。
choose_wallet: 選擇錢包
stratum_wallet_warning: 必須開啟錢包才能接收獎勵。
enable: 啟用
disable: 停用
restart: 重新啟動
server: 伺服器
api_ip: 'API IP 位址:'
api_port: 'API 連接埠:'
api_secret: 'Rest API 和 V2 擁有者 API 權杖:'
foreign_api_secret: '外部 API 權杖:'
disabled: 已停用
enabled: 已啟用
ftl: '未來時間限制(FTL):'
ftl_description: 相對於節點本地時間(秒),新區塊時間戳可以超前的最大限制,超過則區塊不被接受。
not_valid_value: 輸入的值無效
full_validation: 完整驗證
full_validation_description: 是否在處理每個區塊時執行完整鏈驗證(同步期間除外)。
archive_mode: 封存模式
archive_mode_desc: 以完整封存模式執行節點(同步需要更多磁碟空間和時間)。
attempt_time: '挖礦嘗試時間(秒):'
attempt_time_desc: 在停止並重新從交易池收集交易之前,嘗試在特定區塊頭上挖礦的時間量
min_share_diff: '可接受的最低份額難度:'
reset_settings_desc: 將節點設定重設為預設值
reset_settings: 重設設定
reset: 重設
tx_pool: 交易池
pool_fee: '交易池接受的基礎手續費:'
reorg_period: '重組快取保留期(分鐘):'
max_tx_pool: '交易池中最大交易數:'
max_tx_stempool: '莖池中最大交易數:'
max_tx_weight: '可被選中建構區塊的最大交易總權重:'
epoch_duration: '紀元持續時間(秒):'
embargo_timer: 'Embargo 計時器(秒):'
aggregation_period: '彙總週期(秒):'
stem_probability: '莖階段機率:'
stem_txs: 莖階段交易
p2p_server: P2P 伺服器
p2p_port: 'P2P 連接埠:'
add_seed: 新增 DNS 種子節點
seed_address: 'DNS 種子節點位址:'
add_peer: 新增對等節點
peer_address: '對等節點位址:'
peer_address_error: '請輸入正確格式的 IP 位址或 DNS 名稱(確保指定主機可用),例如:192.168.0.1:1234 或 example.com:5678'
default: 預設
allow_list: 允許清單
allow_list_desc: 僅連線到此清單中的對等節點。
deny_list: 拒絕清單
deny_list_desc: 永不連線到此清單中的對等節點。
favourites: 我的最愛
favourites_desc: 優先連線的對等節點清單。
ban_window: '被封鎖對等節點的封鎖時長(秒):'
ban_window_desc: 封鎖決定由節點根據從對等節點接收資料的正確性做出。
max_inbound_count: '最大入站對等連線數:'
max_outbound_count: '最大出站對等連線數:'
reset_data_desc: 重設節點資料。請謹慎使用,僅在同步發生問題時使用。
reset_data: 重設資料
modal:
cancel: 取消
save: 儲存
add: 新增
modal_exit:
description: 確定要離開應用程式嗎?
exit: 離開
app_settings:
proxy: 代理
proxy_desc: 是否為應用程式的網路請求使用代理。
keyboard:
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
7: 7
8: 8
9: 9
0: 0
01: '-'
q:
w:
e:
r:
t: 廿
y:
u:
i:
o:
p:
p1: '"'
a:
s:
d:
f:
g:
h:
j:
k:
l:
l1: \
l2: ':'
z:
x:
c:
v:
b:
n:
m:
m1: ','
m2: .
m3: /
goblin:
home:
anonymous: "匿名"
connected_tor: "已透過 Tor 連線"
tor_ready: "Tor 就緒 · 連線中繼…"
connecting_tor: "正在連線 Tor…"
cant_reach_node: "無法連線節點"
node_synced: "節點已同步"
syncing: "同步中…"
balance_updating: "餘額更新中…"
balance_stale: "無法連線節點 · 上次已知餘額"
fiat_unavailable: "匯率不可用"
listening: "正在監聽付款"
block: "區塊 %{height}"
waiting_for_chain: "等待鏈資料…"
nav_wallet: "錢包"
nav_pay: "支付"
nav_activity: "動態"
nav_receive: "收款"
nav_settings: "設定"
back_again: "再按一次返回以切換錢包"
activity: "動態"
news: "新聞"
empty_title: "暫無動態"
empty_sub: "收發 grin 即可開始。"
recent: "最近"
scan_to_pay: "掃碼支付"
type_amount: "輸入金額"
request: "請求"
pay: "支付"
enter_amount: "輸入要支付或請求的金額"
activity:
canceled: "已取消"
pending: "待處理"
earlier: "更早"
today: "今天"
yesterday: "昨天"
title: "動態"
requests: "請求"
empty_title: "暫無動態"
empty_sub: "你的付款將顯示在這裡。"
pending_header: "待處理"
receipt:
title: "收據"
not_found: "未找到交易"
for_note: "用於 %{note}"
details: "交易詳情"
canceled: "已取消"
expired: "已過期"
funds_returned: "資金已退回"
complete: "已完成"
payment_received: "已收到付款"
payment_sent: "付款傳送成功"
pending: "待處理"
confs: "%{c}/%{r} 次確認"
waiting_to_confirm: "等待確認"
paying: "支付中…"
you: "你"
to: "收款方"
from: "付款方"
nostr: "nostr"
identity: "身份"
fee_none: "無"
network_fee: "網路手續費"
privacy: "隱私"
privacy_value: "Mimblewimble + Tor"
transaction: "交易"
cancel_request: "取消請求"
cancel_send: "取消付款"
stale_note: "此付款已等待一段時間。"
cancel_send_confirm: "再次點按以取消 — 對方可能仍會收到"
cancel_send_done: "付款已取消 — 你的資金已重新可用"
cancel_send_too_late: "這筆付款已經完成,無法取消"
waiting_to_receive: "等待 %{name} 接收…"
request:
title: "%{name} 發起請求"
approve: "同意"
decline: "拒絕"
review_title: "審核請求"
hold_to_accept: "按住以接受"
hold_accept_hint: "按住以支付此請求"
receive:
title: "收款"
requesting: "正在請求 %{amt}%{tsu} — 分享以收款"
clear_request: "清除請求"
share_handle: "分享你的使用者名稱以收款"
share_npub: "分享你的 npub 以收款"
copied: "已複製"
copy_nostr_id: "複製 nostr ID"
copy_address: "複製地址"
copy_npub: "複製 npub"
share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}"
privacy_note: "你的使用者名稱是公開的。付款內容在網路中保持加密。"
privacy_note_npub: "你的 npub 是公開的。付款內容在網路中保持加密。"
profile:
title: "資料"
activity: "動態"
no_activity: "尚無往來記錄。"
unblock: "解除封鎖"
block: "封鎖"
blocked_blurb: "已封鎖 — 其付款和請求會被丟棄。"
block_blurb: "封鎖後將丟棄對方發來的付款和請求。"
settings:
title: "設定"
connected_nostr: "已連線 nostr"
connecting_relays: "正在連線中繼…"
identity: "身份"
copy_npub: "複製 npub(公開)"
backup_note: "更換裝置?兩者都要備份:你的助記詞(資金)和身份 .backup 檔案(名稱 + 金鑰)。"
wallet: "錢包"
display_unit: "顯示單位"
relays: "中繼"
nostr_relays: "Nostr 中繼"
node: "節點"
min_conf: "最少確認數"
integrated_node: "整合節點設定"
node_advanced: "進階"
slatepacks: "Slatepack"
slatepacks_value: "手動交易"
trusted_sites: "受信任網站"
lock_wallet: "鎖定錢包"
switch_wallet: "切換錢包"
advanced: "進階"
privacy: "隱私"
tor_routing: "Tor 路由"
messages_lookups: "訊息和查詢"
auto_accept: "自動接受"
pairing: "價格貨幣"
accept_anyone: "任何人"
accept_contacts: "僅聯絡人"
accept_ask: "每次詢問"
requests: "請求"
incoming_requests: "收到的請求"
incoming_requests_sub: "允許他人向你請求付款"
hide_amounts: "隱藏金額"
hide_amounts_sub: "在通知中隱藏收到的金額"
language: "語言"
update_available: "有可用更新"
appearance: "外觀"
theme: "主題"
theme_light: "淺色"
theme_dark: "深色"
theme_yellow: "黃色"
archive: "存檔"
export_archive: "匯出存檔"
export_archive_caption: "將你的聯絡人、付款記錄和請求以 JSON 格式複製到剪貼簿。"
wipe_history: "清除付款記錄"
wipe_history_confirm: "再次點按以清除 — 無法撤銷"
about: "關於"
goblin: "Goblin"
build: "構建 %{build}"
network: "網路"
network_value: "MW + Tor + nostr"
third_party: "第三方"
grim: "GRIM(上游錢包)"
grin_node: "Grin 節點"
sp_intro: "進階功能 — 像 GRIM 那樣手動交換原始 slatepack。僅在無法透過使用者名稱收付款時使用。"
sp_receive_group: "接收或確認"
sp_receive_blurb: "貼上別人給你的 slatepack。Goblin 會接收付款、支付帳單,或確認並廣播到鏈上。"
sp_process: "處理 slatepack"
sp_paste_first: "請先貼上 slatepack。"
sp_reply_ready: "回覆已就緒 — 發回給傳送方。"
sp_finalizing: "正在確認並廣播到鏈上…"
sp_create_group: "建立付款"
sp_create_blurb: "生成一個 slatepack 交給他人。對方接收後將回復發回,你在上方確認即可。"
sp_amount_hint: "金額(grin"
sp_addr_hint: "收款地址(可選)"
sp_create: "建立 slatepack"
sp_ready: "slatepack 已就緒 — 交給收款方。"
sp_amount_gt_zero: "請輸入大於零的金額。"
sp_to_send: "待發送的 slatepack"
sp_copy: "複製 slatepack"
cancel: "取消"
continue: "繼續"
final_confirmation: "最終確認"
type_reset: "輸入 RESET"
wallet_password: "錢包密碼"
done: "完成"
close: "關閉"
import_nsec_hint: "nsec1… 或貼上的備份"
backup_password_hint: "備份密碼(僅當在他處匯出時需要)"
import_btn: "匯入"
importing: "正在匯入…"
import_failed: "匯入失敗"
name_authority: "名稱授權方"
name_authority_title: "更改名稱授權方"
name_authority_blurb: "註冊和驗證名稱的伺服器。指向另一個實例即可使用並支付那裡託管的名稱。"
name_authority_invalid: "請輸入完整網址(https://…)。"
reset: "重置"
save: "儲存"
backup_file: "備份到檔案"
choose_backup_file: "選擇 .backup 檔案"
backup_read_failed: "無法讀取該檔案。"
backup_saved: "備份已儲存"
backup_saved_sub: "妥善保管 .backup 檔案。同時擁有它和你密碼的人都能恢復你的整個錢包。"
backup_file_title: "備份錢包"
backup_file_blurb: "建立一個包含你的錢包助記詞和所有身份的加密 .backup 檔案。輸入錢包密碼以封存它。"
backup_write_failed: "無法儲存檔案。"
create_backup: "建立備份"
registered: "已註冊 %{name}"
released_msg: "已釋放 — 使用者名稱可被搶注"
release_confirm: "釋放 %{name}"
release_blurb: "一旦釋放即可被認領——任何人都可以,包括你接下來輪換到的金鑰。10 分鐘內你無法註冊另一個使用者名稱。"
releasing: "正在釋放…"
keep_it: "保留"
release_it: "釋放"
username: "使用者名稱"
username_note: "顯示為你的名字。在 goblin.st 上公開。付款保持加密。"
release_username: "釋放使用者名稱"
pick_username: "選擇使用者名稱 — 可選"
working: "處理中…"
claim: "註冊"
err_just_taken: "該使用者名稱剛被佔用"
err_cooldown: "你剛釋放了一個使用者名稱 — 10 分鐘內無法註冊新使用者名稱。"
err_unreachable: "無法連線 goblin.st — 連線中斷。請重試。"
err_release: "無法釋放:%{err}"
avail_available: "可用!"
avail_taken: "已被佔用"
avail_reserved: "已保留"
avail_invalid: "使用者名稱為 3–20 個字元:az、0–9、_ 或 -"
avail_quarantined: "不可用"
avail_unknown: "無法檢查 — 連線中斷。請重試。"
username_none: "未設定"
advanced_privacy: "進階隱私"
tap_reveal: "點按以顯示"
notif_someone: "某人"
notif_private_received: "你收到了付款。開啟 Goblin 檢視。"
notif_private_requested: "新的付款請求。開啟 Goblin 檢視。"
username:
title: "使用者名稱"
authority: "名稱授權方"
authority_blurb: "保持預設,或指向其他實例以使用其託管的名稱。"
learn_more: "瞭解更多"
custom: "自定義授權方"
advprivacy:
intro: "控制通知會透露的內容,並在此裝置上隱藏你的餘額和歷史記錄。"
notifications: "通知"
hide_names: "隱藏名稱"
hide_names_sub: "通知中不顯示是誰付款"
hide_details: "隱藏全部詳情"
hide_details_sub: "僅顯示私密提醒,不含名稱或金額"
anon: "匿名模式"
anon_toggle: "模糊餘額和動態"
anon_sub: "餘額和動態顯示為圓點,點按後才會顯示"
advanced:
title: "進階"
intro: "來自 GRIM 的底層錢包工具。通常你用不到這些。"
own_node_desc: "在本裝置上同步完整的 Grin 節點,而不是信任公共節點。"
own_node_active: "正在執行你自己的節點"
repair: "修復錢包"
repair_desc: "重新掃描鏈並恢復任何遺失的輸出。這可能需要一些時間。"
repair_unavailable: "需要先有已同步的節點連線。"
repairing: "修復中… %{pct}%"
restore: "恢復錢包"
restore_desc: "刪除本地資料並從助記詞重建。如果修復無效,請使用此功能 — 之後需重新開啟錢包。"
restore_confirm: "再次點選以恢復"
show_phrase: "恢復助記詞"
phrase_desc: "你的 24 個 grin 助記詞 — 恢復資金的唯一方式。請離線且私密儲存。"
reveal: "顯示助記詞"
hide: "隱藏"
password: "錢包密碼"
wrong_password: "密碼錯誤。"
delete: "刪除錢包"
delete_desc: "從此裝置永久移除該錢包及其所有身份。請先備份。沒有助記詞,資金將無法找回。"
delete_confirm: "再次點選以刪除"
manage_node: "管理節點連線"
repair_confirm: "是的,立即修復"
repair_confirm_note: "修復會重新掃描鏈,可能需要幾分鐘。"
restore_confirm_note: "這會清除本地資料並從助記詞重建——可能需要幾分鐘。"
nostr_key: "Nostr 金鑰"
nostr_key_desc: "你的 nsec,即 nostr 身份的私鑰。複製它或顯示二維碼,即可登入 magick.market 等 nostr 應用。持有它的人即可控制你的身份,請妥善保管。"
reveal_nsec: "顯示金鑰"
copy_nsec: "複製 nsec"
show_qr: "顯示二維碼"
hide_qr: "隱藏二維碼"
nostr_section: "進階 nostr 設定"
backup_caption: "包含你的錢包和所有身份。"
danger_zone: "危險區域"
delete_warning: "這將刪除此裝置上的恢復助記詞和所有身份。未備份的內容將永久丟失。"
download_backup: "下載備份"
delete_final: "永久刪除"
privacy:
title: "網路隱私"
intro: "Goblin 透過 Tor 傳送其私密流量,向中繼隱藏你的 IP — 加密隱藏其餘部分,使中繼無法將付款關聯到你。"
payments: "付款"
payments_blurb: "每條攜帶 slatepack 的 nostr 訊息。"
usernames: "使用者名稱"
usernames_blurb: "往返 goblin.st 的 NIP-05 名稱查詢。"
price_avatars: "價格"
price_avatars_blurb: "金額旁顯示的即時法幣匯率。"
over_tor: "經由 Tor"
direct_connection: "直接連線"
grin_node: "Grin 節點"
grin_node_blurb: "區塊同步及向網路廣播你的交易。這是公開的鏈上資料,對所有人都一樣,且不與你的身份關聯。"
pairing:
title: "配對"
intro: "你的餘額和金額以何種貨幣顯示。"
pair_with: "配對貨幣"
rates_note: "匯率僅在開啟配對時透過 Tor 獲取 — 關閉後不會有任何匯率請求離開你的裝置。"
relays:
title: "中繼"
intro: "付款訊息會映象到下方每個中繼;只要有一個可達的中繼即可收款。"
your_relays: "你的中繼"
add_relay: "新增中繼"
add_relay_btn: "新增中繼"
save_reconnect: "儲存並重新連線"
none: "無"
count: "%{n} 箇中繼"
node:
title: "節點"
connection: "連線"
integrated: "整合節點"
applies_after: "在錢包鎖定並再次解鎖後生效。"
add_external: "新增外部節點"
api_secret_hint: "API 金鑰(可選)"
add_node: "新增節點"
integrated_host: "整合節點"
summary_syncing: "%{conn} · 同步中"
summary_block: "區塊 %{height} · %{conn}"
nips:
title: "nostr 與 NIPs"
intro1: "Goblin 使用 nostr — 一種透過簡單中繼伺服器傳遞簽名訊息的開放協議。你的錢包擁有自己的 nostr 身份:一個獨立的隨機金鑰,刻意與你的資金和助記詞保持獨立。每筆付款都作為身份之間的端對端加密私信傳輸,slatepack 就包含在其中。"
intro2: "goblin.st 是 Goblin 的名稱服務:註冊使用者名會在此釋出名稱 → 金鑰的對應(NIP-05),讓人們可以付款給你而不必使用冗長的 npub。使用者名稱是公開的;付款內容則永不公開。NIPs 是該協議的構建模組 — 點選任意一項可閱讀規範。"
n05_title: "名稱"
n05_blurb: "將 username@goblin.st 對應到你的金鑰,讓使用者名稱像地址一樣使用。"
n17_title: "私密訊息"
n17_blurb: "每筆付款傳輸所用的加密私信信封。"
n44_title: "加密"
n44_blurb: "這些訊息內部使用的認證加密演算法。"
n49_title: "金鑰加密"
n49_blurb: "私鑰靜態儲存的方式,由你的密碼鎖定。"
n59_title: "禮物包裝"
n59_blurb: "包裝訊息,使中繼無法看到通訊雙方是誰。"
n98_title: "HTTP 認證"
n98_blurb: "為向 goblin.st 註冊使用者名的請求籤名。"
batch:
title: "批准 %{n} 張發票"
blurb: "向 %{name} 發出 %{n} 個付款請求,每個 %{amount},共 %{total}。每個請求都有自己全新的收款地址。"
approve: "批准"
login:
title: "登入"
headline: "登入 %{domain}"
identity: "登入身份"
explain: "這將簽署一次性的登入請求。不會分享你的金鑰,網站也無法以你的身份行事。"
pass_prompt: "輸入錢包密碼以登入。"
confirm: "登入"
sent: "已登入 %{domain}"
failed: "無法連線 %{domain},登入未送達。"
authorize:
title: "授權"
headline: "授權 %{domain}"
kind_post: "公開貼文"
kind_repost: "轉發"
kind_reaction: "回應"
kind_article: "文章(長文)"
kind_unknown: "型別 %{n}(無法識別)"
unknown_caution: "Goblin 無法識別此事件型別。僅在你信任 %{domain} 時才批准。"
identity: "簽名身份"
explain: "批准將簽署這一個事件並交給 %{domain}。Goblin 不會發布任何內容,不會分享金鑰,網站也不會獲得會話或未來的許可權。"
pass_prompt: "輸入錢包密碼以授權。"
confirm: "授權"
sent: "已授權 %{domain}"
failed: "無法連線 %{domain},未送達任何內容。"
truncated: "已截斷,還有 %{n} 個字元"
show_full: "顯示全部"
show_less: "收起"
replying_to: "回覆 %{id}"
mentions: "提及 %{id}"
repost_of: "轉發 %{id}"
by_author: "作者 %{id}"
reacts_to: "回應 %{id}"
article_title: "標題:%{title}"
trust:
title: "信任"
headline: "信任 %{domain}"
identity: "登入身份"
lead: "本次會話中 %{domain} 可代表你操作,無需每次詢問。任何花錢的操作始終會詢問。"
grant_intro: "可無需詢問簽署:"
cat_social: "貼文和互動"
cat_dm: "私信"
cat_market: "商品列表"
cat_identity: "資料和列表"
cat_delete: "刪除"
cat_http: "上傳和 HTTP 認證"
cat_unknown: "其他事件型別(類型 %{n}"
login_excluded: "登入許可權絕不會授予網站。"
money_line: "涉及資金時始終需要你的密碼。絕不會靜默花費或出售。"
duration: "僅本次會話有效。可隨時在設定,受信任網站中結束。"
pass_prompt: "輸入錢包密碼以信任此網站。"
confirm_hold: "長按以在本次會話中信任"
sent: "已在本次會話中信任 %{domain}"
failed: "無法連線 %{domain}。會話未啟動。"
announce_failed: "已信任,但 %{domain} 尚未確認會話。"
notice_volume: "某個受信任網站簽署量過大。"
notice_decrypt: "某個受信任網站正在讀取你的訊息。"
money:
title: "確認"
headline: "在 %{domain} 中確認?"
identity: "以 %{id} 簽署"
explain: "此操作轉移或鎖定價值,因此每次都會詢問。批准將簽署這一操作。"
pass_prompt: "輸入錢包密碼以確認。"
confirm_hold: "長按以確認"
encrypt_desc: "一條鎖定付款的加密訂單訊息。"
trusted_sites:
title: "受信任網站"
intro: "本次會話中你信任的網站可在不詢問的情況下籤署低風險操作。資金操作始終詢問。可在此結束任意會話。"
empty: "暫無受信任網站。登入時信任某個網站即可為其開啟簽署會話。"
none: "無"
can_sign: "可靜默簽署:%{cats}"
time_left: "%{mins} 分鐘後結束"
paused: "已暫停,簽署量過大"
resume: "恢復"
end: "結束會話"
identities:
title: "身份"
switch_hint: "切換身份"
blurb: "你的身份就是你收款時使用的名稱。它們都匯入這一個錢包。新增新身份、在它們之間切換,或移除不再需要的身份。"
privacy_note: "這些身份共用一個錢包和一個餘額——是不同的入口,而不是不同的資金。若需真正分離的資金,請使用另一個錢包。"
held: "已持有的身份"
add: "新增身份"
add_title: "新增一個身份"
nsec_hint: "貼上 nsec"
generate_note: "將為此錢包建立一個全新的匿名金鑰。"
generate: "建立"
import: "匯入"
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: "儲存"
pass_prompt: "錢包密碼用於加密和解鎖每個身份。輸入以繼續。"
working: "處理中…"
onboarding:
intro:
private_money_head: "私密貨幣"
private_money_body: "Goblin 是一個 grin 錢包 — 鏈上無金額、無地址的數字現金。"
send_like_message_head: "像發訊息一樣付款"
send_like_message_body: "向使用者名稱或 npub 付款,款項會作為端對端加密訊息透過 nostr 和 Tor 送達 — 中間任何人都看不到金額或參與者。"
yours_alone_head: "完全屬於你"
yours_alone_body: "金鑰、使用者名稱和歷史記錄都存於本裝置。基於 GRIM 錢包構建。"
get_started: "開始使用"
footnote: "約需一分鐘。之後一切均可更改。"
node:
kicker: "步驟 1 / 3 · 網路"
title: "Goblin 該如何\n監視鏈?"
own_title: "執行我自己的節點"
own_badge: "私密"
own_body: "無需信任任何人 — 錢包自行驗證鏈。完成設定時在後臺同步。"
connect_title: "連線到節點"
connect_badge: "即時"
connect_body: "無需等待同步。你選擇的節點可看到錢包的查詢。"
changeable: "隨時可在 設定 → 節點 中更改。"
continue: "繼續"
url_invalid: "節點 URL 必須以 http:// 或 https:// 開頭"
wallet:
kicker: "步驟 2 / 3 · 錢包"
title: "設定你的錢包"
create_new: "新建"
restore_from_seed: "從助記詞恢復"
import_identity: "匯入身份"
import_identity_hint: "此步驟之後,你將匯入現有金鑰(.backup 檔案或 nsec)。"
name_hint: "錢包名稱"
password_hint: "密碼"
repeat_password_hint: "重複密碼"
restore_hint: "準備好你的助記詞 — 下一步將輸入。"
create_hint: "接下來你會得到 24 個助記詞以供抄寫。它們就是錢 — 誰持有它們,誰就掌握你的資金。"
continue: "繼續"
passwords_no_match: "密碼不一致"
words:
kicker: "步驟 2 / 3 · 錢包"
title_restore: "輸入你的助記詞"
title_create: "抄下這些詞"
write_down_hint: "按順序寫在紙上。任何持有這些詞的人都能取走你的資金;丟失這些詞又丟失裝置,資金將無法找回。"
paste: "貼上"
scan_qr: "掃描二維碼"
copy_clipboard: "複製到剪貼簿(不建議)"
restore_wallet: "恢復錢包"
wrote_them_down: "我已抄好"
fill_every_word: "填寫每個詞 — 點選某個詞進行編輯,或貼上整個短語。"
unlock_backup: "解鎖備份"
backup_unlocked: "備份已解鎖。錢包開啟時將恢復你的身份。"
confirm:
kicker: "步驟 2 / 3 · 錢包"
title: "現在來驗證"
enter_hint: "輸入你剛抄下的詞。點選某個詞進行輸入。"
paste: "貼上"
create_wallet: "建立錢包"
keep_going: "繼續 — 每個詞,按順序。"
identity:
kicker: "步驟 3 / 3 · 身份"
title: "你的付款身份"
key_being_made: "正在生成金鑰…"
connected_tor: "已透過 Tor 連線"
connecting_tor: "正在透過 Tor 連線…"
fresh_key_blurb: "一個不屬於助記詞的支付金鑰——可隨時輪換以保護隱私,且不影響你的資金。"
restoring: "正在恢復你的身份…"
clean_slate_blurb: "想要全新開始?隨時換上一個全新金鑰 — 新的你與舊的毫無關聯。同一個錢包,煥然一新。"
pick_username: "選擇使用者名稱 — 可選"
username_blurb: "朋友支付給你的名稱,而不是一長串金鑰。可選——隨時認領。"
username_field_hint: "你的使用者名稱"
working: "處理中…"
claim_username: "註冊使用者名"
available_when_connected: "Tor 連線後可用 — 或跳過,稍後註冊。"
youre: "你是 %{name}"
claimed_title: "%{name} 已歸你所有"
claimed_blurb: "朋友現在可以用你的使用者名稱向你付款。一切就緒——開啟錢包吧。"
open_wallet: "開啟我的錢包"
skip_for_now: "暫時跳過"
import_existing: "已有 Goblin 身份?匯入它"
import_title: "匯入你的身份"
import_blurb: "貼上你的 nsec 或選擇一個 .backup 檔案,保留你現有的金鑰和使用者名稱,而不是這個新的。"
errors:
cant_open: "無法開啟錢包:%{err}"
cant_create: "無法建立錢包:%{err}"
send:
scan_to_request: "掃碼請求"
scan_to_pay: "掃碼支付"
tab_scan: "掃描"
tab_my_code: "我的二維碼"
request_from: "向誰請求"
send_to: "傳送給"
search_hint: "使用者名稱、npub 或名稱"
suggested: "%{icon} 建議"
no_contacts: "暫無聯絡人。透過使用者名稱查詢某人。"
no_profile: "無資料"
tag_contact: "聯絡人"
tag_on_nostr: "在 nostr 上"
searching_nostr: "正在搜尋 nostr…"
unverified_title: "向未驗證的金鑰付款?"
unverified_body: "此金鑰未釋出 nostr 資料 — 它可能是全新的、匿名的或輸錯的。傳送前請仔細核對是否正確。"
keep_looking: "繼續查詢"
pay_anyway: "仍然付款"
scan_not_recipient: "該二維碼不是 goblin 收款方 — 應為 npub 或使用者名稱"
scan_prompt: "將 goblin 二維碼對準取景框以啟用"
scan_to_pay_me: "掃碼向我付款"
share_btn: "%{icon} 分享"
share_message: "在 Goblin 上向我付款 — %{handle}\n%{link}\nnpub%{npub}"
none_found: "未找到與 %{label} 匹配的人"
enter_recipient: "輸入使用者名稱、npub 或名稱"
amount_title: "金額"
to_name: "傳送給 %{name}"
not_enough: "你的 grin 餘額不足"
max: "最大"
note_label: "備註"
note_hint: "新增備註…"
add_note: "新增備註"
edit_note: "編輯備註"
note_cancel: "取消"
note_save: "儲存"
review_btn: "檢視"
confirm_request: "確認請求"
review_title: "檢視"
requesting_from: "向 %{name} 請求"
youre_sending: "你正在傳送給 %{name}"
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 加密,經由 Tor"
row_network_fee: "網路手續費"
row_network_fee_val: "從你的餘額中扣除"
row_privacy: "隱私"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "傳送請求"
request_approve_hint: "對方將收到一條待同意的請求"
hold_to_send: "長按傳送"
lower_amount: "返回並降低金額"
hold_confirm_hint: "按住以確認"
requesting: "正在請求…"
sending: "正在傳送…"
they: "對方"
request_blocked: "%{who} 不接受請求。請對方改為向你傳送 grin。"
failed_request_title: "請求失敗"
failed_send_title: "傳送失敗"
failed_request_body: "我們無法送達請求。請對方改為向你傳送 grin。"
failed_send_body: "付款未送達。你的 grin 是安全的 — 請重試。"
try_again_btn: "重試"
close_btn: "關閉"
success:
requested: "已請求"
sent: "已傳送"
from: "來自"
to: "發往"
subtitle: "%{dir} %{who} · 剛剛"
done_btn: "完成"
receipt_btn: "收據"
+5 -4
View File
@@ -446,10 +446,11 @@ fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
}
/// 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/es/ko/ja`; 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.
/// articles. The shipped locales are `en/de/fr/ru/tr/zh-CN/zh-TW/es/ko/ja`;
/// the two Chinese locales fold to their shared 639-1 primary `zh` (so
/// Traditional readers currently match the same `zh` news as Simplified),
/// 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()
+15
View File
@@ -106,6 +106,21 @@ pub fn gradient_stops(id: &str) -> ((u8, u8, u8), (u8, u8, u8), f32) {
(c1, c2, angle as f32)
}
/// The anonymous-mode / self avatar as a standalone SVG: a flat Goblin-yellow
/// (`#FED60E`) tile with the SAME Grin mark the gradient avatar composites on top
/// (identical [`GRIN_PATH`], 90% scale, and 67%-black ink), just over a flat fill
/// instead of the per-identity gradient. Keeping the mark geometry identical to
/// [`gradient_avatar_svg`] means the anonymous and self tiles draw the Grin mark
/// exactly the way a normal avatar does.
pub fn censored_avatar_svg(size: u32) -> String {
let target = size as f64 * LOGO_FRAC;
let scale = target / GRIN_NATIVE;
let off = (size as f64 - target) / 2.0;
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><rect width="{size}" height="{size}" fill="#FED60E"/><g transform="translate({off:.2},{off:.2}) scale({scale:.4})"><path d="{GRIN_PATH}" fill="#000000" fill-opacity="{LOGO_OPACITY}"/></g></svg>"##
)
}
/// 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
+196 -133
View File
@@ -262,6 +262,10 @@ struct AdvancedState {
confirm_repair: bool,
/// Armed "really delete?" confirm.
confirm_delete: bool,
/// Wallet password typed into the Danger Zone delete gate.
delete_pass: String,
/// The delete password didn't decrypt the seed — show the wrong-password line.
delete_wrong: bool,
}
/// Inputs and last result for the manual slatepack page (GRIM's native flow,
@@ -576,6 +580,9 @@ struct BackupState {
error: Option<String>,
/// The backup file was created.
done: bool,
/// The form was opened from the Danger Zone delete flow, so it renders
/// inline there instead of under the Advanced nostr section.
anchor_delete: bool,
}
/// Inline username-claim widget state.
@@ -1589,14 +1596,14 @@ impl GoblinWalletView {
.color(t.surface_text),
);
ui.label(
// Relay-gated: "Connected over Nym" only once a
// Relay-gated: "Connected over Tor" only once a
// relay is live on the current tunnel generation.
RichText::new(if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
t!("goblin.home.connected_tor")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
t!("goblin.home.tor_ready")
} else {
t!("goblin.home.connecting_nym")
t!("goblin.home.connecting_tor")
})
.font(FontId::new(11.0, fonts::regular()))
.color(t.surface_text_mute),
@@ -1636,6 +1643,32 @@ impl GoblinWalletView {
self.avatars.texture_for(ctx, &server, handle)
}
/// The user's OWN avatar as shown top-right on the front surfaces (home,
/// pay). Mode-aware: anonymous mode is the ONLY thing that makes it yellow.
/// Anon ON → the flat Goblin-yellow censored tile ([`w::avatar_censored`]);
/// anon OFF → the exact same picture-or-gradient identicon this identity
/// renders everywhere else ([`w::avatar_any`]). Returns the tap Response so
/// the caller can route to settings.
fn avatar_self(&mut self, ui: &mut egui::Ui, wallet: &Wallet, size: f32) -> egui::Response {
if crate::AppConfig::anonymous_mode() {
return w::avatar_censored(ui, size);
}
let (handle, npub_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex_of(&id.npub)));
(h, hex_of(&id.npub))
})
.unwrap_or_else(|| (t!("goblin.home.anonymous").to_string(), String::new()));
let tex = self.handle_tex(ui.ctx(), wallet, &handle);
w::avatar_any(ui, &handle, &npub_hex, size, tex.as_ref())
}
/// Compact node status card: sync state dot, block height, connection.
fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
@@ -1728,23 +1761,6 @@ impl GoblinWalletView {
// Mobile header: wordmark left, avatar (opens settings) right.
if !wide {
ui.add_space(10.0);
let (header_handle, header_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let hex = hex_of(&id.npub);
// With a verified handle show "@name"; otherwise fall back to
// the short npub (avatar_any then draws the deterministic
// pubkey-seeded gradient).
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex));
(h, hex)
})
.unwrap_or_else(|| ("N".to_string(), String::new()));
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
ui.horizontal(|ui| {
// Owner-sized: +50% over the original 24px mark so the lockup
// carries the same visual weight as the 40-44px right cluster.
@@ -1756,15 +1772,10 @@ impl GoblinWalletView {
.color(theme::tokens().text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::avatar_any(
ui,
&header_handle,
&header_hex,
40.0,
header_tex.as_ref(),
)
.clicked()
{
// The user's own avatar (opens settings). Mode-aware: the
// flat yellow + Grin mark tile only in anonymous mode,
// otherwise this identity's normal gradient/picture.
if self.avatar_self(ui, wallet, 40.0).clicked() {
self.tab = Tab::Me;
}
// Scan-to-pay, left of the avatar. No frame: a bold white QR
@@ -1991,21 +2002,6 @@ impl GoblinWalletView {
fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
ui.add_space(8.0);
// Header identity for the avatar (→ settings), mirroring the Home header.
let (header_handle, header_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let hex = hex_of(&id.npub);
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex));
(h, hex)
})
.unwrap_or_else(|| ("N".to_string(), String::new()));
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
ui.horizontal(|ui| {
// Goblin mark (left), sized to match the right-side controls.
ui.add(
@@ -2013,15 +2009,10 @@ impl GoblinWalletView {
.tint(t.text)
.fit_to_exact_size(Vec2::splat(40.0)),
);
// Right cluster: scan QR (black, no background) then the profile
// picture at the far right; all three controls about the same size.
// Right cluster: scan-QR glyph (black, no background). The
// tap-to-Settings profile avatar that used to sit at the far right
// was removed per owner request; Settings stays on the nav bar.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.tab = Tab::Me;
}
ui.add_space(12.0);
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::click());
ui.painter().text(
@@ -3369,7 +3360,7 @@ impl GoblinWalletView {
SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb),
SettingsPage::Privacy => return self.privacy_ui(ui),
SettingsPage::Username => return self.username_ui(ui, wallet, cb),
SettingsPage::AdvancedPrivacy => return self.advanced_privacy_ui(ui),
SettingsPage::AdvancedPrivacy => return self.advanced_privacy_ui(ui, wallet, cb),
SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb),
SettingsPage::Identities => return self.identities_ui(ui, wallet, cb),
SettingsPage::TrustedSites => return self.trusted_sites_ui(ui, wallet, cb),
@@ -3454,23 +3445,23 @@ impl GoblinWalletView {
}
});
// Transport status in place of the redundant second npub line.
// "Connected over Nym" is RELAY-GATED (transport_ready): the
// "Connected over Tor" is RELAY-GATED (transport_ready): the
// tunnel being warm is not enough — a relay must actually carry
// our traffic on the current exit. Otherwise show the tunnel is
// up but relays are still connecting/reconnecting.
let mixnet = if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
let transport = if crate::tor::transport_ready() {
t!("goblin.home.connected_tor")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
t!("goblin.home.tor_ready")
} else {
t!("goblin.home.connecting_nym")
t!("goblin.home.connecting_tor")
};
ui.label(
RichText::new(mixnet)
RichText::new(transport)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
// nostr relay status — the slower step (a relay reached over Nym).
// nostr relay status — the slower step (a relay reached over Tor).
let nostr = if connected {
t!("goblin.settings.connected_nostr")
} else {
@@ -3584,16 +3575,9 @@ impl GoblinWalletView {
cb.vibrate_copy();
self.copy_flash = Some(std::time::Instant::now());
}
// One encrypted backup FILE (key + username + history sealed
// together) — replaces the old copy-nsec / copy-JSON split.
if settings_row_btn(
ui,
&t!("goblin.settings.backup_file"),
crate::gui::icons::DOWNLOAD_SIMPLE,
) && self.backup.is_none()
{
self.backup = Some(BackupState::default());
}
// The encrypted .backup file now lives on the Advanced page,
// under ADVANCED NOSTR SETTINGS (single home — no duplicate
// exposure here).
// Nostr relays the wallet publishes/reads gift wraps on.
// Sits with the identity rows because relays are a nostr
// concern; opens the relay editor (handled below).
@@ -3656,17 +3640,6 @@ impl GoblinWalletView {
self.copy_flash = None;
}
}
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.backup_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
if self.backup.is_some() {
ui.add_space(8.0);
self.backup_ui(ui, wallet, cb);
}
ui.add_space(16.0);
let mut open_node = false;
let mut open_slatepack = false;
@@ -3728,13 +3701,13 @@ impl GoblinWalletView {
let mut open_privacy = false;
let mut open_adv_privacy = false;
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
// Messages, names, price and avatars ride the mixnet; the grin
// Messages, names, price and avatars ride Tor; the grin
// node connects directly. Normal dim value ink: the salmon
// privacy color doubled as the destructive-action color on
// this page, making a plain navigable row read as a warning.
if settings_row_nav(
ui,
&t!("goblin.settings.mixnet_routing"),
&t!("goblin.settings.tor_routing"),
&t!("goblin.settings.messages_lookups"),
) {
open_privacy = true;
@@ -3822,36 +3795,6 @@ impl GoblinWalletView {
self.settings_page = SettingsPage::Language;
}
ui.add_space(16.0);
w::kicker(ui, &t!("goblin.settings.archive"));
ui.add_space(8.0);
w::card(ui, |ui| {
if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
cb.vibrate_copy();
}
}
// Destructive: danger styling + tap-twice confirm (like the
// receipt's "Cancel payment") before the archive is wiped.
let wipe_label = if self.wipe_confirm {
t!("goblin.settings.wipe_history_confirm")
} else {
t!("goblin.settings.wipe_history")
};
if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) {
if self.wipe_confirm {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
self.wipe_confirm = false;
} else {
self.wipe_confirm = true;
}
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.settings.about"), |ui| {
if settings_row_nav(
@@ -4066,7 +4009,7 @@ impl GoblinWalletView {
});
}
/// Network-privacy breakdown: what rides the Nym mixnet versus what connects
/// Network-privacy breakdown: what rides Tor versus what connects
/// directly. Honest by design — no claim that node traffic is mixed, and no
/// toggle to route it (chain sync is heavy and not tied to your identity).
fn privacy_ui(&mut self, ui: &mut egui::Ui) {
@@ -4086,7 +4029,7 @@ impl GoblinWalletView {
.color(t.text_dim),
);
ui.add_space(16.0);
let mixnet = [
let transport = [
(
t!("goblin.privacy.payments"),
t!("goblin.privacy.payments_blurb"),
@@ -4100,8 +4043,8 @@ impl GoblinWalletView {
t!("goblin.privacy.price_avatars_blurb"),
),
];
settings_group(ui, &t!("goblin.privacy.over_mixnet"), |ui| {
for (title, blurb) in &mixnet {
settings_group(ui, &t!("goblin.privacy.over_tor"), |ui| {
for (title, blurb) in &transport {
privacy_line(ui, t.neg, title, blurb);
}
});
@@ -4321,7 +4264,12 @@ impl GoblinWalletView {
/// Advanced Privacy page — notification hiding (amounts / names / all
/// details) and the anonymous-mode toggle that dots the home balance and the
/// activity list. All presentation-only; nothing here touches the money path.
fn advanced_privacy_ui(&mut self, ui: &mut egui::Ui) {
fn advanced_privacy_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.advanced_privacy")) {
self.settings_page = SettingsPage::Main;
@@ -4376,6 +4324,37 @@ impl GoblinWalletView {
}
});
ui.add_space(16.0);
// The local archive (contacts + payment history + requests) lives
// here under Advanced privacy.
settings_group(ui, &t!("goblin.settings.archive"), |ui| {
if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
cb.vibrate_copy();
}
}
advanced_desc(ui, &t!("goblin.settings.export_archive_caption"));
ui.add_space(10.0);
// Destructive: danger styling + tap-twice confirm (like the
// receipt's "Cancel payment") before the archive is wiped.
let wipe_label = if self.wipe_confirm {
t!("goblin.settings.wipe_history_confirm")
} else {
t!("goblin.settings.wipe_history")
};
if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) {
if self.wipe_confirm {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
self.wipe_confirm = false;
} else {
self.wipe_confirm = true;
}
}
});
ui.add_space(16.0);
});
}
@@ -4387,6 +4366,8 @@ impl GoblinWalletView {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.advanced.title")) {
self.advanced = AdvancedState::default();
// Don't leave a half-entered backup password sitting in memory.
self.backup = None;
self.settings_page = SettingsPage::Main;
return;
}
@@ -4401,12 +4382,14 @@ impl GoblinWalletView {
let mut open_node = false;
let mut open_integrated = false;
{
let adv = &mut self.advanced;
ScrollArea::vertical()
.id_salt("goblin_advanced_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Borrow ends (NLL) at the last `adv.` use in the Nostr-key card;
// the .backup + Danger Zone sections below then use `self`.
let adv = &mut self.advanced;
ui.label(
RichText::new(t!("goblin.advanced.intro"))
.font(FontId::new(14.0, fonts::regular()))
@@ -4591,6 +4574,10 @@ impl GoblinWalletView {
});
ui.add_space(12.0);
// ── ADVANCED NOSTR SETTINGS ──────────────────────────────
// The Nostr key, and directly below it the .backup download.
w::kicker(ui, &t!("goblin.advanced.nostr_section"));
ui.add_space(8.0);
// Nostr key (nsec). Password-gated reveal, then Copy + a QR
// so it can be carried into a nostr app's private-key login
// (e.g. magick.market) without retyping. Same gate as the
@@ -4675,23 +4662,98 @@ impl GoblinWalletView {
});
ui.add_space(12.0);
// Delete.
// .backup download — directly under the Nostr key, the second
// half of the ADVANCED NOSTR SETTINGS group. One button, no
// checklist: it seals your CURRENT identity (key + username)
// into an encrypted .backup file. (`adv` is no longer used past
// here, so `self` is free again.)
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.settings.backup_file_title"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.backup_caption"));
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.settings.backup_file")).clicked()
&& self.backup.is_none()
{
self.backup = Some(BackupState::default());
}
});
// The password/seal form, anchored here unless it was opened from
// the Danger Zone delete flow below.
if self.backup.as_ref().is_some_and(|b| !b.anchor_delete) {
ui.add_space(8.0);
self.backup_ui(ui, wallet, cb);
}
ui.add_space(16.0);
// ── DANGER ZONE ──────────────────────────────────────────
// Delete the wallet — password-gated, with a back-up prompt.
w::kicker_danger(ui, &t!("goblin.advanced.danger_zone"));
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.delete"), t.neg);
advanced_desc(ui, &t!("goblin.advanced.delete_desc"));
ui.add_space(10.0);
if adv.confirm_delete {
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete_confirm"),
t.neg,
)
.clicked()
if self.advanced.confirm_delete {
ui.label(
RichText::new(t!("goblin.advanced.delete_warning"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
ui.add_space(10.0);
// Back up BEFORE the password field (spec) — the same seal
// action, but anchored to this flow so its form renders
// here, not up in the nostr section.
if w::big_action_on_card(ui, &t!("goblin.advanced.download_backup"))
.clicked() && self.backup.is_none()
{
wallet.delete_wallet();
leave = true;
self.backup = Some(BackupState {
anchor_delete: true,
..Default::default()
});
}
if self.backup.as_ref().is_some_and(|b| b.anchor_delete) {
self.backup_ui(ui, wallet, cb);
ui.add_space(10.0);
}
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_delete_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.advanced.delete_pass, cb);
});
if self.advanced.delete_wrong {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
let adv = &mut self.advanced;
ui.add_enabled_ui(!adv.delete_pass.is_empty(), |ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete_final"),
t.neg,
)
.clicked()
{
// Wallet-password gate: get_recovery only returns Ok
// when the password decrypts the seed.
if wallet.get_recovery(adv.delete_pass.clone()).is_ok() {
wallet.delete_wallet();
leave = true;
} else {
adv.delete_wrong = true;
}
}
});
} else if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete"),
@@ -4699,7 +4761,7 @@ impl GoblinWalletView {
)
.clicked()
{
adv.confirm_delete = true;
self.advanced.confirm_delete = true;
}
});
ui.add_space(20.0);
@@ -4707,6 +4769,7 @@ impl GoblinWalletView {
}
if leave {
self.advanced = AdvancedState::default();
self.backup = None;
self.settings_page = SettingsPage::Main;
}
if open_node {
@@ -5341,7 +5404,7 @@ impl GoblinWalletView {
if w::big_action(ui, &t!("goblin.settings.create_backup"), false)
.clicked()
{
match wallet.create_nostr_backup(&bk.password) {
match wallet.create_full_backup(&bk.password) {
Ok(envelope) => {
let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M");
let fname = format!("GOBLIN-{stamp}.backup");
@@ -7946,7 +8009,7 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
}
/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag
/// the always-on mixnet routing in the privacy color.
/// the always-on Tor routing in the privacy color.
fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) {
let t = theme::tokens();
ui.horizontal(|ui| {
+200 -3
View File
@@ -82,6 +82,34 @@ pub struct OnboardingContent {
import: Option<OnbImport>,
/// Moment the recovery phrase was copied, for the transient "Copied" check.
words_copied: Option<std::time::Instant>,
/// Full-backup restore (restore-from-seed path): pick a `.backup` file that
/// carries the money seed AND every identity, unlock it, and let the seed feed
/// the normal 24-word creation path.
backup_restore: Option<BackupRestore>,
/// Captured at full-backup unlock: `(file contents, backup password)` to
/// reinstate every identity once the wallet has been created and opened.
pending_restore: Option<(String, String)>,
/// A full-backup identity restore is running in a worker after wallet open.
restore_busy: bool,
/// Worker result for the identity restore: `Ok(())` or an error message.
restore_result: std::sync::Arc<std::sync::Mutex<Option<Result<(), String>>>>,
/// Sticky error from the identity restore, shown on the identity step.
restore_error: Option<String>,
}
/// Full-backup restore sub-state on the restore-from-seed words step.
#[derive(Default)]
struct BackupRestore {
/// The picked `.backup` file contents.
blob: String,
/// The password the backup was sealed under.
password: String,
/// Last error (bad file / wrong password), shown inline.
error: String,
/// A native file pick is in flight (Android resolves the path asynchronously).
picking: bool,
/// The seed was decrypted and loaded into the word grid.
unlocked: bool,
}
/// Onboarding identity-import state. Reuses the wallet password the user just
@@ -124,6 +152,11 @@ impl Default for OnboardingContent {
claim: ClaimState::default(),
import: None,
words_copied: None,
backup_restore: None,
pending_restore: None,
restore_busy: false,
restore_result: std::sync::Arc::new(std::sync::Mutex::new(None)),
restore_error: None,
}
}
}
@@ -601,6 +634,9 @@ impl OnboardingContent {
);
});
ui.add_space(14.0);
// Restore a FULL .backup file: its seed feeds this same word grid,
// and its identities are reinstated once the wallet opens.
self.backup_restore_ui(ui, cb);
} else {
// Transient "Copied" feedback (the Build 82/89 pattern): a silent
// copy of the recovery phrase reads as a dead button.
@@ -652,6 +688,111 @@ impl OnboardingContent {
self.error_ui(ui);
}
/// Restore-from-seed helper: pick a FULL `.backup` file (seed + all
/// identities), unlock it with its password, and load the recovered seed into
/// the word grid so the standard creation path takes over. The identities are
/// stashed in `pending_restore` and reinstated after the wallet opens.
fn backup_restore_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
// Already unlocked: the seed is in the grid; just confirm and stop.
if self.backup_restore.as_ref().is_some_and(|b| b.unlocked) {
ui.label(
RichText::new(t!("goblin.onboarding.words.backup_unlocked"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.pos),
);
ui.add_space(14.0);
return;
}
if self.backup_restore.is_none() {
self.backup_restore = Some(BackupRestore::default());
}
// Recovered seed to apply AFTER the `br` borrow ends (avoids a double
// mutable borrow of `self`): (seed phrase, backup blob, backup password).
let mut apply: Option<(String, String, String)> = None;
{
let br = self.backup_restore.as_mut().unwrap();
// Poll an async (Android) file pick.
if br.picking {
if let Some(path) = cb.picked_file() {
br.picking = false;
if !path.is_empty() {
match std::fs::read_to_string(&path) {
Ok(c) => br.blob = c.trim().to_string(),
Err(_) => {
br.error = t!("goblin.settings.backup_read_failed").to_string()
}
}
}
} else {
ui.ctx().request_repaint();
}
}
if w::chip(ui, &t!("goblin.settings.choose_backup_file"), false).clicked() {
br.error.clear();
match cb.pick_file() {
Some(path) if !path.is_empty() => match std::fs::read_to_string(&path) {
Ok(c) => br.blob = c.trim().to_string(),
Err(_) => br.error = t!("goblin.settings.backup_read_failed").to_string(),
},
// Empty string = Android async pick in flight.
Some(_) => br.picking = true,
None => {}
}
}
// Once a full backup is loaded, ask for its password and unlock it.
if crate::nostr::is_full_backup(&br.blob) {
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("onb_restore_bpw"))
.focus(false)
.hint_text(t!("goblin.settings.backup_password_hint"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut br.password, cb);
});
ui.add_space(8.0);
ui.add_enabled_ui(!br.password.is_empty(), |ui| {
if w::chip(ui, &t!("goblin.onboarding.words.unlock_backup"), false).clicked() {
match crate::nostr::open_full_backup(&br.blob, &br.password) {
Ok(full) => {
apply = Some((
full.seed_phrase.clone(),
br.blob.clone(),
br.password.clone(),
));
}
Err(_) => br.error = t!("goblin.advanced.wrong_password").to_string(),
}
}
});
}
if !br.error.is_empty() {
ui.add_space(6.0);
ui.label(
RichText::new(&br.error)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
}
ui.add_space(14.0);
// Apply outside the `br` borrow: fill the word grid with the recovered
// seed and stash the identities for post-open restore.
if let Some((phrase, blob, pw)) = apply {
self.mnemonic_setup
.mnemonic
.import(&ZeroingString::from(phrase));
self.pending_restore = Some((blob, pw));
if let Some(br) = self.backup_restore.as_mut() {
br.unlocked = true;
br.password.clear();
br.error.clear();
}
}
}
fn confirm_ui(
&mut self,
ui: &mut egui::Ui,
@@ -741,6 +882,21 @@ impl OnboardingContent {
wallets.add(w.clone());
match w.open(pass) {
Ok(_) => {
// A full-backup restore: reinstate every identity from the
// backup in a worker once nostr is up (the seed itself was
// already restored through this creation path).
if let Some((blob, bpw)) = self.pending_restore.take() {
let wallet_pw = self.pass.clone();
let wallet = w.clone();
let slot = self.restore_result.clone();
self.restore_busy = true;
self.restore_error = None;
std::thread::spawn(move || {
let res =
wallet.restore_full_backup_identities(&blob, &bpw, &wallet_pw);
*slot.lock().unwrap() = Some(res);
});
}
self.wallet = Some(w);
self.error = None;
self.step = Step::Identity;
@@ -839,12 +995,12 @@ impl OnboardingContent {
);
}
ui.label(
// Relay-gated readiness: "connected over Nym" only once a
// Relay-gated readiness: "connected over Tor" only once a
// relay is actually live, not merely when the tunnel is warm.
RichText::new(if crate::tor::transport_ready() {
t!("goblin.onboarding.identity.connected_nym")
t!("goblin.onboarding.identity.connected_tor")
} else {
t!("goblin.onboarding.identity.connecting_nym")
t!("goblin.onboarding.identity.connecting_tor")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
@@ -860,6 +1016,47 @@ impl OnboardingContent {
});
ui.add_space(14.0);
// Full-backup identity restore in flight: poll the worker, and while it
// runs show a restoring card instead of the claim/import UI so the user
// never acts on the throwaway key it is replacing.
if self.restore_busy
&& let Some(res) = self.restore_result.lock().unwrap().take()
{
self.restore_busy = false;
if let Err(e) = res {
self.restore_error = Some(e);
}
}
if self.restore_busy {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.onboarding.identity.restoring"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(14.0);
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
if w::big_action(ui, &t!("goblin.onboarding.identity.open_wallet"), false).clicked() {
return Some(wallet);
}
return None;
}
if let Some(err) = &self.restore_error {
ui.label(
RichText::new(err)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
ui.add_space(14.0);
}
// Optional username claim — the same machinery as Settings.
if let Some(msg) = self.claim.result.lock().unwrap().take() {
self.claim.checking = false;
+26 -24
View File
@@ -84,36 +84,27 @@ pub fn avatar_any(
}
}
/// Fixed Goblin yellow for the anonymous-mode censored avatar (#FED60E). A
/// literal constant, never seeded by an identity or read from the theme, so
/// every censored tile is byte-identical and no per-user color can leak.
const CENSOR_AVATAR_FILL: Color32 = Color32::from_rgb(0xFE, 0xD6, 0x0E);
/// The anonymous-mode censored avatar: one uniform tile that replaces every
/// real picture, gradient, or initial while anonymous mode is on. A solid
/// Goblin-yellow circle with the Goblin mark inked dark on top (the same mark
/// [`super::widgets_logo`] draws), tinted with the dark ink so it reads on the
/// yellow in both light and dark themes. Identical for every identity on the
/// real picture, gradient, or initial while anonymous mode is on. A flat
/// Goblin-yellow (`#FED60E`) circle with the GRIN mark composited on top exactly
/// the way a normal gradient avatar draws it (same path, 90% scale, 67%-black
/// ink — see [`super::identicon::censored_avatar_svg`]), just over a flat fill
/// instead of the per-identity gradient. Identical for every identity on the
/// home, activity, and Recent surfaces, so nothing about who the counterparty
/// is leaks. `size` matches the avatar it stands in for; the row still taps
/// through (the returned `Response` senses clicks) so tap-to-reveal is intact.
pub fn avatar_censored(ui: &mut Ui, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
ui.painter()
.circle_filled(rect.center(), rect.width() / 2.0, CENSOR_AVATAR_FILL);
// Goblin mark centered at ~62% of the tile, inked dark so it reads on the
// yellow regardless of theme. Small marks use the pre-rendered raster (same
// crossover as widgets_logo_sized) for cleaner antialiasing.
let mark = size * 0.62;
let mrect = egui::Rect::from_center_size(rect.center(), Vec2::splat(mark));
egui::Image::new(if mark <= 32.0 {
egui::include_image!("../../../../img/goblin-logo2-48.png")
} else {
egui::include_image!("../../../../img/goblin-logo2.svg")
let px = (rect.width() * 2.0) as u32;
let svg = super::identicon::censored_avatar_svg(px);
let uri = format!("bytes://gobcensored-{}.svg", rect.width() as u32);
egui::Image::new(egui::ImageSource::Bytes {
uri: uri.into(),
bytes: svg.into_bytes().into(),
})
.tint(Color32::from_rgb(0x0E, 0x0E, 0x0C))
.fit_to_exact_size(Vec2::splat(mark))
.paint_at(ui, mrect);
.corner_radius(CornerRadius::same((rect.width() / 2.0) as u8))
.fit_to_exact_size(rect.size())
.paint_at(ui, rect);
resp
}
@@ -212,6 +203,17 @@ pub fn kicker(ui: &mut Ui, text: &str) {
);
}
/// A kicker rendered in the danger colour — marks a destructive section
/// (e.g. the Advanced page's Danger Zone).
pub fn kicker_danger(ui: &mut Ui, text: &str) {
let t = theme::tokens();
ui.label(
RichText::new(text.to_uppercase())
.font(fonts::kicker())
.color(t.neg),
);
}
/// A Cash-App-style on/off switch. Yellow (brand accent) when on, neutral track
/// when off. Returns the response — the caller flips the bound state on click.
pub fn toggle(ui: &mut Ui, on: bool) -> Response {
@@ -798,7 +800,7 @@ pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never
// Truncate so a long value (e.g. "Encrypted nostr DM over Tor") never
// runs past the edge or collides with the label on a narrow screen.
ui.add(
egui::Label::new(
+4
View File
@@ -108,5 +108,9 @@ impl SettingsContent {
}
});
ui.add_space(8.0);
// Clear the Android navigation/gesture bar so the last button is not
// clipped or overlaid by the gesture pill. Zero on desktop.
ui.add_space(View::get_bottom_inset());
}
}
+5 -12
View File
@@ -38,12 +38,6 @@ 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;
@@ -117,11 +111,10 @@ where
/// Entry point to start ui with [`eframe`].
pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe::Result<()> {
// Pin rustls to the ring provider process-wide. Linking nym-sdk brings
// aws-lc-rs into the graph alongside our ring; with two providers present
// rustls 0.23 won't auto-select a default, and tokio-tungstenite/reqwest
// 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).
// Pin rustls to the ring provider process-wide so our relay/HTTP TLS
// (tokio-tungstenite, tokio-rustls) selects a crypto provider deterministically
// rather than relying on rustls 0.23's auto-detection. Idempotent (Err if
// already set).
let _ = rustls::crypto::ring::default_provider().install_default();
// 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
@@ -537,7 +530,7 @@ pub fn mark_frame() {
/// True when the GUI drew a frame within the last few seconds — i.e. the app is
/// foreground and visible. While backgrounded (no frames), returns false, so
/// periodic background work (the @name re-verify sweep) can pause and catch up
/// on resume instead of burning mixnet round-trips while nobody's looking.
/// on resume instead of burning Tor round-trips while nobody's looking.
pub fn app_foreground() -> bool {
let last = LAST_FRAME_AT.load(std::sync::atomic::Ordering::Relaxed);
last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS
+1 -1
View File
@@ -724,7 +724,7 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
// The keep-alive notification must reflect the real connection: on the
// external-node default the integrated node is deliberately off, so "Node
// is down" is wrong — the service's actual background job is the
// Nostr-over-Nym payment listen.
// Nostr-over-Tor payment listen.
let status_text = if Node::is_running() || Node::is_starting() {
Node::get_sync_status_text()
} else {
+20 -20
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr service: relay connections over the Nym mixnet,
//! Per-wallet nostr service: relay connections over Tor,
//! identity event publishing, the guarded ingest loop and the DM send path.
use grin_core::core::amount_to_hr_string;
@@ -95,10 +95,10 @@ const RESEND_WINDOW_SECS: i64 = 7 * 86_400;
/// a released or reassigned name stops being shown. Doubles as the freshness
/// gate in `resolve_contact_identity`. Tuned for release/name-change detection
/// freshness, not liveness — a name rarely changes, so 6h is ample and keeps the
/// mixnet re-verify traffic off the interactive path.
/// Tor re-verify traffic off the interactive path.
const NAME_REVERIFY_INTERVAL_SECS: i64 = 6 * 3600;
/// Cap on contacts re-verified per sweep, so a large contact list rolls through
/// instead of bursting dozens of simultaneous mixnet lookups at once.
/// instead of bursting dozens of simultaneous Tor lookups at once.
const NAME_REVERIFY_MAX_PER_TICK: usize = 8;
/// One held identity live in memory: its decrypted keys (for unwrapping incoming
@@ -137,7 +137,7 @@ pub struct NostrService {
client: RwLock<Option<Client>>,
/// Handle to the service's tokio runtime. One-shot fetches (e.g. profile
/// lookups) from worker threads MUST run here, not on a throwaway runtime:
/// the relay connections (incl. the custom Nym mixnet transport) are driven
/// the relay connections (all driven over Tor) are driven
/// by this runtime, and a foreign runtime can't reach them.
rt_handle: RwLock<Option<tokio::runtime::Handle>>,
/// Service thread started flag.
@@ -473,8 +473,8 @@ impl NostrService {
let client = self.client.read().clone()?;
let pk = PublicKey::from_hex(hex).ok()?;
let hints: Vec<String> = hints.to_vec();
// Run on the SERVICE runtime — the relay connections (and the custom Nym
// mixnet transport) live there. A throwaway current-thread runtime can't
// Run on the SERVICE runtime — the relay connections (all driven over Tor)
// live there. A throwaway current-thread runtime can't
// drive them, which is why bare-npub profile lookups silently returned
// nothing even though the relay serves the kind-0 fine.
let handle = self.rt_handle.read().clone()?;
@@ -1284,7 +1284,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
svc.npub(),
relays
);
// (No DNS prewarm here: unlike the old mixnet path, arti resolves relay and
// (No DNS prewarm here: arti resolves relay and
// HTTP hostnames internally as part of the circuit dial — there is no
// separate in-tunnel DoT round trip to warm. The node host was never on this
// path and still isn't — it never rides the private transport.)
@@ -1304,7 +1304,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
*w_client = Some(client.clone());
}
// Log when the first relay reaches Connected over the mixnet, measured from
// Log when the first relay reaches Connected over Tor, measured from
// the connect() call. Non-blocking; exits on first success.
{
let client_probe = client.clone();
@@ -1331,10 +1331,10 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// over, a relay DROP wouldn't flip the flag back for up to ~30s
// (until the post-catch-up re-check re-syncs it to reality) — the
// same-order staleness as the old pessimistic gap, just optimistic
// instead. The transport watchdog (nymproc) still tracks real exit
// instead. The relay-gated readiness signal still tracks real relay
// health independently of this UI flag.
svc_probe.connected.store(true, Ordering::Relaxed);
// FAST relay-live report: closes nymproc's relay-readiness
// FAST relay-live report: closes the relay-readiness
// window as soon as the exit is proven to carry relay traffic,
// independent of the up-to-30s catch-up fetch below (a slow
// catch-up must not get a good exit wrongly condemned).
@@ -1387,7 +1387,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
Filter::new()
.kind(Kind::LongFormTextNote)
.author(pk)
.limit(4)
.limit(18)
});
if let Ok(events) = client
@@ -1446,9 +1446,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// a relay is typically already up.
let connected = relays_connected(&client).await;
svc.connected.store(connected, Ordering::Relaxed);
// Feed the relay-gated readiness signal so "Connected over Nym" reflects an
// Feed the relay-gated readiness signal so "Connected over Tor" reflects an
// actual connected+subscribed relay on THIS tunnel generation, not merely a
// warm tunnel — and so nymproc's relay-readiness window closes successfully.
// warm tunnel — and so the relay-readiness window closes successfully.
if connected {
crate::tor::report_relay_live(dial_gen);
}
@@ -1457,8 +1457,8 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// Poll connection state on a SHORT, INDEPENDENT interval. This used to live in
// the `select!` behind a `sleep(30s)` that restarted on every notification, so
// the flag could lag the real relay state by 30s+ (or, under steady event
// flow, never update) — that's the "stuck on Connecting…" the mixnet gets
// blamed for, even though a relay handshake over Nym takes ~2s. An `interval`
// flow, never update) — that's the "stuck on Connecting…" Tor gets
// blamed for, even though a relay handshake over Tor takes ~2s. An `interval`
// fires on its own schedule regardless of notifications; the heavier heartbeat
// work (persisting last-seen, TTL pruning) stays on a ~30s cadence.
let mut status_tick = tokio::time::interval(Duration::from_secs(2));
@@ -1513,7 +1513,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
let connected = relays_connected(&client).await;
svc.connected.store(connected, Ordering::Relaxed);
// Relay-gated readiness + exit-health feedback for THIS generation:
// a live relay closes/keeps-open nymproc's readiness window; all
// a live relay closes/keeps-open the readiness window; all
// relays down for too long condemns the exit and reselects.
if connected {
crate::tor::report_relay_live(dial_gen);
@@ -1531,8 +1531,8 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
}
// Re-validate cached @usernames so a released/reassigned name
// stops showing. Only the stalest few per sweep (capped) to bound
// mixnet lookups; each worker re-checks against the identity server.
// Skipped while the app is backgrounded — no point spending mixnet
// Tor lookups; each worker re-checks against the identity server.
// Skipped while the app is backgrounded — no point spending Tor
// round-trips when nobody's looking. We DON'T advance last_name_sweep
// in that case, so the very next foreground tick runs the sweep
// immediately to catch up on resume.
@@ -1597,7 +1597,7 @@ async fn connect_relays(client: &Client, urls: &[String]) {
let url = url.clone();
async move {
let _ = client.add_relay(&url).await;
// Short cap: a reachable relay connects in ~2-4s over the mixnet; we
// Short cap: a reachable relay connects in ~2-4s over Tor; we
// don't want one dead relay in the list to stall the whole send. Once
// connected it stays connected, so only the first send pays this.
let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await;
@@ -1763,7 +1763,7 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
}
// Discovery fan-out off the caller's path: each indexer is gated by the
// lazy NIP-11 probe (over Nym) before use.
// lazy NIP-11 probe (over Tor) before use.
let client = client.clone();
tokio::spawn(async move {
let targets: Vec<String> = crate::nostr::pool::usable_discovery_relays()
+207
View File
@@ -331,10 +331,217 @@ impl NostrIdentity {
}
}
/// The decrypted contents of a full wallet backup (see [`build_full_backup`]):
/// the money seed phrase plus every held identity (each with its unlocked keys),
/// and the hex of the identity that was active when the backup was made.
pub struct FullBackup {
/// The 24-word grin recovery phrase, in memory only.
pub seed_phrase: String,
/// Hex pubkey of the identity that was active at backup time (may be empty).
pub active: String,
/// Every held identity with its unlocked keys, re-openable by the restorer.
pub identities: Vec<(NostrIdentity, Keys)>,
}
/// Seal an arbitrary UTF-8 string under a password with NO plaintext, reusing the
/// exact two-layer scheme of [`NostrIdentity::to_encrypted_backup`]: a fresh
/// random wrapper key is password-protected as a NIP-49 ncryptsec (scrypt), and
/// the text is NIP-44-sealed to that key. Returns `(k, d)` — the ncryptsec and
/// the sealed blob. The wrapper key is otherwise meaningless; only recovering the
/// text matters. No new crypto and no new dependency: same primitives the
/// identity backup already uses.
pub fn seal_secret_text(
plaintext: &str,
password: &str,
) -> Result<(String, String), IdentityError> {
let wrapper = Keys::generate();
let encrypted = EncryptedSecretKey::new(
wrapper.secret_key(),
password,
NCRYPTSEC_LOG_N,
KeySecurity::Medium,
)
.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
let k = encrypted
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?;
let d = nip44::encrypt(
wrapper.secret_key(),
&wrapper.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?;
Ok((k, d))
}
/// Reverse [`seal_secret_text`]: unlock the wrapper key with the password, then
/// open the NIP-44-sealed text. A wrong password fails at the ncryptsec layer.
pub fn open_secret_text(k: &str, d: &str, password: &str) -> Result<String, IdentityError> {
let enc = EncryptedSecretKey::from_bech32(k)
.map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?;
let secret = enc
.decrypt(password)
.map_err(|_| IdentityError::WrongPassword)?;
let keys = Keys::new(secret);
nip44::decrypt(keys.secret_key(), &keys.public_key(), d)
.map_err(|_| IdentityError::WrongPassword)
}
/// Build the contents of a FULL wallet `.backup` file (format version 2): the
/// money seed AND every held identity, all sealed under one password. The seed is
/// sealed with [`seal_secret_text`]; each identity is sealed with the SAME
/// per-identity scheme as the single-identity backup ([`NostrIdentity::to_encrypted_backup`]),
/// so every element is itself a valid v1 identity envelope. No plaintext (no
/// seed, no npub, no name) ever appears in the output. `identities` carries each
/// identity with its already-unlocked keys; `active_hex` is the identity active
/// at backup time.
pub fn build_full_backup(
seed_phrase: &str,
identities: &[(NostrIdentity, Keys)],
active_hex: &str,
password: &str,
) -> Result<String, IdentityError> {
let (k, d) = seal_secret_text(seed_phrase, password)?;
let mut elems = Vec::with_capacity(identities.len());
for (id, keys) in identities {
elems.push(id.to_encrypted_backup(keys)?);
}
let envelope = serde_json::json!({
"goblin_backup": 2,
"seed": { "k": k, "d": d },
"identities": elems,
"active": active_hex,
});
serde_json::to_string(&envelope).map_err(IdentityError::from)
}
/// True if `s` is a FULL wallet backup (format version 2 — seed + identities),
/// as opposed to a v1 single-identity backup or a bare nsec. Both formats set
/// `goblin_backup`; only v2 carries a seed, so callers that must create the
/// wallet check this FIRST.
pub fn is_full_backup(s: &str) -> bool {
serde_json::from_str::<serde_json::Value>(s.trim())
.ok()
.and_then(|v| v.get("goblin_backup").and_then(|x| x.as_u64()))
.map(|ver| ver >= 2)
.unwrap_or(false)
}
/// Open a full wallet backup with its password, returning the seed phrase, the
/// active identity's hex, and every held identity with its unlocked keys. A wrong
/// password fails at the seed's ncryptsec layer.
pub fn open_full_backup(blob: &str, password: &str) -> Result<FullBackup, IdentityError> {
let v: serde_json::Value = serde_json::from_str(blob.trim())?;
let seed = v
.get("seed")
.ok_or_else(|| IdentityError::Key("backup missing seed".into()))?;
let k = seed
.get("k")
.and_then(|x| x.as_str())
.ok_or_else(|| IdentityError::Key("backup missing seed key".into()))?;
let d = seed
.get("d")
.and_then(|x| x.as_str())
.ok_or_else(|| IdentityError::Key("backup missing seed data".into()))?;
let seed_phrase = open_secret_text(k, d, password)?;
let active = v
.get("active")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let mut identities = Vec::new();
if let Some(arr) = v.get("identities").and_then(|x| x.as_array()) {
for elem in arr {
let elem_str = elem
.as_str()
.ok_or_else(|| IdentityError::Key("malformed identity element".into()))?;
let (id, keys) = NostrIdentity::from_encrypted_backup(elem_str, password)?;
identities.push((id, keys));
}
}
Ok(FullBackup {
seed_phrase,
active,
identities,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_backup_roundtrips_seed_identities_and_active() {
// Build a full backup from a seed + several identities, then reopen it:
// the seed text, every identity's key, and the active marker must survive,
// and NOTHING sensitive (seed word, npub, name) may appear in the file.
let seed = "abandon abandon abandon abandon abandon abandon abandon abandon \
abandon abandon abandon abandon abandon abandon abandon abandon abandon \
abandon abandon abandon abandon abandon abandon art";
let (mut a, ka) = NostrIdentity::create_random("walletpw").unwrap();
a.nip05 = Some("alice@goblin.st".to_string());
a.anonymous = false;
let (b, kb) = NostrIdentity::create_random("walletpw").unwrap();
let active_hex = b.pubkey_hex().unwrap();
let ids = vec![(a.clone(), ka.clone()), (b.clone(), kb.clone())];
let blob = build_full_backup(seed, &ids, &active_hex, "walletpw").unwrap();
assert!(is_full_backup(&blob));
// A full backup is NOT a v1 single-identity backup, but the shared
// `goblin_backup` marker still reads as "an encrypted backup".
assert!(NostrIdentity::is_encrypted_backup(&blob));
// Opaque: no seed word, no npub, no username leaks.
assert!(!blob.contains("abandon"));
assert!(!blob.contains(&a.npub));
assert!(!blob.contains(&b.npub));
assert!(!blob.contains("alice"));
let opened = open_full_backup(&blob, "walletpw").unwrap();
assert_eq!(opened.seed_phrase, seed);
assert_eq!(opened.active, active_hex);
assert_eq!(opened.identities.len(), 2);
// Both identities and their keys restore exactly, metadata intact.
let npubs: Vec<_> = opened
.identities
.iter()
.map(|(i, _)| i.npub.clone())
.collect();
assert!(npubs.contains(&a.npub));
assert!(npubs.contains(&b.npub));
let restored_a = opened
.identities
.iter()
.find(|(i, _)| i.npub == a.npub)
.unwrap();
assert_eq!(restored_a.0.nip05.as_deref(), Some("alice@goblin.st"));
assert!(!restored_a.0.anonymous);
assert_eq!(restored_a.1.public_key(), ka.public_key());
// Wrong password opens nothing.
assert!(open_full_backup(&blob, "wrong").is_err());
}
#[test]
fn old_single_identity_backup_is_not_a_full_backup() {
// A v1 single-identity envelope must NOT be mistaken for a full backup, and
// must keep restoring through the v1 path exactly as before.
let (a, keys) = NostrIdentity::create_random("pw-1").unwrap();
let v1 = a.to_encrypted_backup(&keys).unwrap();
assert!(NostrIdentity::is_encrypted_backup(&v1));
assert!(!is_full_backup(&v1), "v1 must not read as a full backup");
let (restored, _) = NostrIdentity::from_encrypted_backup(&v1, "pw-1").unwrap();
assert_eq!(restored.npub, a.npub);
}
#[test]
fn seal_secret_text_roundtrips_and_is_opaque() {
let secret = "the quick brown fox";
let (k, d) = seal_secret_text(secret, "pw").unwrap();
assert!(!k.contains(secret) && !d.contains(secret));
assert_eq!(open_secret_text(&k, &d, "pw").unwrap(), secret);
assert!(open_secret_text(&k, &d, "nope").is_err());
}
#[test]
fn backup_restores_under_new_password() {
// Export under one wallet password, restore on a device with another.
+4 -2
View File
@@ -14,7 +14,7 @@
//! Nostr payment-messaging subsystem: contacts are nostr users, slatepacks
//! travel as NIP-17 private DMs (NIP-44 encrypted, NIP-59 gift-wrapped) over
//! relays reached through the in-process Nym mixnet client.
//! relays reached through the embedded Tor client.
mod types;
pub use types::*;
@@ -29,7 +29,9 @@ mod store;
pub use store::NostrStore;
mod identity;
pub use identity::{IdentitySource, NostrIdentity};
pub use identity::{
FullBackup, IdentitySource, NostrIdentity, build_full_backup, is_full_backup, open_full_backup,
};
pub mod identities;
pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since};
+10 -107
View File
@@ -13,9 +13,9 @@
// limitations under the License.
//! Relay candidate pool: a maintained list of vetted public relays fetched
//! from the project gist over the Nym mixnet, cached on disk, with a pinned
//! from the project gist over Tor, cached on disk, with a pinned
//! copy compiled in for first-run/offline. Pool relays are gated LAZILY: a
//! NIP-11 probe (also over Nym) runs only right before a relay is actually
//! NIP-11 probe (also over Tor) runs only right before a relay is actually
//! used — no background sweeps.
use lazy_static::lazy_static;
@@ -45,7 +45,7 @@ const CACHE_MAX_AGE_SECS: u64 = 7 * 86_400;
/// NIP-11 probe results are reused for this long (24 h, in memory).
const PROBE_TTL_SECS: i64 = 24 * 3600;
/// Per-probe cap: a dead relay must not stall the caller for the full mixnet
/// Per-probe cap: a dead relay must not stall the caller for the full Tor
/// HTTP timeout — a failed probe just skips the relay this time.
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
@@ -82,18 +82,6 @@ pub struct PoolRelay {
/// Last-vetted date; presence marks the entry as vetted.
#[serde(default)]
pub vetted: Option<String>,
/// This relay operator's CO-LOCATED Nym exit address, when they run one (the
/// bundled floonet-rs / floonet-strfry `exit = true` feature). It is a Nym
/// `Recipient` (`<client>.<enc>@<gateway>`) for a SCOPED MixnetStream proxy
/// that forwards ONLY to this relay — so the wallet can reach the relay over
/// the mixnet WITHOUT public DNS and WITHOUT depending on a public IPR exit
/// (the anchor; see [`crate::nym::nymproc`]). Absent → this relay is reached
/// the old way (public-IPR smolmix + in-tunnel DoT). Carried in the pinned
/// pool so the money-path default relay's exit bootstraps OFFLINE, before any
/// network — breaking the chicken-and-egg of learning it over the very path
/// it is meant to replace.
#[serde(default)]
pub exit: Option<String>,
}
impl PoolRelay {
@@ -140,46 +128,6 @@ impl RelayPool {
.map(|r| r.url.clone())
.collect()
}
/// The operator's co-located Nym exit address for `url`, if the pool
/// advertises one (url compared modulo a trailing slash). `None` → reach the
/// relay over the public-IPR path as before. This is how the wallet learns
/// the anchor exit for its money-path relay (see [`PoolRelay::exit`]).
pub fn exit_for(&self, url: &str) -> Option<String> {
let want = url.trim_end_matches('/');
self.relays
.iter()
.find(|r| r.url.trim_end_matches('/') == want)
.and_then(|r| r.exit.clone())
.filter(|e| !e.trim().is_empty())
}
/// Like [`Self::exit_for`], but keyed on the HOSTNAME — the HTTP dial site
/// ([`crate::nym::request_once`]) knows only `host`, never the relay's ws
/// URL. HTTPS to a host whose relay advertises a co-located exit (its
/// NIP-11 probe, in practice) rides that exit too.
pub fn exit_for_host(&self, host: &str) -> Option<String> {
self.relays
.iter()
.find(|r| {
url::Url::parse(&r.url)
.ok()
.and_then(|u| u.host_str().map(|h| h.eq_ignore_ascii_case(host)))
.unwrap_or(false)
})
.and_then(|r| r.exit.clone())
.filter(|e| !e.trim().is_empty())
}
/// Whether ANY relay in the pool advertises a co-located exit. The cold-start
/// sequencer ([`crate::nym::nymproc`]) reads this to decide whether to give
/// the scoped-exit client its bandwidth-grant head start before building the
/// public-IPR tunnel — no exit anywhere → no wait, unchanged behavior.
pub fn has_exit(&self) -> bool {
self.relays
.iter()
.any(|r| r.exit.as_deref().is_some_and(|e| !e.trim().is_empty()))
}
}
/// Disk path of the cached pool file.
@@ -196,9 +144,9 @@ pub fn load() -> RelayPool {
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
}
/// Refresh the disk cache from the gist — over the Nym mixnet, like all other
/// Refresh the disk cache from the gist — over Tor, like all other
/// HTTP — when it is absent or older than 7 days. At most one attempt per app
/// run; call only once the Nym tunnel is up.
/// run; call only once Tor is up.
pub async fn refresh_if_stale() {
static TRIED: AtomicBool = AtomicBool::new(false);
if TRIED.swap(true, Ordering::SeqCst) {
@@ -265,7 +213,7 @@ fn nip11_pass(doc: &serde_json::Value, min_len: u64) -> bool {
.unwrap_or(true)
}
/// Lazy per-use probe: fetch the relay's NIP-11 document (HTTP over Nym,
/// Lazy per-use probe: fetch the relay's NIP-11 document (HTTP over Tor,
/// `Accept: application/nostr+json`) and apply the gate. Results are cached
/// for 24 h; an unreachable or unparseable document fails, which just skips
/// the relay this time.
@@ -301,7 +249,7 @@ pub async fn probe(url: &str) -> bool {
/// The pool's "discovery" relays that pass the lazy NIP-11 gate right now.
pub async fn usable_discovery_relays() -> Vec<String> {
// Probe every candidate CONCURRENTLY (each is a NIP-11 HTTP round trip over
// the mixnet — sequentially this cost ~N × a full round trip). The PROBES
// Tor — sequentially this cost ~N × a full round trip). The PROBES
// cache is RwLock-safe under concurrent access. Zip the pass/fail results back
// to the urls and keep the passing ones in the original pool order.
let urls = load().discovery_relays();
@@ -373,50 +321,6 @@ mod tests {
assert!(disc.contains(&"wss://relay.0xchat.com".to_string()));
}
#[test]
fn exit_field_is_optional_and_looked_up_by_url() {
// 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_none());
// A pool that DOES advertise an exit for one relay.
let pool = RelayPool::parse(
r#"{"version":1,"updated":"x","min_message_length":131072,"relays":[
{"url":"wss://relay.goblin.st/","roles":["dm"],"exit":"aaa.bbb@ccc"},
{"url":"wss://nos.lol","roles":["dm"]},
{"url":"wss://blank.example","roles":["dm"],"exit":" "}
]}"#,
)
.unwrap();
// Trailing-slash-insensitive lookup.
assert_eq!(
pool.exit_for("wss://relay.goblin.st"),
Some("aaa.bbb@ccc".to_string())
);
// No exit field → None; blank exit → None (treated as unset).
assert!(pool.exit_for("wss://nos.lol").is_none());
assert!(pool.exit_for("wss://blank.example").is_none());
// Unknown url → None.
assert!(pool.exit_for("wss://unknown.example").is_none());
// Host-keyed lookup (the HTTP dial site): same answers by hostname.
assert_eq!(
pool.exit_for_host("relay.goblin.st"),
Some("aaa.bbb@ccc".to_string())
);
assert_eq!(
pool.exit_for_host("RELAY.GOBLIN.ST"),
Some("aaa.bbb@ccc".to_string())
);
assert!(pool.exit_for_host("nos.lol").is_none());
assert!(pool.exit_for_host("blank.example").is_none());
assert!(pool.exit_for_host("unknown.example").is_none());
}
#[test]
fn pool_validation_rejects_bad_documents() {
assert!(RelayPool::parse("not json").is_none());
@@ -434,11 +338,12 @@ mod tests {
RelayPool::parse(r#"{"version":1,"updated":"x","min_message_length":1,"relays":[]}"#)
.is_none()
);
// Unknown fields (like the gist's "notes") are tolerated; a missing
// Unknown fields (the gist's "notes", or a stray per-relay "exit" left
// over from the retired co-located-exit schema) are tolerated; a missing
// "vetted" parses as unvetted.
let pool = RelayPool::parse(
r#"{"version":1,"updated":"x","notes":"n","min_message_length":131072,
"relays":[{"url":"wss://a","roles":["dm"]}]}"#,
"relays":[{"url":"wss://a","roles":["dm"],"exit":"aaa.bbb@ccc"}]}"#,
)
.unwrap();
assert!(pool.relays[0].vetted.is_none());
@@ -484,7 +389,6 @@ mod tests {
url: url.to_string(),
roles: vec!["dm".to_string()],
vetted: vetted.then(|| "2026-07-01".to_string()),
exit: None,
};
vec![
mk("wss://a.example", false),
@@ -509,7 +413,6 @@ mod tests {
url: "wss://relay.goblin.st".to_string(),
roles: vec!["dm".to_string()],
vetted: Some("2026-07-01".to_string()),
exit: None,
});
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
assert_eq!(order.len(), 4);
+33 -3
View File
@@ -30,8 +30,10 @@ use crate::nostr::types::*;
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;
/// shows the latest, so this is just a small archive bound. Sized for two full
/// nine-language posts so the oldest-`created_at` variant (English is published
/// first) is not evicted before its readers see it.
const NEWS_CAP: usize = 18;
/// Nostr metadata archive for a wallet.
pub struct NostrStore {
@@ -385,12 +387,16 @@ mod tests {
use super::*;
fn item(d: &str, created_at: i64) -> NewsItem {
item_lang(d, created_at, None)
}
fn item_lang(d: &str, created_at: i64, lang: Option<&str>) -> NewsItem {
NewsItem {
d: d.to_string(),
created_at,
title: format!("t{created_at}"),
summary: String::new(),
lang: None,
lang: lang.map(str::to_string),
published_at: None,
}
}
@@ -418,4 +424,28 @@ mod tests {
assert_eq!(all[0].created_at, 9);
assert_eq!(all[2].created_at, 7);
}
#[test]
fn nine_language_batch_retains_english_under_news_cap() {
// A single post now ships as nine per-`d` language variants. The
// publisher emits English FIRST, so the untagged (lang == None) English
// event carries the OLDEST `created_at` in the batch. Under the real
// `NEWS_CAP` the whole batch must survive so English readers see it.
let langs = ["es", "fr", "de", "it", "pt", "ja", "zh", "ko"];
let mut all = vec![];
// English published first → oldest created_at, no lang tag.
all = reconcile_news(all, item_lang("post-en", 100, None), NEWS_CAP);
for (i, code) in langs.iter().enumerate() {
all = reconcile_news(
all,
item_lang(&format!("post-{code}"), 101 + i as i64, Some(code)),
NEWS_CAP,
);
}
assert_eq!(all.len(), 9);
assert!(
all.iter().any(|n| n.d == "post-en" && n.lang.is_none()),
"untagged English variant must survive the cap"
);
}
}
-662
View File
@@ -1,662 +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.
//! DNS resolution THROUGH the mixnet, over DoT (DNS-over-TLS, RFC 7858).
//! `Tunnel::tcp_connect` takes a `SocketAddr`, so resolving the hostname is our
//! job (the old SOCKS5 network requester resolved at the exit for us) — and it
//! rides the tunnel so neither the query nor its answer ever touches the clear:
//! a clearnet lookup would leak exactly which relays/nodes Goblin contacts,
//! defeating the mixnet.
//!
//! WHY DoT (TCP+TLS), not the old UDP mix-dns: the previous path sent raw UDP
//! datagrams over the mixnet, and mixnet UDP LOSES packets — a lost datagram
//! stalled behind a multi-second timeout, and Phase-1 measurements showed
//! resolves taking ~10s (21 lost-datagram retries) which tipped relay connects
//! past the exit-condemnation grace and drove the 2-3 minute reselect loop. DoT
//! runs the DNS query over a TCP+TLS connection through the tunnel: TCP
//! RETRANSMITS, so there are no packet-loss stalls, and TLS ENCRYPTS the query
//! end to end, so not even the IPR exit can see (or forge) which host we asked
//! for. Reliable AND private AND authenticated — smolmix is a TCP tunnel and is
//! good at TCP. (The exit policy allows :853 — verified live by the
//! `probe_dns_ports` harness before shipping this; if a future exit blocks 853,
//! DoH on 443 is the drop-in fallback.)
//!
//! Wire codec: hickory-proto — already in the dependency graph via
//! nym-http-api-client, so no vendored encode/parse is needed. DoT framing is
//! the DNS message prefixed with its 2-byte big-endian length (RFC 1035 §4.2.2).
//! Answers land in a TTL-respecting in-memory cache and hosts are prewarmed at
//! startup, so a warm entry (not a fresh mixnet round trip) serves the common
//! case. IPv4-only, like the rest of the app (GRIM audit).
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::stream::{FuturesUnordered, StreamExt};
use hickory_proto::op::{Message, MessageType, Query, ResponseCode};
use hickory_proto::rr::{Name, RData, RecordType};
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use lazy_static::lazy_static;
use log::{debug, warn};
use parking_lot::RwLock;
use smolmix::Tunnel;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A DoT resolver: the IP:853 to dial through the tunnel and the SNI / cert name
/// its DoT endpoint presents (the query is validated against this hostname, so a
/// hostile exit that redirects the IP cannot MITM the lookup).
struct DotResolver {
addr: SocketAddr,
sni: &'static str,
}
/// DoT resolvers, RACED against each other (not primary/fallback) so a slow or
/// unlucky handshake to one never stalls behind it — whichever answers first
/// wins. Addressed BY IP (no bootstrap chicken-and-egg); the SNI is validated.
const DOT_RESOLVERS: [DotResolver; 2] = [
DotResolver {
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 853),
sni: "cloudflare-dns.com",
},
DotResolver {
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 853),
sni: "dns.quad9.net",
},
];
/// A DoH resolver: the IP:443 to dial through the tunnel, its SNI/cert + Host
/// name, and the RFC 8484 query path. DoH is the FALLBACK for an exit whose
/// policy blocks DoT (:853) — 443 is guaranteed reachable (relays + HTTPS ride
/// it), so DNS never has to touch the clearnet.
struct DohResolver {
ip: SocketAddr,
sni: &'static str,
host: &'static str,
path: &'static str,
}
const DOH_RESOLVERS: [DohResolver; 2] = [
DohResolver {
ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
sni: "cloudflare-dns.com",
host: "cloudflare-dns.com",
path: "/dns-query",
},
DohResolver {
ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
sni: "dns.quad9.net",
host: "dns.quad9.net",
path: "/dns-query",
},
];
/// Which in-tunnel DNS transport a lookup uses. NEVER clearnet.
#[derive(Clone, Copy)]
enum DnsMode {
/// DoT — DNS-over-TLS on :853 (preferred; smallest overhead).
Dot,
/// DoH — DNS-over-HTTPS on :443 (fallback when an exit blocks :853).
Doh,
}
/// Sticky: set once an exit is found to block DoT (:853), so we stop paying the
/// DoT timeout on every subsequent lookup and go straight to DoH (:443). Both
/// stay inside the tunnel — this only picks which in-tunnel transport to use.
static PREFER_DOH: AtomicBool = AtomicBool::new(false);
/// Per-query answer wait. DoT includes a TCP + TLS handshake over the mixnet
/// (a few seconds of deliberate per-hop delay), so allow more headroom than the
/// UDP path did; a round that exceeds this is retried rather than waited out.
const DOT_QUERY_TIMEOUT: Duration = Duration::from_secs(8);
/// Quick race-both-resolvers rounds before giving up. DoT is TCP-reliable within
/// a round, so two rounds is plenty (the second only matters if a whole
/// connection was dropped).
const DOT_ROUNDS: usize = 2;
/// DoH per-query wait (TCP + TLS + one HTTP round trip over the mixnet) and its
/// round count. Same reliability as DoT (TCP), a touch more per-request overhead
/// (HTTP framing), so the timeout is a shade more generous.
const DOH_QUERY_TIMEOUT: Duration = Duration::from_secs(10);
const DOH_ROUNDS: usize = 2;
/// TTL floor/ceiling for the cache: don't hammer resolvers for zero-TTL
/// records, don't trust a stale record for more than an hour.
const TTL_FLOOR_SECS: u32 = 60;
const TTL_CEILING_SECS: u32 = 3600;
/// TTL floor for KNOWN/stable hosts (relays, the name authority, the price API,
/// the DoT/DoH resolvers) — the ones we prewarm. Their addresses change rarely,
/// so we keep them cached at least 15 min (up to the 60-min ceiling) instead of
/// re-resolving every minute. Combined with serve-stale (below) this means a
/// dial to one of these NEVER blocks on a fresh mixnet DoT round trip.
const KNOWN_TTL_FLOOR_SECS: u32 = 900;
lazy_static! {
/// host → (addresses, expiry).
static ref CACHE: RwLock<HashMap<String, (Vec<Ipv4Addr>, Instant)>> =
RwLock::new(HashMap::new());
/// Hosts we treat as known/stable (populated by [`prewarm`]). Known hosts get
/// the longer [`KNOWN_TTL_FLOOR_SECS`] floor AND serve-stale-while-revalidate.
static ref KNOWN: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
/// Hosts with a background revalidation in flight — single-flight guard so a
/// burst of dials to a stale known host spawns exactly one refresh.
static ref REFRESHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
}
/// Whether `host` is a known/stable host (has been prewarmed at least once).
fn is_known(host: &str) -> bool {
KNOWN.read().contains(host)
}
/// Resolve `host` to a socket address for `tcp_connect`, entirely over the
/// mixnet via DoT. IP-literal hosts skip DNS; cached answers are honored until
/// their (clamped) TTL lapses. Each round RACES both resolvers concurrently and
/// takes the first valid answer; a round with no answer is retried. Returns
/// `None` only after every round fails.
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
// IP literals (v4 or v6) need no lookup at all.
if let Ok(ip) = host.parse::<IpAddr>() {
return Some(SocketAddr::new(ip, port));
}
match cache_hit(host) {
// Fresh entry: serve it, no network at all.
Some(CacheHit::Fresh(ip)) => return Some(SocketAddr::new(IpAddr::V4(ip), port)),
// SERVE-STALE-WHILE-REVALIDATE for known/stable hosts: hand back the
// last-known address immediately (so the dial never blocks on a cold DoT
// round trip) and refresh it in the background. Unknown hosts fall
// through to a blocking resolve, preserving correctness.
Some(CacheHit::Stale(ip)) if is_known(host) => {
spawn_revalidate(tunnel, host);
return Some(SocketAddr::new(IpAddr::V4(ip), port));
}
_ => {}
}
resolve_cold(tunnel, host, port).await
}
/// The blocking DoT-then-DoH resolve, run when there is no usable cache entry.
/// Writes the cache on success.
async fn resolve_cold(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
// If a previous lookup already learned this exit blocks DoT, go straight to
// DoH — still entirely inside the tunnel.
if PREFER_DOH.load(Ordering::Acquire) {
return resolve_via(tunnel, host, port, DnsMode::Doh).await;
}
// DoT first; on total failure (exit likely blocks :853) fall back to DoH on
// :443 — which is guaranteed reachable through the exit. There is NEVER a
// clearnet fallback: both transports ride the mixnet.
if let Some(addr) = resolve_via(tunnel, host, port, DnsMode::Dot).await {
return Some(addr);
}
if !PREFER_DOH.swap(true, Ordering::AcqRel) {
warn!("dns: DoT (:853) unavailable through this exit; using DoH (:443) over the tunnel");
}
resolve_via(tunnel, host, port, DnsMode::Doh).await
}
/// Kick off a background refresh of a stale known host through the current
/// tunnel, at most one in flight per host.
fn spawn_revalidate(tunnel: &Tunnel, host: &str) {
let host = host.to_string();
// Single-flight: skip if a refresh for this host is already running.
if !REFRESHING.write().insert(host.clone()) {
return;
}
let tunnel = tunnel.clone();
tokio::spawn(async move {
// Port is irrelevant here — only the host-keyed cache is refreshed.
let _ = resolve_cold(&tunnel, &host, 0).await;
REFRESHING.write().remove(&host);
});
}
/// Run the round loop for one in-tunnel DNS transport, writing the cache on the
/// first valid answer. Shared by DoT / DoH.
async fn resolve_via(tunnel: &Tunnel, host: &str, port: u16, mode: DnsMode) -> Option<SocketAddr> {
let (proto, rounds) = match mode {
DnsMode::Dot => ("dot-dns", DOT_ROUNDS),
DnsMode::Doh => ("doh-dns", DOH_ROUNDS),
};
let start = Instant::now();
for round in 0..rounds {
let answer = match mode {
DnsMode::Dot => race_dot(tunnel, host).await,
DnsMode::Doh => race_doh(tunnel, host).await,
};
if let Some((resolver, ips, ttl)) = answer {
// Known/stable hosts get the longer floor so they stay cached 15-60
// min; everything else keeps the tight 60s..1h window.
let floor = if is_known(host) {
KNOWN_TTL_FLOOR_SECS
} else {
TTL_FLOOR_SECS
};
let ttl = ttl.clamp(floor, TTL_CEILING_SECS);
debug!(
"{proto}: resolved {host} -> {} in {}ms (via {resolver}, round {}/{rounds}, \
ttl {ttl}s, {} record(s))",
ips[0],
start.elapsed().as_millis(),
round + 1,
ips.len()
);
let expiry = Instant::now() + Duration::from_secs(ttl as u64);
CACHE
.write()
.insert(host.to_string(), (ips.clone(), expiry));
return Some(SocketAddr::new(IpAddr::V4(ips[0]), port));
}
debug!(
"{proto}: no answer for {host} in round {}/{rounds}, retrying",
round + 1
);
}
debug!(
"{proto}: resolution failed for {host} after {rounds} rounds ({}ms)",
start.elapsed().as_millis()
);
None
}
/// One DoT round: fire an A query at EVERY resolver concurrently and return the
/// first valid, non-empty answer (with the resolver address that produced it). A
/// resolver that errors or times out is simply outrun.
async fn race_dot(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec<Ipv4Addr>, u32)> {
let mut inflight = FuturesUnordered::new();
for resolver in &DOT_RESOLVERS {
inflight.push(async move {
let answer = tokio::time::timeout(DOT_QUERY_TIMEOUT, query_dot(tunnel, host, resolver))
.await
.ok()
.flatten();
(resolver.addr, answer)
});
}
while let Some((addr, answer)) = inflight.next().await {
if let Some((ips, ttl)) = answer
&& !ips.is_empty()
{
return Some((addr, ips, ttl));
}
}
None
}
/// One DoT A query/response over the tunnel against `resolver`: TCP connect
/// through the mixnet, TLS (rustls, webpki roots, SNI-validated), then the DNS
/// message framed with its 2-byte big-endian length, and the length-framed
/// response read back.
async fn query_dot(
tunnel: &Tunnel,
host: &str,
resolver: &DotResolver,
) -> Option<(Vec<Ipv4Addr>, u32)> {
let tcp = tunnel
.tcp_connect(resolver.addr)
.await
.map_err(|e| debug!("dot-dns: connect to {} failed: {e}", resolver.addr))
.ok()?;
let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?;
let mut tls = tokio_rustls::TlsConnector::from(super::tls_config())
.connect(server_name, tcp)
.await
.map_err(|e| debug!("dot-dns: tls handshake with {} failed: {e}", resolver.sni))
.ok()?;
let id = rand::random::<u16>();
let query = encode_query(id, host)?;
// RFC 7858 / RFC 1035 §4.2.2: 2-byte big-endian length prefix + message.
let mut framed = Vec::with_capacity(2 + query.len());
framed.extend_from_slice(&(query.len() as u16).to_be_bytes());
framed.extend_from_slice(&query);
tls.write_all(&framed)
.await
.map_err(|e| debug!("dot-dns: send to {} failed: {e}", resolver.sni))
.ok()?;
tls.flush().await.ok()?;
let mut len_buf = [0u8; 2];
tls.read_exact(&mut len_buf)
.await
.map_err(|e| debug!("dot-dns: recv len from {} failed: {e}", resolver.sni))
.ok()?;
let len = u16::from_be_bytes(len_buf) as usize;
if len == 0 {
return None;
}
let mut resp = vec![0u8; len];
tls.read_exact(&mut resp)
.await
.map_err(|e| debug!("dot-dns: recv body from {} failed: {e}", resolver.sni))
.ok()?;
parse_response(id, &resp)
}
/// One DoH round: race both resolvers and take the first valid, non-empty
/// answer (with the resolver IP that produced it).
async fn race_doh(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec<Ipv4Addr>, u32)> {
let mut inflight = FuturesUnordered::new();
for resolver in &DOH_RESOLVERS {
inflight.push(async move {
let answer = tokio::time::timeout(DOH_QUERY_TIMEOUT, query_doh(tunnel, host, resolver))
.await
.ok()
.flatten();
(resolver.ip, answer)
});
}
while let Some((ip, answer)) = inflight.next().await {
if let Some((ips, ttl)) = answer
&& !ips.is_empty()
{
return Some((ip, ips, ttl));
}
}
None
}
/// One DoH A query over the tunnel against `resolver` (RFC 8484): TCP connect
/// through the mixnet, TLS (SNI-validated), then an HTTP/1.1 POST to the
/// resolver's /dns-query with the wire-format DNS message as the body and
/// `application/dns-message` content type; the wire-format response body is
/// parsed the same way as DoT/UDP.
async fn query_doh(
tunnel: &Tunnel,
host: &str,
resolver: &DohResolver,
) -> Option<(Vec<Ipv4Addr>, u32)> {
let id = rand::random::<u16>();
let query = encode_query(id, host)?;
let tcp = tunnel
.tcp_connect(resolver.ip)
.await
.map_err(|e| debug!("doh-dns: connect to {} failed: {e}", resolver.ip))
.ok()?;
let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?;
let tls = tokio_rustls::TlsConnector::from(super::tls_config())
.connect(server_name, tcp)
.await
.map_err(|e| debug!("doh-dns: tls handshake with {} failed: {e}", resolver.sni))
.ok()?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.map_err(|e| debug!("doh-dns: http handshake with {} failed: {e}", resolver.host))
.ok()?;
tokio::spawn(async move {
let _ = conn.await;
});
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(resolver.path)
.header(hyper::header::HOST, resolver.host)
.header(hyper::header::CONTENT_TYPE, "application/dns-message")
.header(hyper::header::ACCEPT, "application/dns-message")
.header(hyper::header::USER_AGENT, "goblin-wallet")
.body(Full::new(Bytes::from(query)))
.ok()?;
let resp = sender
.send_request(req)
.await
.map_err(|e| debug!("doh-dns: request to {} failed: {e}", resolver.host))
.ok()?;
if resp.status() != hyper::StatusCode::OK {
debug!("doh-dns: {} returned {}", resolver.host, resp.status());
return None;
}
let body = resp.into_body().collect().await.ok()?.to_bytes();
parse_response(id, &body)
}
/// Resolve a batch of hosts concurrently to populate the cache, so the first
/// real use (relay dial, NIP-05 name claim, price fetch) hits a warm entry
/// instead of paying the mixnet DoT round trip inline. Best-effort; the port is
/// irrelevant here (only the host-keyed cache is filled) so a placeholder is used.
pub async fn prewarm(tunnel: &Tunnel, hosts: &[String]) {
// Mark these as known/stable so they get the long TTL floor and serve-stale.
{
let mut known = KNOWN.write();
for host in hosts {
known.insert(host.clone());
}
}
let mut inflight = FuturesUnordered::new();
for host in hosts {
inflight.push(resolve(tunnel, host, 0));
}
while inflight.next().await.is_some() {}
}
/// A cache lookup outcome for `host`: fresh (within TTL) or stale (expired but
/// still remembered, usable via serve-stale for known hosts).
enum CacheHit {
Fresh(Ipv4Addr),
Stale(Ipv4Addr),
}
/// Look up `host` in the cache, distinguishing fresh from stale entries. Returns
/// `None` only when the host has never been resolved.
fn cache_hit(host: &str) -> Option<CacheHit> {
let cache = CACHE.read();
let (ips, expiry) = cache.get(host)?;
let ip = ips.first().copied()?;
Some(if Instant::now() < *expiry {
CacheHit::Fresh(ip)
} else {
CacheHit::Stale(ip)
})
}
/// Stable public addresses the liveness probe RACES through the tunnel: a tunnel
/// is alive if it can reach ANY of them. Racing (not one fixed target) is why a
/// momentarily slow path to a single resolver no longer false-declares a healthy
/// exit DEAD — the same reason the DoT/DoH resolvers above are raced, not tried in
/// series. Both are anycast resolvers on :443 (never exit-policy-firewalled, since
/// relays + HTTPS already ride it) and effectively always-on.
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 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 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;
/// 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 {
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(timeout, tunnel.tcp_connect(addr)).await,
Ok(Ok(_))
)
});
}
while let Some(reached) = inflight.next().await {
if reached {
return true;
}
}
debug!(
"probe: no stable target reachable through tunnel (round {}/{rounds})",
round + 1
);
}
debug!("probe: tunnel failed liveness — reached no stable target in {rounds} rounds");
false
}
/// Encode a recursive A query for `host` with transaction id `id`.
fn encode_query(id: u16, host: &str) -> Option<Vec<u8>> {
let name = Name::from_ascii(host).ok()?;
let mut msg = Message::query();
msg.metadata.id = id;
msg.metadata.recursion_desired = true;
msg.add_query(Query::query(name, RecordType::A));
msg.to_vec().ok()
}
/// Parse a response to transaction `id`: all A records in the answer section
/// plus the smallest TTL among them. `None` on id mismatch, non-response,
/// error rcode or no A records (CNAMEs and other types are skipped).
fn parse_response(id: u16, raw: &[u8]) -> Option<(Vec<Ipv4Addr>, u32)> {
let msg = Message::from_vec(raw).ok()?;
if msg.metadata.id != id
|| msg.metadata.message_type != MessageType::Response
|| msg.metadata.response_code != ResponseCode::NoError
{
return None;
}
let mut ips = Vec::new();
let mut ttl = u32::MAX;
for record in &msg.answers {
if let RData::A(a) = record.data {
ips.push(a.0);
ttl = ttl.min(record.ttl);
}
}
if ips.is_empty() {
None
} else {
Some((ips, ttl))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Query for `example.com` A/IN, id 0x1234, RD set — the canonical fixture
/// (same bytes smolmix's own docs use).
const QUERY_FIXTURE: &[u8] = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
/// Response to `QUERY_FIXTURE`: flags 0x8180 (QR, RD, RA, NOERROR), one
/// question, two answers — a CNAME (ttl 3600, rdata = compression pointer
/// back to the qname) that must be skipped, then an A record for
/// 93.184.216.34 with ttl 300.
const RESPONSE_FIXTURE: &[u8] = b"\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01\
\xc0\x0c\x00\x05\x00\x01\x00\x00\x0e\x10\x00\x02\xc0\x0c\
\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
#[test]
fn encode_query_matches_fixture() {
let bytes = encode_query(0x1234, "example.com").unwrap();
assert_eq!(bytes, QUERY_FIXTURE);
}
#[test]
fn parse_response_extracts_a_records_and_min_ttl() {
let (ips, ttl) = parse_response(0x1234, RESPONSE_FIXTURE).unwrap();
assert_eq!(ips, vec![Ipv4Addr::new(93, 184, 216, 34)]);
// The CNAME's larger ttl (3600) must not win: only A records count.
assert_eq!(ttl, 300);
}
#[test]
fn parse_response_rejects_wrong_id() {
assert!(parse_response(0x5678, RESPONSE_FIXTURE).is_none());
}
#[test]
fn parse_response_rejects_query_and_garbage() {
// A query (QR=0) is not an answer.
assert!(parse_response(0x1234, QUERY_FIXTURE).is_none());
// Truncated/garbage input parses to nothing.
assert!(parse_response(0x1234, &RESPONSE_FIXTURE[..7]).is_none());
assert!(parse_response(0x1234, b"\x00").is_none());
}
#[test]
fn parse_response_rejects_error_rcode() {
// Same fixture with rcode NXDOMAIN (flags 0x8183) and no answers.
let nx: &[u8] = b"\x12\x34\x81\x83\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
assert!(parse_response(0x1234, nx).is_none());
}
#[test]
fn ttl_clamp_bounds() {
assert_eq!(5u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 60);
assert_eq!(999_999u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 3600);
assert_eq!(300u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 300);
}
}
-411
View File
@@ -1,411 +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.
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an
//! auto-selected public IPR exit, so neither the payload nor the
//! destination-in-flight ever touches the clearnet. Hostnames resolve through
//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes
//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located
//! scoped exit in the pool is instead dialed over a MixnetStream straight to
//! that exit ([`streamexit`]) — no DNS and no public IPR at all — falling
//! back to the tunnel on any failure. The mixnet breaks the sender↔receiver
//! timing correlation that Mimblewimble's interactive slate exchange
//! otherwise leaks at the network layer.
//!
//! DNS reliability was the one weak spot: the original mix-dns sent UDP over the
//! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second
//! timeouts (~10s measured), tipping relay connects past the exit-condemnation
//! grace and driving a 2-3 minute reselect loop. Build 98 moves DNS to DoT
//! (TCP+TLS through the tunnel): TCP retransmits (no packet-loss stalls) and TLS
//! encrypts the query from the exit — reliable AND private.
pub mod dns;
pub mod nymproc;
pub mod streamexit;
pub mod transport;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use log::{debug, warn};
use tokio::io::{AsyncRead, AsyncWrite};
pub use nymproc::{
condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer,
transport_ready, tunnel_generation, warm_up,
};
pub use transport::NymWebSocketTransport;
/// How long a single HTTP exchange (one redirect hop) may take end to end.
/// The mixnet adds deliberate per-hop delay; allow generous time.
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the shared tunnel before giving up on a request.
const TUNNEL_WAIT: Duration = Duration::from_secs(30);
/// Redirect hops to follow before giving up (matches the old client, which
/// followed redirects transparently).
const MAX_REDIRECTS: usize = 5;
/// An HTTP request routed over the Nym mixnet: resolve the host over the tunnel
/// (DoT — see [`dns`]), then `tcp_connect` to that IP through the tunnel, then
/// rustls (webpki roots) for https, then HTTP/1.1. Follows redirects. Returns
/// `(status, body)`.
pub async fn http_request_bytes(
method: &str,
url: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
let tunnel = nymproc::wait_for_tunnel(TUNNEL_WAIT).await?;
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(&tunnel, &method, &url, body.clone(), &headers),
)
.await
.map_err(|_| warn!("nym http: request to {} timed out", redacted(&url)))
.ok()??;
match location {
Some(loc) => {
url = url.join(&loc).ok()?;
// Like the old client: 303 (and legacy 301/302) turn into a
// bodiless GET; 307/308 replay the method + body.
if matches!(status, 301..=303) {
method = "GET".to_string();
body = None;
}
debug!(
"nym http: following {status} redirect to {}",
redacted(&url)
);
}
None => return Some((status, resp_body)),
}
}
warn!("nym 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()
}
/// How long a pooled keep-alive connection may sit idle before we discard it
/// rather than reuse a possibly half-dead handle (hyper's `is_closed()` catches
/// cleanly-closed ones; this bounds the silent-death window).
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Pool key: a live HTTP/1.1 keep-alive connection is reusable only for the same
/// host, port and scheme.
#[derive(Clone, PartialEq, Eq, Hash)]
struct ConnKey {
host: String,
port: u16,
https: bool,
}
/// A pooled hyper request handle. The body type matches [`request_once`]'s.
type HttpSender = hyper::client::conn::http1::SendRequest<Full<Bytes>>;
struct Pooled {
sender: HttpSender,
idle_since: Instant,
}
lazy_static::lazy_static! {
/// Idle keep-alive connections, keyed by (host, port, https). A sender is
/// REMOVED while in use and reinserted when the exchange finishes, so the map
/// only ever holds idle handles and the lock is never held across an await.
static ref CONN_POOL: Mutex<HashMap<ConnKey, Pooled>> = Mutex::new(HashMap::new());
}
/// Take a live, non-idle-expired pooled sender for `key`, if one exists. A
/// closed or stale handle is dropped (tearing down its connection) and `None`
/// returned so the caller builds a fresh one.
fn take_pooled(key: &ConnKey) -> Option<HttpSender> {
let mut pool = CONN_POOL.lock().ok()?;
let pooled = pool.remove(key)?;
if pooled.sender.is_closed() || pooled.idle_since.elapsed() >= POOL_IDLE_TIMEOUT {
return None;
}
Some(pooled.sender)
}
/// Return a still-live sender to the pool for the next request to reuse.
fn store_pooled(key: ConnKey, sender: HttpSender) {
if sender.is_closed() {
return;
}
if let Ok(mut pool) = CONN_POOL.lock() {
pool.insert(
key,
Pooled {
sender,
idle_since: Instant::now(),
},
);
}
}
/// Send one request/response exchange on `sender`. On success returns the parsed
/// `(status, body, location)` AND the sender (drained and ready for the next
/// request, so the caller can pool it). `None` if the connection failed.
async fn exchange(
mut sender: HttpSender,
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
host: &str,
https: bool,
port: u16,
) -> Option<((u16, Vec<u8>, Option<String>), HttpSender)> {
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.to_string()
} 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, "goblin-wallet");
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!("nym 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), sender))
}
/// A single HTTP/1.1 exchange over the tunnel. Returns the status, the
/// collected body and, for 3xx responses, the `Location` target.
async fn request_once(
tunnel: &smolmix::Tunnel,
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 key = ConnKey {
host: host.clone(),
port,
https,
};
// KEEP-ALIVE FAST PATH: reuse a pooled connection for this (host, port,
// https) when one is live, skipping a fresh mixnet TCP + TLS + HTTP handshake.
// This is what makes the many small reads (price, contact-name resolution)
// fast. Only steady-state tunnel connections are pooled (see below); the
// cold-start scoped-exit fallback is one-shot.
if let Some(sender) = take_pooled(&key) {
if let Some((resp, sender)) = exchange(
sender,
method,
url,
body.clone(),
headers,
&host,
https,
port,
)
.await
{
store_pooled(key, sender);
return Some(resp);
}
// Pooled connection died mid-exchange: fall through and build a fresh one.
}
// TUNNEL-FIRST for HTTP. NIP-11/HTTP is PUBLIC data (relay docs, price, name
// authority) and both egresses are mixnet-private, so in steady state we ride
// the already-warm tunnel — opening a fresh MixnetStream + settle to a scoped
// exit PER request was pure latency here. Only when the tunnel isn't up yet
// (`!is_ready()`) do we fall to a host's co-located scoped exit to avoid a cold
// wait; failure there just falls through to the tunnel path below. transport.rs
// (relay websockets) stays exit-first and is untouched — this is the HTTP path
// only.
let exit_io = if https && !nymproc::is_ready() {
match crate::nostr::pool::load().exit_for_host(&host) {
Some(exit) => exit_connect(&host, &exit).await,
None => None,
}
} else {
None
};
// The one-shot scoped-exit fallback is NOT pooled — it's a cold-start bridge
// while the tunnel comes up. Only tunnel-borne connections go in the pool.
let poolable = exit_io.is_none();
let io: Box<dyn Stream> = match exit_io {
Some(io) => io,
None => {
// Resolve the host over the tunnel (DoT — see dns), then dial that
// IP through the same tunnel so nothing (lookup or body) touches
// the clear.
let addr = dns::resolve(tunnel, &host, port).await?;
let tcp = match tunnel.tcp_connect(addr).await {
Ok(s) => s,
Err(e) => {
warn!("nym http: connect to {host} failed: {e}");
return None;
}
};
if https {
match tls_connect(&host, tcp).await {
Some(tls) => Box::new(tls),
None => return None,
}
} else {
Box::new(tcp)
}
}
};
let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.map_err(|e| warn!("nym http: handshake with {host} failed: {e}"))
.ok()?;
// Drive the connection in the background. It stays alive for keep-alive reuse
// as long as the pooled sender is held; it ends once the sender is dropped
// (evicted from the pool) or the peer closes the connection.
tokio::spawn(async move {
let _ = conn.await;
});
let (resp, sender) = exchange(sender, method, url, body, headers, &host, https, port).await?;
if poolable {
store_pooled(key, sender);
}
Some(resp)
}
/// Try the scoped-exit egress for an HTTPS `host`: a MixnetStream to the
/// relay operator's exit ([`streamexit`]), then the SAME hostname-validated
/// [`tls_connect`] as the tunnel path — SNI = `host`, so the exit sees only
/// ciphertext. `None` (logged) on ANY failure, and the whole attempt is
/// bounded by the shared bootstrap cap — a dead exit costs seconds inside the
/// caller's [`HTTP_TIMEOUT`] budget, leaving room to fall back to the tunnel.
async fn exit_connect(host: &str, exit: &str) -> Option<Box<dyn Stream>> {
let cap = nymproc::BOOTSTRAP_TIMEOUT;
let dial = async {
let stream = streamexit::open_stream(exit, cap)
.await
.map_err(|e| warn!("nym http: scoped exit for {host} unavailable: {e}"))
.ok()?;
let tls = tls_connect(host, stream).await?;
debug!("nym http: {host} riding its operator's scoped exit");
Some(Box::new(tls) as Box<dyn Stream>)
};
match tokio::time::timeout(cap, dial).await {
Ok(io) => io,
Err(_) => {
warn!(
"nym http: scoped exit dial for {host} exceeded {}s; falling back to the tunnel",
cap.as_secs()
);
None
}
}
}
/// Everything hyper (and the TLS/websocket layers) needs from a mixnet-carried
/// stream, boxable for the plain http / https / scoped-exit split. Shared with
/// the scoped-exit egress ([`streamexit::BoxedStream`]).
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 — the Build 65/66 rule), reused by every in-tunnel TLS handshake
/// (HTTPS here, DoT/DoH in [`dns`]).
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 tunneled TCP stream with rustls + webpki roots (never the
/// platform verifier — it panics on Android outside a full app context). The
/// certificate is validated against the HOSTNAME even though the dial went to a
/// DoT-resolved IP, so a lying resolver or a hostile exit cannot MITM.
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!("nym http: tls handshake with {host} failed: {e}"))
.ok()
}
-1073
View File
File diff suppressed because it is too large Load Diff
-465
View File
@@ -1,465 +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.
//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool
//! advertises a relay operator's CO-LOCATED Nym exit
//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit
//! directly over the mixnet with a [`MixnetStream`]; the exit pipes the bytes
//! to its ONE configured relay. No public DNS, no public IPR — the two flaky
//! dependencies of the fallback path are gone from the money path. The exit is
//! scoped (it forwards nowhere else), so the wallet writes nothing but the TLS
//! ClientHello: the dial sites run the SAME hostname-validated TLS (SNI = the
//! relay host) + websocket/HTTP wrap over this stream as over the smolmix
//! tunnel's TCP stream, and the exit sees only ciphertext.
//!
//! ANCHOR + FALLBACK, never pin-only: every failure here (bad address, client
//! bootstrap, stream open, timeout) just returns `Err`, and the dial sites
//! ([`super::transport`], [`super::request_once`]) fall through to the
//! public-IPR tunnel ([`super::nymproc`]) — losing the operator's exit never
//! locks the wallet out. Server side: the bundled `floonet-mixexit` binary
//! (design in ~/.claude/plans/floonet-nym-exit.md).
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use log::{info, warn};
use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient};
use tokio::sync::Mutex;
/// The boxed transport stream handed to the TLS/websocket layer — the same
/// seat the smolmix tunnel's TCP stream occupies on the fallback path
/// (everything that layer needs is the shared [`super::Stream`] trait).
pub(crate) type BoxedStream = Box<dyn super::Stream>;
/// After the Open is SENT, wait this long before handing back a writable
/// stream. `open_stream` returns once the Open message leaves the client, NOT
/// once the exit has `accept()`ed and wired its inbound half. But the caller
/// speaks first (TLS ClientHello over a raw-pipe exit), so a write landing in
/// that gap is dropped and the handshake stalls into a fallback. One mixnet
/// round of slack lets the exit be listening before the first byte.
/// ponytail: fixed settle (measured: 0s always stalls, 3s is reliable). The
/// exit pipes raw bytes to its relay, so it can't inject an accept-ack for the
/// client to wait on; if mixnet jitter ever makes 3s flaky, raise it.
const STREAM_SETTLE: Duration = Duration::from_secs(3);
/// Process-lifetime mixnet client for the scoped-exit egress, lazily connected
/// on first use (mirrors the tunnel singleton in [`super::nymproc`]).
/// Ephemeral in-memory identity, like the tunnel — a fresh mixnet identity per
/// run. Behind an async mutex because `open_stream` needs `&mut`; a dead
/// client (cancelled shutdown token or a failed open) is dropped so the next
/// dial reconnects fresh.
static CLIENT: Mutex<Option<MixnetClient>> = Mutex::const_new(None);
/// True once the exit's `MixnetClient` has bootstrapped and is usable. The
/// cold-start sequencer in [`super::nymproc`] reads this to hold the public-IPR
/// tunnel's bootstrap until the exit client has its Nym bandwidth grant (see the
/// NOTE below), so the money path connects in seconds instead of a minute.
static READY: AtomicBool = AtomicBool::new(false);
/// Whether the scoped-exit mixnet client is bootstrapped and usable.
pub fn is_ready() -> bool {
READY.load(Ordering::Relaxed)
}
// NOTE ON COLD-START LATENCY (and its fix): the exit rides a SECOND ephemeral
// MixnetClient (separate from the smolmix tunnel). When BOTH clients bootstrap
// at once on a cold start they serialize on Nym free-tier bandwidth grants — so
// whichever dials second waits ~a minute for its grant. The money path must not
// be the loser of that race. Fix (see nymproc's cold-start sequencer): the exit
// client is allowed to grab its grant FIRST, and the tunnel's bootstrap waits a
// bounded head-start for `is_ready()` before it competes — so only ONE client
// bootstraps at a time and the money-path relay connects in seconds. The tunnel
// (fallback / HTTP / discovery, all non-blocking) comes up right after. A
// startup pre-warm of BOTH in parallel does NOT help (measured) — sequencing,
// not parallelism, is what removes the stall. Sharing ONE client for tunnel +
// exit would remove the second grant entirely but couples the robust exit to
// the tunnel's per-reselect client rebuild; deferred as a future upgrade.
/// Open a scoped MixnetStream to `exit` — a pool-advertised Nym address
/// (`<client>.<enc>@<gateway>`) of a relay operator's co-located exit. The
/// whole dial (client bootstrap when cold + stream open) is capped at
/// `min(timeout, BOOTSTRAP_TIMEOUT)` so a stuck bootstrap fails FAST into the
/// caller's public-IPR fallback. NOTE: `open_stream` is fire-and-forget on the
/// mixnet — a DEAD exit still hands back a stream, and its death surfaces at
/// the caller's (timeout-bounded) TLS handshake, which doubles as the
/// liveness probe: no ServerHello through the pipe → fall back.
pub(crate) async fn open_stream(exit: &str, timeout: Duration) -> Result<BoxedStream, String> {
let recipient: Recipient = exit
.trim()
.parse()
.map_err(|e| format!("invalid exit address: {e}"))?;
let cap = timeout.min(super::nymproc::BOOTSTRAP_TIMEOUT);
let stream = match tokio::time::timeout(cap, open(recipient)).await {
Ok(result) => result?,
Err(_) => return Err(format!("exit dial exceeded {}s", cap.as_secs())),
};
// Let the exit accept() + wire its inbound half before the caller writes.
tokio::time::sleep(STREAM_SETTLE).await;
Ok(Box::new(stream) as BoxedStream)
}
/// Bootstrap the shared client ahead of the first dial. The cold-start
/// sequencer in [`super::nymproc`] spawns this when the pool advertises an
/// exit: without it the client would only bootstrap on the first relay dial —
/// which happens after a wallet opens, so the tunnel's bounded head-start wait
/// would just expire and both clients would race for their bandwidth grants
/// anyway. Failure is non-fatal (the first real dial retries the bootstrap).
pub async fn prewarm() {
if let Err(e) = ensure_client().await {
warn!("nym: streamexit prewarm failed: {e}");
}
}
/// Ensure the shared client is connected (bootstrapping it when absent or
/// dead) and READY reflects reality. Holds the client lock across the
/// bootstrap so concurrent callers coalesce onto one connect.
async fn ensure_client() -> Result<(), String> {
let mut guard = CLIENT.lock().await;
// A dead client (gateway dropped, hosting runtime gone) is discarded and
// rebuilt — the auto-reconnect-on-drop rule.
if guard
.as_ref()
.is_some_and(|c| c.cancellation_token().is_cancelled())
{
warn!("nym: streamexit client died; reconnecting");
*guard = None;
READY.store(false, Ordering::Relaxed);
}
if guard.is_none() {
let started = std::time::Instant::now();
let client = MixnetClient::connect_new()
.await
.map_err(|e| format!("mixnet client bootstrap failed: {e}"))?;
info!(
"[timing] nym: streamexit client CONNECTED in {}ms",
started.elapsed().as_millis()
);
*guard = Some(client);
READY.store(true, Ordering::Relaxed);
}
Ok(())
}
/// Ensure the shared client is connected, then open a stream on it.
async fn open(recipient: Recipient) -> Result<MixnetStream, String> {
ensure_client().await?;
let mut guard = CLIENT.lock().await;
// Re-acquired the lock after ensure_client — a concurrent failed dial may
// have dropped the client in between; error into the caller's fallback
// rather than panic.
let Some(client) = guard.as_mut() else {
return Err("exit client lost before dial".to_string());
};
match client.open_stream(recipient, None).await {
Ok(stream) => Ok(stream),
Err(e) => {
// `open_stream` fails only LOCALLY (the client's input channel) —
// it never waits on the peer — so an error means the client itself
// is broken, not the exit. Drop it; the next dial reconnects.
*guard = None;
READY.store(false, Ordering::Relaxed);
Err(format!("open_stream failed: {e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bad_exit_address_fails_fast_without_touching_the_mixnet() {
// The address parse runs BEFORE any client bootstrap, so garbage from
// a hostile pool costs nothing and degrades to the fallback path.
let err = open_stream("not-a-recipient", Duration::from_secs(5))
.await
.err()
.expect("garbage address must fail");
assert!(err.contains("invalid exit address"), "got: {err}");
}
/// LIVE end-to-end smoke test of the money path against the DEPLOYED
/// floonet-mixexit (.8): dial the pinned pool's `exit` for relay.goblin.st
/// over the mixnet with the real [`open_stream`], run the SAME
/// hostname-validated TLS + websocket wrap the wallet uses
/// ([`super::super::transport`]), then send a nostr REQ and require the
/// relay to answer (EVENT/EOSE). Proves mixnet -> exit -> relay:443 ->
/// nostr actually carries traffic. Ignored (needs network + a cold mixnet
/// bootstrap). Run:
/// cargo test --lib nym::streamexit::tests::live_exit_roundtrip -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn live_exit_roundtrip() {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
// The app installs this at startup (src/lib.rs); an isolated test must
// too, or rustls 0.23 can't pick a provider for the TLS handshake.
let _ = rustls::crypto::ring::default_provider().install_default();
let exit = crate::nostr::pool::load()
.exit_for("wss://relay.floonet.dev")
.expect("pinned pool advertises the relay.floonet.dev exit");
println!("dialing scoped exit {exit}");
// A cold ephemeral mixnet bootstrap can exceed the per-dial cap; the
// real wallet just falls back and retries, so retry until one dial wins.
let mut stream = None;
for attempt in 1..=6 {
let t = std::time::Instant::now();
match open_stream(&exit, Duration::from_secs(90)).await {
Ok(s) => {
println!(
"open_stream OK on attempt {attempt} in {}ms",
t.elapsed().as_millis()
);
stream = Some(s);
break;
}
Err(e) => println!(
"attempt {attempt} failed in {}ms: {e}",
t.elapsed().as_millis()
),
}
}
let stream = stream.expect("exit stream opened within retries");
let url = "wss://relay.floonet.dev";
let (mut ws, _resp) = tokio::time::timeout(
Duration::from_secs(45),
tokio_tungstenite::client_async_tls(url, stream),
)
.await
.expect("TLS+ws handshake timed out (dead exit?)")
.expect("TLS+ws handshake through exit failed");
println!("TLS+ws handshake through .8 exit OK");
ws.send(Message::Text(
r#"["REQ","smoke",{"kinds":[1],"limit":1}]"#.into(),
))
.await
.expect("send REQ");
let reply = tokio::time::timeout(Duration::from_secs(30), ws.next())
.await
.expect("relay reply timed out")
.expect("ws stream closed early")
.expect("ws frame error");
let txt = match reply {
Message::Text(t) => t.to_string(),
other => format!("{other:?}"),
};
println!("relay answered through exit: {txt}");
assert!(
txt.contains("EVENT") || txt.contains("EOSE"),
"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,
}
}
}
}
-231
View File
@@ -1,231 +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.
//! WebSocket transport for the Nostr relay pool routed through the Nym
//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool
//! entry advertises its operator's co-located scoped exit
//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream
//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR.
//! FALLBACK (and every relay without an exit): Goblin's in-process smolmix
//! tunnel — the relay host is resolved by [`super::dns`], the TCP stream is
//! opened via `tunnel.tcp_connect`. Either way the SAME TLS (rustls, webpki
//! roots) + websocket handshake runs over the mixnet-carried stream, so the
//! payload + in-flight destination never touch the clear, and an exit failure
//! only ever falls back — never a lockout.
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
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()))
}
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
#[derive(Debug, Clone, Copy, Default)]
pub struct NymWebSocketTransport;
impl WebSocketTransport for NymWebSocketTransport {
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();
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
// MONEY-PATH ANCHOR: when the pool advertises this relay
// operator's co-located scoped Nym exit, dial THROUGH it — a
// MixnetStream straight to the exit (which pipes to its one
// relay), no public DNS, no public IPR, no tunnel dependency. The
// TLS + websocket wrap inside is byte-for-byte the tunnel path's
// (same `client_async_tls`, SNI = the relay host), so the exit
// sees only ciphertext. ANY failure — bootstrap, open, handshake,
// timeout — falls through to the public-IPR tunnel dial below:
// anchor + fallback, never pin-only.
if let Some(exit) = crate::nostr::pool::load().exit_for(url.as_str()) {
let t_exit = std::time::Instant::now();
match exit_connect(url, &exit, timeout).await {
Ok(parts) => {
log::info!(
"[timing] nym: relay {host} CONNECTED via scoped exit — \
stream+tls+ws {}ms",
t_exit.elapsed().as_millis()
);
return Ok(parts);
}
Err(e) => log::warn!(
"nym: scoped exit dial for {host} failed after {}ms ({e}); \
falling back to the public-IPR tunnel",
t_exit.elapsed().as_millis()
),
}
}
// The shared mixnet tunnel (lazy-started at app launch).
let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout)
.await
.ok_or_else(|| terr("nym tunnel not ready"))?;
// Resolve the relay host (clearnet by default — see nym::dns), then
// dial the resolved IP THROUGH the same tunnel so the TCP, TLS and
// websocket all still ride the mixnet. Each stage is timed so the
// connect-timing harness can attribute cost per relay.
let t_resolve = std::time::Instant::now();
let addr =
tokio::time::timeout(timeout, crate::nym::dns::resolve(&tunnel, &host, port))
.await
.map_err(|_| terr("dns resolve timeout"))?
.ok_or_else(|| terr(format!("could not resolve relay host {host}")))?;
let resolve_ms = t_resolve.elapsed().as_millis();
let t_tcp = std::time::Instant::now();
let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr))
.await
.map_err(|_| terr("nym tunnel connect timeout"))?
.map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?;
let tcp_ms = t_tcp.elapsed().as_millis();
// Perform TLS (for wss) + websocket handshake over the mixnet stream.
let t_ws = std::time::Instant::now();
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
log::info!(
"[timing] nym: relay {host} CONNECTED — resolve {resolve_ms}ms, \
tcp_connect(mixnet) {tcp_ms}ms, tls+ws(mixnet) {}ms",
t_ws.elapsed().as_millis()
);
Ok(split_ws(ws))
})
}
}
/// Dial `url` through the relay operator's scoped Nym exit `exit`: a
/// MixnetStream to the exit (which pipes to its one configured relay), then
/// the SAME hostname-validated TLS + websocket handshake as the tunnel path.
/// The handshake doubles as the exit liveness probe — `open_stream` is
/// fire-and-forget, so a dead exit surfaces here as a (bounded) timeout and
/// the caller falls back.
async fn exit_connect(
url: &Url,
exit: &str,
timeout: Duration,
) -> Result<(WebSocketSink, WebSocketStream), TransportError> {
let stream = crate::nym::streamexit::open_stream(exit, timeout)
.await
.map_err(terr)?;
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout (exit stream)"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
Ok(split_ws(ws))
}
/// Split a websocket into the pool's boxed sink/stream halves — shared by the
/// scoped-exit and tunnel dial paths, so everything above the byte transport
/// is identical whichever egress carried the connection.
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(NymSink(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 NymSink<S>(S);
impl<S> Sink<Message> for NymSink<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)
}
}
+1 -37
View File
@@ -110,14 +110,6 @@ pub struct AppConfig {
/// (dots until tapped to reveal). Presentation-only, no money-path or
/// storage effect. Default false.
anonymous_mode: 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.
nym_entry_gateway: Option<String>,
/// 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>,
}
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
@@ -226,15 +218,13 @@ impl Default for AppConfig {
// On by default, like upstream Grim: checks Goblin's own GitHub
// releases direct over HTTPS (see http/release.rs). This is the same
// non-sensitive-metadata-over-clearnet posture Grim uses for its
// update check — payments, relays and identity still stay mixnet-only.
// update check — payments, relays and identity still egress over Tor.
check_updates: Some(true),
app_update: None,
hide_amounts: None,
notif_hide_names: None,
notif_hide_details: None,
anonymous_mode: None,
nym_entry_gateway: None,
nym_last_ipr: None,
}
}
}
@@ -518,32 +508,6 @@ impl AppConfig {
w_config.save();
}
/// Get the last-known-good Nym ENTRY gateway (base58 identity), if any.
pub fn nym_entry_gateway() -> Option<String> {
let r_config = Settings::app_config_to_read();
r_config.nym_entry_gateway.clone()
}
/// Save (or clear) the last-known-good Nym ENTRY gateway.
pub fn set_nym_entry_gateway(gw: Option<String>) {
let mut w_config = Settings::app_config_to_update();
w_config.nym_entry_gateway = gw;
w_config.save();
}
/// Get the last-known-good Nym IPR exit recipient string, if any.
pub fn nym_last_ipr() -> Option<String> {
let r_config = Settings::app_config_to_read();
r_config.nym_last_ipr.clone()
}
/// Save (or clear) the last-known-good Nym IPR exit recipient string.
pub fn set_nym_last_ipr(ipr: Option<String>) {
let mut w_config = Settings::app_config_to_update();
w_config.nym_last_ipr = ipr;
w_config.save();
}
/// Check if proxy for network requests is needed.
pub fn use_proxy() -> bool {
let r_config = Settings::app_config_to_read();
+15 -16
View File
@@ -21,8 +21,9 @@
//!
//! 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.
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, so arti's TLS
//! stays on native-tls and never touches the rustls/ring provider our relay and
//! HTTP TLS install.
//!
//! The arti client runs on its OWN dedicated tokio runtime (created once, kept
//! alive for the process). `TorClient::connect()` returns a [`DataStream`] that
@@ -79,7 +80,7 @@ impl Tor {
}
}
// --- Readiness signals (re-pointed from `nym::nymproc`, same semantics) -------
// --- Readiness signals ---------------------------------------------------------
/// Set once arti has bootstrapped (mirrors `TUNNEL_GEN != 0`); cheap to poll.
static READY: AtomicBool = AtomicBool::new(false);
@@ -87,7 +88,7 @@ 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
/// still works this way: a relay-liveness report tagged with an older
/// generation can never mark a newer transport ready.
static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
@@ -96,9 +97,8 @@ static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
/// 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.
/// Whether a nostr consumer currently wants relays over Tor. 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
@@ -152,16 +152,15 @@ 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.
/// Bracket a nostr consumer's lifetime. 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.
/// External condemnation request (kept for API parity with earlier transports).
/// 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)");
@@ -275,8 +274,8 @@ fn bootstrap_once() {
"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.
// Eager price fetch the moment Tor is ready: 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);
}
+2 -3
View File
@@ -21,9 +21,8 @@
//! 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.
//! 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.
+144 -15
View File
@@ -124,7 +124,7 @@ pub struct Wallet {
syncing: Arc<AtomicBool>,
/// On-demand node polling (Android battery): pause the heavy node sync at
/// sync thread while the app is backgrounded and nothing is in flight.
/// The relay+Nym nostr service keeps running regardless of this flag.
/// The relay+Tor nostr service keeps running regardless of this flag.
node_polling_paused: Arc<AtomicBool>,
/// Resume-signal counter closing the receipt-vs-pause race: bumped by
/// [`Wallet::resume_node_polling`]; the sync thread only pauses when no
@@ -439,7 +439,7 @@ impl Wallet {
// iteration (wallet.rs, top of the loop); if the thread races
// ahead of this, that first iteration finds no service, skips
// it, and the service doesn't start until the NEXT cycle — a
// full SYNC_DELAY (60s) later. That 60s gap (not the mixnet,
// full SYNC_DELAY (60s) later. That 60s gap (not Tor,
// which connects a relay in ~2s) is the "stuck on Connecting…
// for a minute" symptom. Synchronous + on this thread, so the
// service is guaranteed present when start_sync runs.
@@ -622,6 +622,14 @@ impl Wallet {
old.unlock(&password)
.map_err(|_| "Wrong password".to_string())?;
let input = input.trim();
// A FULL wallet backup carries a seed and belongs to wallet creation, not
// to swapping one identity in an existing wallet. Reject it here with a
// clear pointer rather than failing obscurely on the v1 path below.
if crate::nostr::is_full_backup(input) {
return Err("That's a full wallet backup. Restore it when creating a \
wallet, not here."
.to_string());
}
let bpw = backup_password
.as_deref()
.filter(|s| !s.is_empty())
@@ -702,21 +710,137 @@ impl Wallet {
Ok(new_npub)
}
/// Build the contents of a `GOBLIN-*.backup` file: the whole nostr identity,
/// fully sealed under the wallet password. Verifies the password first.
pub fn create_nostr_backup(&self, password: &str) -> Result<String, String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
let identity = svc.identity.read().clone();
let keys = identity
.unlock(password)
/// Build the contents of a `GOBLIN-*.backup` file: the FULL wallet — the money
/// seed AND every held identity — sealed under the wallet password (format
/// version 2). The password both unlocks the seed (via [`get_recovery`], which
/// proves it) and unlocks each identity, so a wrong password fails before any
/// bytes are produced. The plaintext seed is assembled and sealed in memory and
/// never written to disk; the returned string is what the caller saves.
///
/// [`get_recovery`]: Self::get_recovery
pub fn create_full_backup(&self, password: &str) -> Result<String, String> {
// Unlock the seed first: this both verifies the wallet password and yields
// the phrase, held only in a zeroizing string that drops at end of scope.
let phrase = self
.get_recovery(password.to_string())
.map_err(|_| "Wrong password".to_string())?;
identity
.to_encrypted_backup(&keys)
let nostr_dir = self.get_config().get_nostr_path();
let index =
HeldIdentities::load(&nostr_dir).ok_or_else(|| "identities unavailable".to_string())?;
// Unlock every held identity under the same wallet password so each can be
// re-sealed into the backup with its own recoverable key.
let mut identities = Vec::new();
for entry in &index.identities {
let id = entry
.load(&nostr_dir)
.ok_or_else(|| "identity file unreadable".to_string())?;
let keys = id
.unlock(password)
.map_err(|_| "Wrong password".to_string())?;
identities.push((id, keys));
}
crate::nostr::build_full_backup(&phrase, &identities, &index.active, password)
.map_err(|e| format!("backup failed: {e}"))
}
/// Restore every identity from a FULL backup into THIS freshly created wallet,
/// re-encrypting each under the wallet password and making the backup's active
/// identity active. The seed itself was already restored through the normal
/// 24-word wallet-creation path (the caller decrypted the seed to feed that
/// path); this method only reinstates the identities. It replaces the fresh
/// random identity the new wallet minted on open with the backed-up active one,
/// then adds the rest as held identities — reusing the same on-disk index and
/// service-rebuild plumbing as add/import. `blob` is the full-backup file,
/// `backup_password` the password it was sealed under (may differ from this
/// wallet's on a new device), `wallet_password` this wallet's password.
pub fn restore_full_backup_identities(
&self,
blob: &str,
backup_password: &str,
wallet_password: &str,
) -> Result<(), String> {
let full = crate::nostr::open_full_backup(blob, backup_password)
.map_err(|_| "Couldn't open the backup — wrong password?".to_string())?;
if full.identities.is_empty() {
return Ok(());
}
// The new wallet starts nostr asynchronously on open; wait for the service
// so we replace a fully-initialized identity set rather than racing it.
let mut svc = None;
for _ in 0..200 {
if let Some(s) = self.nostr_service() {
svc = Some(s);
break;
}
thread::sleep(Duration::from_millis(100));
}
let svc = svc.ok_or_else(|| "nostr didn't start".to_string())?;
let config = self.get_config();
let nostr_dir = config.get_nostr_path();
// Re-encrypt every backed-up identity under THIS wallet's password.
let mut rebuilt: Vec<NostrIdentity> = Vec::new();
for (backup, keys) in &full.identities {
let mut ident = NostrIdentity::from_unlocked_keys(keys, wallet_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();
rebuilt.push(ident);
}
// The active identity (as marked at backup time; else the first) becomes
// identity #1 in identity.json, overwriting the throwaway random key the
// fresh wallet minted on open. A clean single-entry index is then written
// so the discarded random key leaves no held entry behind.
let active_pos = rebuilt
.iter()
.position(|i| i.pubkey_hex().as_deref() == Some(full.active.as_str()))
.unwrap_or(0);
let primary = rebuilt[active_pos].clone();
primary
.save(&nostr_dir)
.map_err(|e| format!("identity save failed: {e}"))?;
let mut index = HeldIdentities::from_legacy(&primary)
.ok_or_else(|| "identity has a malformed key".to_string())?;
index
.save(&nostr_dir)
.map_err(|e| format!("index save failed: {e}"))?;
let primary_hex = primary.pubkey_hex();
for (i, ident) in rebuilt.iter().enumerate() {
if i == active_pos || ident.pubkey_hex() == primary_hex {
continue;
}
// Skip duplicates / cap overflow without aborting the whole restore.
if let Err(e) = index.add(&nostr_dir, ident) {
warn!("nostr: skipping identity during restore: {e}");
}
}
// Rebuild the live service on the restored set, active identity active.
svc.stop();
for _ in 0..100 {
if !svc.is_running() {
break;
}
thread::sleep(Duration::from_millis(100));
}
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
let store = NostrStore::new(config.get_nostr_db_path());
let (recv, active_hex) = self
.unlock_all_identities(&nostr_dir, wallet_password)
.ok_or_else(|| "identity unlock failed".to_string())?;
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: restored {} identit(ies) from full backup",
rebuilt.len()
);
Ok(())
}
// ── Held nostr identities (one wallet, one balance, many front doors) ──────
//
// One grin seed / one balance, but the wallet can HOLD several nostr
@@ -802,6 +926,11 @@ impl Wallet {
let (identity, _keys) = match import {
Some(blob) => {
let blob = blob.trim();
if crate::nostr::is_full_backup(blob) {
return Err("That's a full wallet backup. Restore it when \
creating a wallet, not here."
.to_string());
}
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
@@ -2547,7 +2676,7 @@ fn start_sync(wallet: Wallet) -> Thread {
// Start the nostr payment-messaging service the moment the wallet is
// open — BEFORE (and independent of) the grin node sync. Previously
// this lived deep in the sync body behind `!sync_error` and the node
// checks, so the Nym/relay connection could wait up to a full
// checks, so the Tor/relay connection could wait up to a full
// SYNC_DELAY (60s) — or never start while the node errored — leaving
// the profile stuck on "Connecting…". Idempotent.
if let Some(service) = wallet.nostr_service() {
@@ -2604,7 +2733,7 @@ fn start_sync(wallet: Wallet) -> Thread {
// On-demand node polling (Android battery): while the app is
// backgrounded and no transaction is waiting on the node, skip
// the heavy node sync. The relay+Nym nostr service started
// the heavy node sync. The relay+Tor nostr service started
// above keeps running and listening for gift wraps regardless;
// a slatepack receipt resumes polling instantly (see
// `resume_node_polling`). Foreground always polls.
+1 -1
View File
@@ -21,7 +21,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
/// The locales shipped alongside English.
const OTHER_LOCALES: &[&str] = &["de", "fr", "ru", "tr", "zh-CN", "es", "ko", "ja"];
const OTHER_LOCALES: &[&str] = &["de", "fr", "ru", "tr", "zh-CN", "zh-TW", "es", "ko", "ja"];
/// Flatten a YAML mapping into dotted leaf keys → string value.
fn flatten(value: &serde_yaml::Value, prefix: &str, out: &mut BTreeMap<String, String>) {