1
0
forked from GRIN/grim

Goblin Build 133 - Tor transport (replaces the Nym mixnet)

The wallet's private transport moves from the Nym mixnet to embedded Tor
(arti, copied from GRIM's engine): it dials the relay's pinned .onion, so
the relay never learns your IP, while the relay + NIP-59 gift-wrap hide the
rest - content, sender, and (via a relay-side randomized release) timing.
The Grin node stays on the clear internet as before.

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

Preserved intact: the confirm-before-sent guard, relay-gated readiness, and
the lazy warm-on-activity node polling. src/nym/ is feature-gated off (arti
and nym-sdk can't share one binary); full removal is a follow-up.
This commit is contained in:
2ro
2026-07-04 03:35:29 -04:00
parent 22bf3359f5
commit 30c0ed9a12
19 changed files with 3231 additions and 4713 deletions
Generated
+2197 -4486
View File
File diff suppressed because it is too large Load Diff
+38 -11
View File
@@ -31,6 +31,17 @@ lto = true
codegen-units = 1
panic = "abort"
[features]
## Default build uses the Tor transport only. The `nym` feature gates the dormant
## mixnet path (src/nym/). Cargo resolves OPTIONAL deps into the graph too, so
## nym-sdk cannot merely be `optional` — it links a different libsqlite3-sys than
## arti (a native-lib `links` conflict Cargo rejects at resolution). The nym
## path-deps are therefore commented out below; the module code is retained on
## disk but building `--features nym` requires restoring them (and drops arti —
## the two transports cannot coexist in one binary, which is why Tor replaced Nym).
default = []
nym = []
[dependencies]
log = "0.4.27"
@@ -124,18 +135,34 @@ rustls = { version = "0.23", features = ["ring"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
webpki-roots = "1"
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary).
## Tor — embedded arti (the DIALING half only: connect OUT to the relay's .onion,
## and to clearnet HTTP hosts through a Tor exit). Copied from our sister wallet
## GRIM's proven, shipping engine. Two choices inherited VERBATIM from GRIM: arti
## 0.43 across the family, and the native-tls Tor runtime (TokioNativeTlsRuntime),
## NOT rustls — this deliberately sidesteps the rustls/ring crypto-provider
## conflict fought during the Nym era (our relay/HTTP rustls still uses ring, see
## lib.rs; arti's own TLS is native-tls and never touches the rustls provider).
## `static` vendors openssl (self-contained Android/cross builds, as GRIM ships);
## `onion-service-client` enables dialing .onion. We drop GRIM's `pt-client`
## (bridges) and `onion-service-service` (hosting) — Goblin only dials.
arti-client = { version = "0.43.0", features = ["static", "onion-service-client"] }
tor-rtcompat = { version = "0.43.0", features = ["static"] }
## Nym mixnet — DORMANT since the Tor transport swap. The mixnet path (src/nym/)
## is retained on disk but its deps are COMMENTED OUT, because arti's `tor-dirmgr`
## needs libsqlite3-sys 0.34 (rusqlite 0.36) while nym-sdk's credential-storage
## needs libsqlite3-sys 0.30 (sqlx) and BOTH link the native `sqlite3` library —
## Cargo forbids two packages linking the same native lib, and it rejects this at
## RESOLUTION even for optional/unused deps. The two transports therefore cannot
## coexist in one binary (exactly why Tor replaced Nym). To build the old path,
## restore these three deps and build `--features nym` (which then drops arti).
## Full deletion is a later phase; for now the code stays on disk for reference.
## Path deps into the local nym checkout, PINNED at rev
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local
## commit "http-api-client: preconfigured webpki roots on Android". Do not
## float the checkout past that rev without re-verifying the Android build.
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit —
## the single-network-requester SPOF is structurally gone (plan G14).
smolmix = { path = "../nym/smolmix/core" }
## mix-dns wire codec. Already in the dependency graph via nym-http-api-client
## (Cargo.lock), so we reuse it instead of vendoring a DNS encode/parse.
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
## webpki roots on Android").
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
# smolmix = { path = "../nym/smolmix/core" }
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
## NIP-98 payload hashing
sha2 = "0.10.8"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "Anonym"
connected_nym: "Über Nym verbunden"
nym_ready: "Nym bereit · Relays…"
connecting_nym: "Verbinde mit Nym…"
connected_nym: "Über Tor verbunden"
nym_ready: "Tor bereit · Relays…"
connecting_nym: "Verbinde mit Tor…"
cant_reach_node: "Node nicht erreichbar"
node_synced: "Node synchronisiert"
syncing: "Synchronisiere…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "Keine"
network_fee: "Netzwerkgebühr"
privacy: "Privatsphäre"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaktion"
cancel_request: "Anfrage abbrechen"
cancel_send: "Zahlung abbrechen"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "Wallet wechseln"
advanced: "Erweitert"
privacy: "Privatsphäre"
mixnet_routing: "Mixnet-Routing"
mixnet_routing: "Tor-Routing"
messages_lookups: "Nachrichten & Abfragen"
auto_accept: "Automatisch annehmen"
pairing: "Preiswährung"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Netzwerk"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Drittanbieter"
grim: "GRIM (Upstream-Wallet)"
grin_node: "Grin-Node"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "QR ausblenden"
privacy:
title: "Netzwerk-Privatsphäre"
intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
intro: "Goblin sendet seinen privaten Datenverkehr über Tor und verbirgt so deine IP vor dem Relay — die Verschlüsselung verbirgt den Rest, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
payments: "Zahlungen"
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
usernames: "usernames"
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
price_avatars: "Preis"
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
over_mixnet: "Über das mixnet"
over_mixnet: "Über Tor"
direct_connection: "Direkte Verbindung"
grin_node: "Grin-Node"
grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft."
@@ -632,7 +632,7 @@ goblin:
title: "Kopplung"
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
pair_with: "Koppeln mit"
rates_note: "Kurse werden über das Nym mixnet abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
rates_note: "Kurse werden über Tor abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
relays:
title: "Relays"
intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen."
@@ -674,7 +674,7 @@ goblin:
private_money_head: "Privates Geld"
private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain."
send_like_message_head: "Senden wie eine Nachricht"
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und das Nym mixnet an — niemand dazwischen sieht den Betrag oder die Beteiligten."
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und Tor an — niemand dazwischen sieht den Betrag oder die Beteiligten."
yours_alone_head: "Nur deins"
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
get_started: "Loslegen"
@@ -725,8 +725,8 @@ goblin:
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
title: "Deine Zahlungsidentität"
key_being_made: "Schlüssel wird erstellt…"
connected_nym: "über Nym verbunden"
connecting_nym: "verbinde über Nym…"
connected_nym: "über Tor verbunden"
connecting_nym: "verbinde über Tor…"
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht."
pick_username: "Benutzernamen wählen — optional"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "deinname"
working: "Arbeite…"
claim_username: "Benutzernamen sichern"
available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern."
available_when_connected: "Verfügbar, sobald Tor verbindet — oder überspringen und später sichern."
youre: "Du bist %{name}"
claimed_title: "%{name} gehört dir"
claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet."
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "Sie zahlen"
row_they_pay_val: "Nur wenn sie zustimmen"
row_delivery: "Zustellung"
row_delivery_val: "NIP-44-verschlüsselt, über Nym"
row_delivery_val: "NIP-44-verschlüsselt, über Tor"
row_network_fee: "Netzwerkgebühr"
row_network_fee_val: "Von deinem Guthaben abgezogen"
row_privacy: "Privatsphäre"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Anfrage senden"
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
hold_to_send: "Zum Senden halten"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "Anonymous"
connected_nym: "Connected over Nym"
nym_ready: "Nym ready · relays…"
connecting_nym: "Connecting to Nym…"
connected_nym: "Connected over Tor"
nym_ready: "Tor ready · relays…"
connecting_nym: "Connecting to Tor…"
cant_reach_node: "Can't reach node"
node_synced: "Node synced"
syncing: "Syncing…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "None"
network_fee: "Network fee"
privacy: "Privacy"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaction"
cancel_request: "Cancel request"
cancel_send: "Cancel payment"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "Switch wallet"
advanced: "Advanced"
privacy: "Privacy"
mixnet_routing: "Mixnet routing"
mixnet_routing: "Tor routing"
messages_lookups: "Messages & lookups"
auto_accept: "Auto-accept"
pairing: "Price currency"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Network"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Third party"
grim: "GRIM (upstream wallet)"
grin_node: "Grin node"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "Hide QR"
privacy:
title: "Network privacy"
intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you."
intro: "Goblin sends its private traffic through Tor, which hides your IP from the relay — encryption hides the rest, so a relay can't link a payment back to you."
payments: "Payments"
payments_blurb: "Every nostr message carrying a slatepack."
usernames: "usernames"
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
price_avatars: "Price"
price_avatars_blurb: "The live fiat rate shown next to amounts."
over_mixnet: "Over the mixnet"
over_mixnet: "Over Tor"
direct_connection: "Direct connection"
grin_node: "Grin node"
grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity."
@@ -632,7 +632,7 @@ goblin:
title: "Pairing"
intro: "What your balance and amounts are shown against."
pair_with: "Pair with"
rates_note: "Rates fetch over the Nym mixnet, only while a pairing is on — off means no rate request leaves your device."
rates_note: "Rates fetch over Tor, only while a pairing is on — off means no rate request leaves your device."
relays:
title: "Relays"
intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive."
@@ -674,7 +674,7 @@ goblin:
private_money_head: "Private money"
private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain."
send_like_message_head: "Send like a message"
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and the Nym mixnet — no one in between can see the amount or who's involved."
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and Tor — no one in between can see the amount or who's involved."
yours_alone_head: "Yours alone"
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
get_started: "Get started"
@@ -725,8 +725,8 @@ goblin:
kicker: "STEP 3 OF 3 · IDENTITY"
title: "Your payment identity"
key_being_made: "key being made…"
connected_nym: "connected over Nym"
connecting_nym: "connecting over Nym…"
connected_nym: "connected over Tor"
connecting_nym: "connecting over Tor…"
fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds."
clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face."
pick_username: "Pick a username — optional"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "yourname"
working: "Working…"
claim_username: "Claim username"
available_when_connected: "Available once the mixnet connects — or skip and claim later."
available_when_connected: "Available once Tor connects — or skip and claim later."
youre: "You're %{name}"
claimed_title: "%{name} is yours"
claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet."
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "They pay"
row_they_pay_val: "Only if they approve"
row_delivery: "Delivery"
row_delivery_val: "NIP-44 encrypted, over Nym"
row_delivery_val: "NIP-44 encrypted, over Tor"
row_network_fee: "Network fee"
row_network_fee_val: "Deducted from your balance"
row_privacy: "Privacy"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Send request"
request_approve_hint: "They'll get a request to approve"
hold_to_send: "Hold to send"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "Anonyme"
connected_nym: "Connecté via Nym"
nym_ready: "Nym prêt · relais…"
connecting_nym: "Connexion à Nym…"
connected_nym: "Connecté via Tor"
nym_ready: "Tor prêt · relais…"
connecting_nym: "Connexion à Tor…"
cant_reach_node: "Nœud injoignable"
node_synced: "Nœud synchronisé"
syncing: "Synchronisation…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "Aucun"
network_fee: "Frais de réseau"
privacy: "Confidentialité"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Transaction"
cancel_request: "Annuler la demande"
cancel_send: "Annuler le paiement"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "Changer de portefeuille"
advanced: "Avancé"
privacy: "Confidentialité"
mixnet_routing: "Routage par mixnet"
mixnet_routing: "Routage par Tor"
messages_lookups: "Messages et recherches"
auto_accept: "Acceptation auto"
pairing: "Devise des prix"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "Build %{build}"
network: "Réseau"
network_value: "MW + mixnet Nym + nostr"
network_value: "MW + Tor + nostr"
third_party: "Tiers"
grim: "GRIM (portefeuille amont)"
grin_node: "Nœud grin"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "Masquer le QR"
privacy:
title: "Confidentialité réseau"
intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous."
intro: "Goblin envoie son trafic privé via Tor, qui masque votre IP au relais — le chiffrement masque le reste, afin qu'un relais ne puisse pas relier un paiement à vous."
payments: "Paiements"
payments_blurb: "Chaque message nostr transportant un slatepack."
usernames: "usernames"
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
price_avatars: "Prix"
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
over_mixnet: "Via le mixnet"
over_mixnet: "Via Tor"
direct_connection: "Connexion directe"
grin_node: "Nœud grin"
grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité."
@@ -632,7 +632,7 @@ goblin:
title: "Appairage"
intro: "Ce à quoi votre solde et vos montants sont comparés."
pair_with: "Apparier avec"
rates_note: "Les cours sont récupérés via le mixnet Nym, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
rates_note: "Les cours sont récupérés via Tor, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
relays:
title: "Relais"
intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir."
@@ -674,7 +674,7 @@ goblin:
private_money_head: "Argent privé"
private_money_body: "Goblin est un portefeuille pour grin — de l'argent numérique sans montants ni adresses sur sa chaîne."
send_like_message_head: "Envoyer comme un message"
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et le mixnet Nym — personne entre les deux ne voit le montant ni les personnes impliquées."
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et Tor — personne entre les deux ne voit le montant ni les personnes impliquées."
yours_alone_head: "À vous seul"
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
get_started: "Commencer"
@@ -725,8 +725,8 @@ goblin:
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
title: "Votre identité de paiement"
key_being_made: "clé en cours de création…"
connected_nym: "connecté via Nym"
connecting_nym: "connexion via Nym…"
connected_nym: "connecté via Tor"
connecting_nym: "connexion via Tor…"
fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds."
clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage."
pick_username: "Choisir un nom d'utilisateur — facultatif"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "votrenom"
working: "En cours…"
claim_username: "Réserver le nom d'utilisateur"
available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard."
available_when_connected: "Disponible une fois Tor connecté — ou passez et réservez plus tard."
youre: "Vous êtes %{name}"
claimed_title: "%{name} est à vous"
claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille."
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "Ils paient"
row_they_pay_val: "Seulement s'ils approuvent"
row_delivery: "Livraison"
row_delivery_val: "Chiffré NIP-44, via Nym"
row_delivery_val: "Chiffré NIP-44, via Tor"
row_network_fee: "Frais de réseau"
row_network_fee_val: "Déduit de votre solde"
row_privacy: "Confidentialité"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Envoyer la demande"
request_approve_hint: "Ils recevront une demande à approuver"
hold_to_send: "Maintenir pour envoyer"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "Аноним"
connected_nym: "Подключено через Nym"
nym_ready: "Nym готов · реле…"
connecting_nym: "Подключение к Nym…"
connected_nym: "Подключено через Tor"
nym_ready: "Tor готов · реле…"
connecting_nym: "Подключение к Tor…"
cant_reach_node: "Нет связи с узлом"
node_synced: "Узел синхронизирован"
syncing: "Синхронизация…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "Нет"
network_fee: "Сетевая комиссия"
privacy: "Приватность"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "Транзакция"
cancel_request: "Отменить запрос"
cancel_send: "Отменить платёж"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "Сменить кошелёк"
advanced: "Дополнительно"
privacy: "Приватность"
mixnet_routing: "Маршрутизация через mixnet"
mixnet_routing: "Маршрутизация через Tor"
messages_lookups: "Сообщения и поиск"
auto_accept: "Автоприём"
pairing: "Валюта цены"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "Сборка %{build}"
network: "Сеть"
network_value: "MW + mixnet Nym + nostr"
network_value: "MW + Tor + nostr"
third_party: "Сторонние"
grim: "GRIM (исходный кошелёк)"
grin_node: "Узел Grin"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "Скрыть QR"
privacy:
title: "Сетевая приватность"
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
payments: "Платежи"
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
usernames: "usernames"
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
price_avatars: "Цена"
price_avatars_blurb: "Текущий курс рядом с суммами."
over_mixnet: "Через mixnet"
over_mixnet: "Через Tor"
direct_connection: "Прямое соединение"
grin_node: "Узел Grin"
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
@@ -632,7 +632,7 @@ goblin:
title: "Привязка"
intro: "К чему привязаны отображаемые баланс и суммы."
pair_with: "Привязать к"
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
relays:
title: "Реле"
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
@@ -674,7 +674,7 @@ goblin:
private_money_head: "Приватные деньги"
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
send_like_message_head: "Отправляйте как сообщение"
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и mixnet Nym — никто посередине не увидит сумму или участников."
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и Tor — никто посередине не увидит сумму или участников."
yours_alone_head: "Только ваше"
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
get_started: "Начать"
@@ -725,8 +725,8 @@ goblin:
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
title: "Ваша платёжная личность"
key_being_made: "ключ создаётся…"
connected_nym: "подключено через Nym"
connecting_nym: "подключение через Nym…"
connected_nym: "подключено через Tor"
connecting_nym: "подключение через Tor…"
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
pick_username: "Выберите имя — необязательно"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "yourname"
working: "Обработка…"
claim_username: "Занять имя"
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
youre: "Вы %{name}"
claimed_title: "%{name} теперь ваше"
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "Они платят"
row_they_pay_val: "Только если они одобрят"
row_delivery: "Доставка"
row_delivery_val: "Зашифровано NIP-44, через Nym"
row_delivery_val: "Зашифровано NIP-44, через Tor"
row_network_fee: "Сетевая комиссия"
row_network_fee_val: "Списывается с вашего баланса"
row_privacy: "Приватность"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "Отправить запрос"
request_approve_hint: "Они получат запрос на одобрение"
hold_to_send: "Удерживайте для отправки"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "Anonim"
connected_nym: "Nym üzerinden bağlı"
nym_ready: "Nym hazır · relaylar…"
connecting_nym: "Nym'e bağlanılıyor…"
connected_nym: "Tor üzerinden bağlı"
nym_ready: "Tor hazır · relaylar…"
connecting_nym: "Tor'a bağlanılıyor…"
cant_reach_node: "Düğüme ulaşılamıyor"
node_synced: "Düğüm eşitlendi"
syncing: "Eşitleniyor…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "Yok"
network_fee: "Ağ ücreti"
privacy: "Gizlilik"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "İşlem"
cancel_request: "İsteği iptal et"
cancel_send: "Ödemeyi iptal et"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "Cüzdan değiştir"
advanced: "Gelişmiş"
privacy: "Gizlilik"
mixnet_routing: "Mixnet yönlendirme"
mixnet_routing: "Tor yönlendirme"
messages_lookups: "Mesajlar ve aramalar"
auto_accept: "Otomatik kabul"
pairing: "Fiyat para birimi"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "Sürüm %{build}"
network: "Ağ"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "Üçüncü taraf"
grim: "GRIM (üst kaynak cüzdan)"
grin_node: "Grin düğümü"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "QR gizle"
privacy:
title: "Ağ gizliliği"
intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz."
intro: "Goblin özel trafiğini Tor üzerinden gönderir ve senin IP adresini relaydan gizler — şifreleme de gerisini gizler, böylece bir relay bir ödemeyi sana bağlayamaz."
payments: "Ödemeler"
payments_blurb: "Slatepack taşıyan her nostr mesajı."
usernames: "usernamelar"
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
price_avatars: "Fiyat"
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
over_mixnet: "Mixnet üzerinden"
over_mixnet: "Tor üzerinden"
direct_connection: "Doğrudan bağlantı"
grin_node: "Grin düğümü"
grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez."
@@ -632,7 +632,7 @@ goblin:
title: "Eşleştirme"
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
pair_with: "Eşleştir"
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Nym mixnet üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Tor üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
relays:
title: "Relaylar"
intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir."
@@ -674,7 +674,7 @@ goblin:
private_money_head: "Özel para"
private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit."
send_like_message_head: "Mesaj gibi gönder"
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Nym mixnet üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Tor üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
yours_alone_head: "Yalnızca senin"
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
get_started: "Başla"
@@ -725,8 +725,8 @@ goblin:
kicker: "ADIM 3 / 3 · KİMLİK"
title: "Ödeme kimliğin"
key_being_made: "anahtar oluşturuluyor…"
connected_nym: "Nym üzerinden bağlı"
connecting_nym: "Nym üzerinden bağlanılıyor…"
connected_nym: "Tor üzerinden bağlı"
connecting_nym: "Tor üzerinden bağlanılıyor…"
fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür."
clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz."
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "adınız"
working: "Çalışıyor…"
claim_username: "Kullanıcı adı al"
available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al."
available_when_connected: "Tor bağlandığında müsait — ya da atla ve sonra al."
youre: "Sen %{name}'sin"
claimed_title: "%{name} artık senin"
claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç."
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "Onlar öder"
row_they_pay_val: "Yalnızca onaylarlarsa"
row_delivery: "Teslimat"
row_delivery_val: "NIP-44 şifreli, Nym üzerinden"
row_delivery_val: "NIP-44 şifreli, Tor üzerinden"
row_network_fee: "Ağ ücreti"
row_network_fee_val: "Bakiyenden düşülür"
row_privacy: "Gizlilik"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "İstek gönder"
request_approve_hint: "Onaylamaları için bir istek alacaklar"
hold_to_send: "Göndermek için basılı tut"
+15 -15
View File
@@ -359,9 +359,9 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "已通过 Nym 连接"
nym_ready: "Nym 就绪 · 连接中继…"
connecting_nym: "正在连接 Nym…"
connected_nym: "已通过 Tor 连接"
nym_ready: "Tor 就绪 · 连接中继…"
connecting_nym: "正在连接 Tor…"
cant_reach_node: "无法连接节点"
node_synced: "节点已同步"
syncing: "同步中…"
@@ -416,7 +416,7 @@ goblin:
fee_none: "无"
network_fee: "网络费用"
privacy: "隐私"
privacy_value: "Mimblewimble + Nym"
privacy_value: "Mimblewimble + Tor"
transaction: "交易"
cancel_request: "取消请求"
cancel_send: "取消付款"
@@ -474,7 +474,7 @@ goblin:
switch_wallet: "切换钱包"
advanced: "高级"
privacy: "隐私"
mixnet_routing: "mixnet 路由"
mixnet_routing: "Tor 路由"
messages_lookups: "消息和查询"
auto_accept: "自动接受"
pairing: "价格货币"
@@ -497,7 +497,7 @@ goblin:
goblin: "Goblin"
build: "构建 %{build}"
network: "网络"
network_value: "MW + Nym mixnet + nostr"
network_value: "MW + Tor + nostr"
third_party: "第三方"
grim: "GRIM(上游钱包)"
grin_node: "Grin 节点"
@@ -617,14 +617,14 @@ goblin:
hide_qr: "隐藏二维码"
privacy:
title: "网络隐私"
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
payments: "付款"
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
usernames: "用户名"
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
price_avatars: "价格"
price_avatars_blurb: "金额旁显示的实时法币汇率。"
over_mixnet: "经由 mixnet"
over_mixnet: "经由 Tor"
direct_connection: "直接连接"
grin_node: "Grin 节点"
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
@@ -632,7 +632,7 @@ goblin:
title: "配对"
intro: "你的余额和金额以何种货币显示。"
pair_with: "配对货币"
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
relays:
title: "中继"
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
@@ -674,7 +674,7 @@ goblin:
private_money_head: "私密货币"
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
send_like_message_head: "像发消息一样付款"
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Nym mixnet 送达 — 中间任何人都看不到金额或参与者。"
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
yours_alone_head: "完全属于你"
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
get_started: "开始使用"
@@ -725,8 +725,8 @@ goblin:
kicker: "步骤 3 / 3 · 身份"
title: "你的付款身份"
key_being_made: "正在生成密钥…"
connected_nym: "已通过 Nym 连接"
connecting_nym: "正在通过 Nym 连接…"
connected_nym: "已通过 Tor 连接"
connecting_nym: "正在通过 Tor 连接…"
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
pick_username: "选择用户名 — 可选"
@@ -734,7 +734,7 @@ goblin:
username_field_hint: "你的用户名"
working: "处理中…"
claim_username: "注册用户名"
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
youre: "你是 %{name}"
claimed_title: "%{name} 已归你所有"
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
@@ -792,11 +792,11 @@ goblin:
row_they_pay: "对方支付"
row_they_pay_val: "仅当对方同意时"
row_delivery: "传输"
row_delivery_val: "NIP-44 加密,经由 Nym"
row_delivery_val: "NIP-44 加密,经由 Tor"
row_network_fee: "网络费用"
row_network_fee_val: "从你的余额中扣除"
row_privacy: "隐私"
row_privacy_val: "Mimblewimble + Nym"
row_privacy_val: "Mimblewimble + Tor"
send_request_btn: "发送请求"
request_approve_hint: "对方将收到一条待同意的请求"
hold_to_send: "长按发送"
+5 -5
View File
@@ -795,9 +795,9 @@ impl GoblinWalletView {
ui.label(
// Relay-gated: "Connected over Nym" only once a
// relay is live on the current tunnel generation.
RichText::new(if crate::nym::transport_ready() {
RichText::new(if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
} else if crate::nym::is_ready() {
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
} else {
t!("goblin.home.connecting_nym")
@@ -2412,9 +2412,9 @@ impl GoblinWalletView {
// 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::nym::transport_ready() {
let mixnet = if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
} else if crate::nym::is_ready() {
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
} else {
t!("goblin.home.connecting_nym")
@@ -2435,7 +2435,7 @@ impl GoblinWalletView {
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if !crate::nym::transport_ready() || !connected {
if !crate::tor::transport_ready() || !connected {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(600));
}
+1 -1
View File
@@ -804,7 +804,7 @@ impl OnboardingContent {
ui.label(
// Relay-gated readiness: "connected over Nym" only once a
// relay is actually live, not merely when the tunnel is warm.
RichText::new(if crate::nym::transport_ready() {
RichText::new(if crate::tor::transport_ready() {
t!("goblin.onboarding.identity.connected_nym")
} else {
t!("goblin.onboarding.identity.connecting_nym")
+5 -5
View File
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::AppConfig;
use crate::nym;
use crate::tor;
/// Cache refresh interval (seconds).
const REFRESH_SECS: i64 = 300;
@@ -154,7 +154,7 @@ pub fn eager_refresh() {
.build()
.unwrap();
rt.block_on(async {
let generation = nym::tunnel_generation();
let generation = tor::tunnel_generation();
let mut ok = false;
for attempt in 1..=PROBE_ATTEMPTS {
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
@@ -175,8 +175,8 @@ pub fn eager_refresh() {
// generation we probed: the exit is up but blackholing our HTTP. Condemn
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
// probed generation so a reselect that already happened is never hit.
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
nym::condemn_exit(generation);
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
tor::condemn_exit(generation);
}
});
FETCHING.write().remove(&vs);
@@ -191,7 +191,7 @@ async fn fetch_rate(vs: &str) -> Option<f64> {
// CoinGecko rejects requests without a User-Agent (403). A static,
// non-identifying UA is fine over the mixnet.
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
let body = nym::http_request("GET", url, None, headers).await?;
let body = tor::http_request("GET", url, None, headers).await?;
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
+12 -5
View File
@@ -38,8 +38,14 @@ mod http;
pub mod logger;
mod node;
pub mod nostr;
/// The old Nym-mixnet transport, DORMANT since the Tor swap. Retained on disk but
/// only compiled with `--features nym` (its nym-sdk deps link a different
/// libsqlite3-sys than arti and cannot coexist with Tor in one binary). Deletion
/// is a later phase.
#[cfg(feature = "nym")]
pub mod nym;
mod settings;
pub mod tor;
mod wallet;
/// Upstream GRIM version the fork is based on (third-party credit).
@@ -117,11 +123,12 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
// would panic on the first TLS handshake. nym uses its own explicit provider,
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
let _ = rustls::crypto::ring::default_provider().install_default();
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
// traffic egresses through it; nothing clearnet.
nym::warm_up();
// Pre-warm the embedded Tor client FIRST, before i18n/node setup, so the Tor
// bootstrap (the long pole on cold start) overlaps everything else and
// price/NIP-05/nostr are ready at first use. All of Goblin's relay + HTTP
// traffic egresses through Tor; the Grin node stays on the clear internet
// exactly as before (its lazy warm-on-activity polling is untouched).
tor::warm_up();
// Seed the price cache from disk so the amount preview can paint an instant
// (stale-marked) fiat value while the first live fetch is still in flight.
crate::http::price::seed_from_disk();
+39 -85
View File
@@ -35,7 +35,7 @@ use crate::nostr::relays::MAX_DM_RELAYS;
use crate::nostr::types::*;
use crate::nostr::wrapv3;
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
use crate::nym::NymWebSocketTransport;
use crate::tor::TorWebSocketTransport;
use crate::wallet::Wallet;
use crate::wallet::types::WalletTask;
@@ -61,10 +61,10 @@ const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
/// Money-path safety: a payment/control DM is only reported "sent" once a relay
/// is confirmed to actually hold the gift wrap. A transport-write success is NOT
/// proof of delivery — over the scoped Nym exit a multi-fragment wrap can trail
/// its local "sent" by many seconds to minutes (exit backpressure / gateway
/// bandwidth), so reporting on the write alone silently loses payments. Total
/// budget to confirm via read-back before surfacing failure to the caller.
/// proof of delivery — over the transport a wrap can trail its local "sent" by
/// seconds (transport buffering / a slow relay), so reporting on the write alone
/// silently loses payments. Total budget to confirm via read-back before
/// surfacing failure to the caller.
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
/// Per-attempt read-back timeout while confirming (short, so one dead relay
/// doesn't consume the whole confirm budget in a single poll).
@@ -615,13 +615,12 @@ impl NostrService {
let event_id = res.val;
// SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the
// moment the gift wrap is written to the (mixnet) transport sink — NOT
// when a relay has actually stored it. Over the scoped Nym exit a
// multi-fragment wrap can trail its local "sent" by many seconds to
// minutes (exit backpressure / gateway bandwidth), so a bare success is a
// FALSE "sent" that silently loses the payment. Require a genuine
// read-back: poll the target relays for the event id (it may still be
// egressing right after send) until one confirms it holds the wrap, or the
// moment the gift wrap is written to the transport sink — NOT when a relay
// has actually stored it. Over the transport a wrap can trail its local
// "sent" by seconds (transport buffering / a slow relay), so a bare success
// is a FALSE "sent" that silently loses the payment. Require a genuine
// read-back: poll the target relays for the event id (it may still be in
// flight right after send) until one confirms it holds the wrap, or the
// CONFIRM_TIMEOUT budget is spent — then surface failure so the caller
// retries / falls back instead of dropping the payment.
let confirm_filter = Filter::new().id(event_id).limit(1);
@@ -870,32 +869,27 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
let client = Client::builder()
.signer(svc.keys.clone())
.websocket_transport(NymWebSocketTransport)
.websocket_transport(TorWebSocketTransport)
.build();
// Wait for the in-process Nym mixnet tunnel before any network work
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
// launch, but a fast wallet-open can beat the cold mixnet bootstrap — and
// dialing before it's up drops every relay into nostr-sdk's backing-off
// reconnect, leaving the wallet on "Connecting…" long after the mixnet is
// actually ready. Once it's warm this returns immediately.
for i in 0..60u32 {
if crate::nym::is_ready() {
// Wait for the embedded Tor client before any network work (relay dials, pool
// refresh, NIP-11 probes). `warm_up()` starts it at launch, but a fast
// wallet-open can beat the cold Tor bootstrap — and dialing before it's up
// drops every relay into nostr-sdk's backing-off reconnect, leaving the wallet
// on "Connecting…" long after Tor is actually ready. Once it's bootstrapped
// this returns immediately.
for i in 0..240u32 {
if crate::tor::is_ready() {
if i > 0 {
info!(
"nostr: Nym tunnel ready after ~{}ms, dialing relays",
i * 500
);
info!("nostr: Tor ready after ~{}ms, dialing relays", i * 500);
}
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
// We are now a relay consumer: arm nymproc's relay-reachability governance of
// exit health for our lifetime, so a DNS-ok-but-relay-dead exit gets
// condemned. Disarmed when the loop exits (see below), so plain HTTP-only
// usage of the tunnel never condemns an otherwise-healthy exit.
crate::nym::set_relay_consumer(true);
// Refresh the relay candidate pool cache (gist over Nym) when stale.
// We are now a relay consumer (API parity with the old transport; inert under
// Tor, which manages its own circuit health). Disarmed when the loop exits.
crate::tor::set_relay_consumer(true);
// Refresh the relay candidate pool cache (gist over Tor) when stale.
tokio::spawn(crate::nostr::pool::refresh_if_stale());
// Select this identity's advertised relay set if it hasn't one yet.
ensure_advertised_set(&svc).await;
@@ -906,59 +900,19 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
svc.npub(),
relays
);
// Prewarm mix-dns for the hosts we're about to (or will soon) hit — the
// relays being dialed, the NIP-05 name authority (Claim username), and the
// price API — so those resolutions are already cached by the time the user
// acts, rather than each paying a cold mixnet round trip inline. The node host
// is NOT here — it never rides the mixnet.
//
// Unlike before this no longer silently SKIPS when the tunnel isn't up yet
// (the cold-start case that used to leave the first relay dial to a cold DoT
// round trip): it WAITS for the tunnel, prewarms, then keeps the entries hot
// by re-prewarming on a cadence below the DNS cache TTL floor, so known/stable
// hosts are refreshed in the background before they can expire.
{
let mut hosts: Vec<String> = relays
.iter()
.filter_map(|r| nostr_sdk::Url::parse(r).ok())
.filter_map(|u| u.host_str().map(|h| h.to_string()))
.collect();
// The name authority, both from this service's config and the process-wide
// configured home domain (they're normally the same; dedup below folds it).
hosts.push(svc.config.read().home_domain());
hosts.push(crate::nostr::nip05::home_domain());
hosts.push("api.coingecko.com".to_string());
hosts.retain(|h| !h.is_empty());
hosts.sort();
hosts.dedup();
tokio::spawn(async move {
// Wait out the cold start rather than skipping the prewarm entirely.
let Some(tunnel) = crate::nym::nymproc::wait_for_tunnel(Duration::from_secs(60)).await
else {
return;
};
crate::nym::dns::prewarm(&tunnel, &hosts).await;
// Keep the entries warm: re-prewarm every 45s (below the 60s TTL
// floor) so a stable host never expires out of the cache between
// uses. Picks up the current tunnel each cycle, so it survives exit
// reselects.
loop {
tokio::time::sleep(Duration::from_secs(45)).await;
if let Some(t) = crate::nym::nymproc::tunnel() {
crate::nym::dns::prewarm(&t, &hosts).await;
}
}
});
}
// (No DNS prewarm here: unlike the old mixnet path, 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.)
for relay in &relays {
if let Err(e) = client.add_relay(relay.clone()).await {
warn!("nostr: add relay {relay} failed: {e}");
}
}
// The tunnel generation these relays are being dialed on. If the exit is
// later reselected (generation bumped by nymproc), the status loop drops
// these now-dead sockets and re-dials through the fresh tunnel.
let mut dial_gen = crate::nym::tunnel_generation();
// The transport generation these relays are being dialed on. With Tor this is
// stable (arti rebuilds circuits transparently), so the reselect-driven
// re-dial below simply never fires — the status loop still re-checks liveness.
let mut dial_gen = crate::tor::tunnel_generation();
let connect_started = std::time::Instant::now();
client.connect().await;
{
@@ -1000,7 +954,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// 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).
crate::nym::report_relay_live(report_gen);
crate::tor::report_relay_live(report_gen);
return;
}
if svc_probe.shutdown.load(Ordering::SeqCst)
@@ -1080,7 +1034,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// actual connected+subscribed relay on THIS tunnel generation, not merely a
// warm tunnel — and so nymproc's relay-readiness window closes successfully.
if connected {
crate::nym::report_relay_live(dial_gen);
crate::tor::report_relay_live(dial_gen);
}
let mut notifications = client.notifications();
@@ -1123,7 +1077,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// subscription — a reselect thus transparently restores
// receive+send. (An individual relay bounce with the exit still
// healthy is left to nostr-sdk's own auto-reconnect + resubscribe.)
let generation = crate::nym::tunnel_generation();
let generation = crate::tor::tunnel_generation();
if generation != dial_gen {
info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit");
redial_on_new_tunnel(&client, &relays, &filter).await;
@@ -1135,9 +1089,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// a live relay closes/keeps-open nymproc's readiness window; all
// relays down for too long condemns the exit and reselects.
if connected {
crate::nym::report_relay_live(dial_gen);
crate::tor::report_relay_live(dial_gen);
} else {
crate::nym::report_relay_down(dial_gen);
crate::tor::report_relay_down(dial_gen);
}
let now = unix_time();
if now - last_heartbeat >= 30 {
@@ -1181,7 +1135,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// No longer a relay consumer: disarm relay-reachability governance so the
// idle tunnel isn't condemned for "no relay" once we stop dialing.
crate::nym::set_relay_consumer(false);
crate::tor::set_relay_consumer(false);
{
let mut w_client = svc.client.write();
*w_client = None;
+9 -9
View File
@@ -22,7 +22,7 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::nostr::relays::HOME_NIP05_DOMAIN;
use crate::nym;
use crate::tor;
use parking_lot::RwLock;
/// The active name-authority "home" domain, mirrored here from the wallet config
@@ -102,7 +102,7 @@ pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
domain,
urlencode(name)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
let body = tor::http_request("GET", url, None, vec![]).await?;
parse_well_known(&body, name)
}
@@ -120,7 +120,7 @@ pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option<String> {
domain,
urlencode(pubkey_hex)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
let body = tor::http_request("GET", url, None, vec![]).await?;
let doc: Value = serde_json::from_str(&body).ok()?;
doc.get("name")
.and_then(|v| v.as_str())
@@ -159,7 +159,7 @@ pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check {
domain,
urlencode(name)
);
let Some(body) = nym::http_request("GET", url, None, vec![]).await else {
let Some(body) = tor::http_request("GET", url, None, vec![]).await else {
return Nip05Check::Unreachable;
};
check_body(&body, pubkey, name)
@@ -218,7 +218,7 @@ pub async fn check_availability(server: &str, name: &str) -> Availability {
server.trim_end_matches('/'),
urlencode(name)
);
let body = match nym::http_request("GET", url, None, vec![]).await {
let body = match tor::http_request("GET", url, None, vec![]).await {
Some(b) => b,
None => return Availability::Unknown,
};
@@ -284,7 +284,7 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
("Authorization".to_string(), auth),
("Content-Type".to_string(), "application/json".to_string()),
];
let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else {
let Some(resp) = tor::http_request("POST", url, Some(body), headers).await else {
return RegisterResult::Network;
};
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
@@ -313,7 +313,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
return Err("couldn't sign the request".to_string());
};
let headers = vec![("Authorization".to_string(), auth)];
match nym::http_request("DELETE", url, None, headers).await {
match tor::http_request("DELETE", url, None, headers).await {
Some(resp) if resp.contains("\"released\":true") => Ok(()),
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
.ok()
@@ -328,7 +328,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/profile/{}", server, urlencode(name));
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
if code == 404 {
return Some(None);
}
@@ -347,7 +347,7 @@ pub async fn fetch_avatar(server: &str, hash: &str) -> Option<Vec<u8>> {
}
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/avatar/{}.png", server, hash);
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) {
return None;
}
+53 -11
View File
@@ -66,7 +66,7 @@ const PINNED_POOL: &str = r#"{
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.",
"min_message_length": 131072,
"relays": [
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe" },
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe", "onion": "m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion" },
{ "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
@@ -103,6 +103,16 @@ pub struct PoolRelay {
/// it is meant to replace.
#[serde(default)]
pub exit: Option<String>,
/// This relay's pinned `.onion` address (`<host>.onion`, optional `:port`),
/// when its operator fronts the relay with a Tor onion service. The wallet
/// dials this over embedded Tor and speaks PLAIN websocket to it (the onion
/// connection is already encrypted+authenticated end to end). Absent → the
/// relay is reached over a Tor exit to its clearnet host instead. Added beside
/// `exit` the same tolerant way (no `deny_unknown_fields`, `version` stays 1),
/// so OLDER builds simply ignore it — no schema break, no flag day. Carried in
/// the pinned pool so the money-path relay's onion bootstraps OFFLINE.
#[serde(default)]
pub onion: Option<String>,
}
impl PoolRelay {
@@ -189,6 +199,27 @@ impl RelayPool {
.iter()
.any(|r| r.exit.as_deref().is_some_and(|e| !e.trim().is_empty()))
}
/// The pinned `.onion` for `url`, if the pool advertises one (url compared
/// modulo a trailing slash). `None` → reach the relay over a Tor exit to its
/// clearnet host. This is how the wallet learns the money-path relay's onion
/// (see [`PoolRelay::onion`]).
pub fn onion_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.onion.clone())
.filter(|o| !o.trim().is_empty())
}
/// Whether ANY relay in the pool pins an `.onion`. Used to prefer a pool that
/// carries the money-path onion (see [`load`]).
pub fn has_onion(&self) -> bool {
self.relays
.iter()
.any(|r| r.onion.as_deref().is_some_and(|o| !o.trim().is_empty()))
}
}
/// Disk path of the cached pool file.
@@ -202,12 +233,12 @@ pub fn load() -> RelayPool {
std::fs::read_to_string(cache_path())
.ok()
.and_then(|raw| RelayPool::parse(&raw))
// A cache written by a pre-exit build parses fine but hides the
// scoped-exit money path (and the current primary relay) for up to
// CACHE_MAX_AGE_SECS after an app update — relay connects then ride
// the slow public-IPR path for days. The pinned pool is newer than
// any exit-less file, so prefer it until the next gist refresh.
.filter(RelayPool::has_exit)
// A cache written by a pre-Tor build parses fine but hides the onion
// money path (and the current primary relay) for up to CACHE_MAX_AGE_SECS
// after an app update — relay connects then have no onion to dial for days.
// The pinned pool is newer than any onion-less file, so prefer it until the
// next gist refresh.
.filter(RelayPool::has_onion)
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
}
@@ -226,17 +257,17 @@ pub async fn refresh_if_stale() {
.and_then(|t| t.elapsed().ok())
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
.unwrap_or(false)
// An exit-less cache predates the current pool shape (see `load`,
// An onion-less cache predates the current pool shape (see `load`,
// which already ignores it) — replace it now instead of serving the
// pinned fallback for the rest of the file's 7 days.
&& std::fs::read_to_string(&path)
.ok()
.and_then(|raw| RelayPool::parse(&raw))
.is_some_and(|p| p.has_exit());
.is_some_and(|p| p.has_onion());
if fresh {
return;
}
let Some(raw) = crate::nym::http_request("GET", POOL_URL.to_string(), None, vec![]).await
let Some(raw) = crate::tor::http_request("GET", POOL_URL.to_string(), None, vec![]).await
else {
warn!("relay pool: refresh fetch failed, keeping current pool");
return;
@@ -305,7 +336,7 @@ pub async fn probe(url: &str) -> bool {
let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())];
let ok = tokio::time::timeout(
PROBE_TIMEOUT,
crate::nym::http_request("GET", http_url, None, headers),
crate::tor::http_request("GET", http_url, None, headers),
)
.await
.ok()
@@ -385,6 +416,15 @@ mod tests {
assert_eq!(dm.len(), 10);
assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev"));
assert!(dm.iter().all(|r| r.vetted.is_some()));
// The money-path relay pins its .onion so the Tor transport bootstraps
// OFFLINE, before any network; every other relay is onion-less (reached
// over a Tor exit).
assert!(pool.has_onion());
assert_eq!(
pool.onion_for("wss://relay.floonet.dev"),
Some("m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion".to_string())
);
assert!(pool.onion_for("wss://nos.lol").is_none());
let disc = pool.discovery_relays();
// relay.floonet.dev carries both roles; the two indexers
// are discovery-only.
@@ -505,6 +545,7 @@ mod tests {
roles: vec!["dm".to_string()],
vetted: vetted.then(|| "2026-07-01".to_string()),
exit: None,
onion: None,
};
vec![
mk("wss://a.example", false),
@@ -530,6 +571,7 @@ mod tests {
roles: vec!["dm".to_string()],
vetted: Some("2026-07-01".to_string()),
exit: None,
onion: None,
});
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
assert_eq!(order.len(), 4);
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Embedded Tor (arti) client — the DIALING half only. Copied from our sister
//! wallet GRIM's proven, shipping engine (`grim/src/tor/`), stripped to what
//! Goblin needs: connect OUT to the relay's `.onion` (and to clearnet HTTP hosts
//! through a Tor exit). Goblin never HOSTS an onion service (GRIM's receiving
//! half), so the onion-service hosting, keystore-seeding and reverse-proxy code
//! is dropped.
//!
//! Two technical choices are inherited VERBATIM from GRIM because it already paid
//! for them: **arti 0.43** across the arti family, and the **native-tls Tor
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, to sidestep
//! the rustls/ring crypto-provider conflict Goblin fought during the Nym era.
//!
//! The arti client runs on its OWN dedicated tokio runtime (created once, kept
//! alive for the process). `TorClient::connect()` returns a [`DataStream`] that
//! is `AsyncRead + AsyncWrite`; that byte source is handed to the websocket layer
//! ([`super::transport`]) and the HTTP layer ([`super`]), each driven by their
//! own caller runtime — a `DataStream` is runtime-agnostic once the client's
//! circuit tasks are running on the arti runtime.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::{fs, thread};
use arti_client::config::TorClientConfigBuilder;
use arti_client::{DataStream, TorClient, TorClientConfig};
use lazy_static::lazy_static;
use log::{error, info, warn};
use parking_lot::RwLock;
use tor_rtcompat::SpawnExt;
use tor_rtcompat::tokio::TokioNativeTlsRuntime;
/// The Tor runtime type — native-tls, matching GRIM (never rustls).
type Runtime = TokioNativeTlsRuntime;
/// The concrete arti client type.
pub type Client = TorClient<Runtime>;
/// How long a single cold Tor bootstrap may take before we declare it failed and
/// let a later `warm_up()`/`wait_ready()` retry. A cold bootstrap with no cached
/// consensus can take tens of seconds; a warm one (cached dir) is a few.
const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(90);
lazy_static! {
/// Process-lifetime Tor state. The dedicated arti runtime lives here so its
/// worker threads (which drive every circuit) persist for the whole process.
static ref TOR: Tor = Tor::new();
}
struct Tor {
/// The dedicated arti runtime (native-tls). All arti tasks run on this.
runtime: Runtime,
/// The bootstrapped client, once it is up.
client: RwLock<Option<Arc<Client>>>,
/// Guards the background bootstrap so `warm_up()` is idempotent.
launching: AtomicBool,
}
impl Tor {
fn new() -> Self {
Self {
runtime: TokioNativeTlsRuntime::create().expect("create tor runtime"),
client: RwLock::new(None),
launching: AtomicBool::new(false),
}
}
}
// --- Readiness signals (re-pointed from `nym::nymproc`, same semantics) -------
/// Set once arti has bootstrapped (mirrors `TUNNEL_GEN != 0`); cheap to poll.
static READY: AtomicBool = AtomicBool::new(false);
/// Monotonic "transport generation". With Tor there is no exit-reselect churn —
/// arti rebuilds circuits transparently under the `DataStream` — so this simply
/// becomes 1 once bootstrapped and stays there. The relay-gated readiness logic
/// (copied from nym) still works: a relay-liveness report tagged with an older
/// generation can never mark a newer transport ready.
static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
/// The generation on which the nostr client currently has a relay connected AND
/// subscribed, or 0 for "no relay live". A single atomic so [`transport_ready`]
/// can compare it to `TUNNEL_GEN` in one shot.
static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0);
/// Whether a nostr consumer currently wants relays over Tor. Kept for API parity
/// with the nym transport (the UI/service bracket it); Tor needs no exit-health
/// governance, so it is otherwise inert.
static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false);
/// Pre-warm the embedded Tor client in the background so relays / NIP-05 / price
/// are ready by first use. Idempotent — a call while a bootstrap is in flight, or
/// once one has succeeded, is a no-op.
pub fn warm_up() {
if TOR.client.read().is_some() {
return;
}
if TOR.launching.swap(true, Ordering::SeqCst) {
return;
}
thread::spawn(|| {
bootstrap_once();
TOR.launching.store(false, Ordering::SeqCst);
});
}
/// Whether the embedded Tor client has bootstrapped. Cheap and cached — safe to
/// poll from the UI each frame. Distinct from a relay being connected (see
/// [`transport_ready`]): Tor can be up while no relay yet rides it.
pub fn is_ready() -> bool {
READY.load(Ordering::Relaxed)
}
/// The current transport generation. The nostr client reads this right before it
/// dials so it can tag its relay-liveness reports.
pub fn tunnel_generation() -> u64 {
TUNNEL_GEN.load(Ordering::Acquire)
}
/// Relay-gated readiness — the AUTHORITATIVE "ready to receive/send over Tor"
/// signal, distinct from the bootstrap-only [`is_ready`]. True only when Tor is
/// bootstrapped AND a required relay is connected+subscribed on the CURRENT
/// generation, so the UI never shows a false "Connected".
pub fn transport_ready() -> bool {
let generation = TUNNEL_GEN.load(Ordering::Acquire);
generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation && is_ready()
}
/// Client → transport report: a relay is connected+subscribed on `generation`.
/// `fetch_max` so a late report for an older generation can never move liveness
/// backwards over a newer one.
pub fn report_relay_live(generation: u64) {
RELAY_LIVE_GEN.fetch_max(generation, Ordering::AcqRel);
}
/// Client → transport report: no relay is currently live on `generation`. Clears
/// liveness only when `generation` is still the live one.
pub fn report_relay_down(generation: u64) {
let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire);
}
/// Bracket a nostr consumer's lifetime (API parity with the nym transport). Inert
/// for Tor — arti manages its own circuit health — but kept so the service's
/// existing calls compile unchanged.
pub fn set_relay_consumer(active: bool) {
RELAY_CONSUMER.store(active, Ordering::Release);
}
/// External condemnation request (API parity with the nym transport). Under Tor
/// there is no exit to abandon — arti rebuilds circuits itself — so this is a
/// logged no-op rather than triggering a reselect.
pub fn condemn_exit(generation: u64) {
if generation != 0 {
warn!("tor: condemn_exit(gen {generation}) is a no-op (arti rebuilds circuits itself)");
}
}
/// The bootstrapped client, if it is up. Cloning the `Arc` is cheap.
pub fn client() -> Option<Arc<Client>> {
TOR.client.read().clone()
}
/// Wait until the embedded Tor client has bootstrapped, starting it if nothing
/// has yet (lazy init on first use). Returns `false` once `timeout` lapses.
pub async fn wait_ready(timeout: Duration) -> bool {
warm_up();
let deadline = Instant::now() + timeout;
loop {
if is_ready() {
return true;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
/// Open a Tor stream to `host:port`. `host` may be a `.onion` address (dialed as
/// a real onion connection — no exit node) or a clearnet host (dialed through a
/// Tor exit). Returns a [`DataStream`] (`AsyncRead + AsyncWrite`) — the byte
/// source the websocket / HTTP layers wrap. The caller is responsible for its own
/// connect timeout.
pub async fn connect(host: &str, port: u16) -> Result<DataStream, String> {
let client = client().ok_or_else(|| "tor client not bootstrapped".to_string())?;
client
.connect((host, port))
.await
.map_err(|e| format!("tor connect to {host}:{port} failed: {e}"))
}
/// Build the arti client config: fs-backed state + cache in Goblin's base dir,
/// and — crucially — `allow_onion_addrs(true)` so `.onion` targets are dialable
/// (this plus the `onion-service-client` cargo feature is what enables onion
/// connections). Matches GRIM's `build_config`, minus the bridge plumbing Goblin
/// does not use.
fn build_config() -> TorClientConfig {
let mut builder =
TorClientConfigBuilder::from_directories(super::state_path(), super::cache_path());
builder.address_filter().allow_onion_addrs(true);
builder.build().expect("build tor client config")
}
/// One bootstrap attempt, driven on the arti runtime (GRIM's proven pattern:
/// spawn the bootstrap on arti's runtime, poll a flag from this thread). On
/// success the client is published and the readiness signals flip.
fn bootstrap_once() {
// Ensure the state/cache dirs exist (arti creates them, but on a fresh device
// the parent must be present first).
let _ = fs::create_dir_all(super::state_path());
let _ = fs::create_dir_all(super::cache_path());
let config = build_config();
let client = match TorClient::with_runtime(TOR.runtime.clone())
.config(config)
.create_unbootstrapped()
{
Ok(c) => c,
Err(e) => {
error!("tor: could not create client: {e}");
return;
}
};
let started = Instant::now();
let bootstrapping = Arc::new(AtomicBool::new(true));
let success = Arc::new(AtomicBool::new(false));
let bootstrapping_t = bootstrapping.clone();
let success_t = success.clone();
let c = client.clone();
let spawned = TOR.runtime.spawn(async move {
match tokio::time::timeout(BOOTSTRAP_TIMEOUT, c.bootstrap()).await {
Ok(Ok(())) => success_t.store(true, Ordering::Relaxed),
Ok(Err(e)) => error!("tor: bootstrap error: {e}"),
Err(_) => error!(
"tor: bootstrap timed out after {}s",
BOOTSTRAP_TIMEOUT.as_secs()
),
}
bootstrapping_t.store(false, Ordering::Relaxed);
});
if spawned.is_err() {
error!("tor: could not spawn bootstrap task");
return;
}
// Wait for the bootstrap task to finish.
while bootstrapping.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(500));
}
if !success.load(Ordering::Relaxed) {
return;
}
// `create_unbootstrapped()` already hands back an `Arc<TorClient>`, so store it
// as-is (no extra wrapping).
TOR.client.write().replace(client);
// A NEW transport is live: publish generation 1 (relay-liveness left over from
// a prior generation is instantly stale) and flip the bootstrap-ready flag.
TUNNEL_GEN.store(1, Ordering::Release);
READY.store(true, Ordering::Release);
info!(
"tor: bootstrapped and ready in {}ms (gen 1)",
started.elapsed().as_millis()
);
// Eager price fetch the moment Tor is ready (mirrors what the old mixnet
// bootstrap did): prefetch the pairing's rate so the amount preview has a live
// value by first use. One-shot — bootstrap_once only reaches here once.
std::thread::spawn(crate::http::price::eager_refresh);
}
+301
View File
@@ -0,0 +1,301 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Embedded-Tor transport. Everything Goblin sends over the network — nostr relay
//! websockets and every HTTP request (NIP-05, price, relay pool, avatars) — rides
//! Tor, embedded in-process (arti), copied from our sister wallet GRIM's proven,
//! shipping engine. The wallet dials the relay's pinned `.onion` over Tor and
//! speaks PLAIN websocket to it (the onion connection is already encrypted +
//! authenticated end to end — the `.onion` address IS the relay's public key — so
//! a TLS wrapper is redundant and the relay backend does not serve it). Relays
//! WITHOUT a pinned onion (e.g. a recipient's arbitrary DM relay) are reached over
//! a Tor exit to their clearnet host, with the usual TLS for `wss://`.
//!
//! This replaces the Nym-mixnet transport (`crate::nym`, left dormant): Tor is
//! free, unmetered, has no token or grant to expire, and GRIM has already proven
//! the whole embedded path on desktop and Android.
//!
//! The Grin blockchain node is NOT routed here — it stays on the clear internet
//! exactly as before; it never sees who pays whom.
mod engine;
mod transport;
pub use engine::{
Client, client, condemn_exit, connect, is_ready, report_relay_down, report_relay_live,
set_relay_consumer, transport_ready, tunnel_generation, wait_ready, warm_up,
};
pub use transport::TorWebSocketTransport;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use log::{debug, warn};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::Settings;
/// How long a single HTTP exchange (one redirect hop) may take end to end.
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the embedded Tor client to bootstrap before giving up on
/// a request. A cold Tor bootstrap can take tens of seconds; a warm one is fast.
const TUNNEL_WAIT: Duration = Duration::from_secs(60);
/// Redirect hops to follow before giving up.
const MAX_REDIRECTS: usize = 5;
// --- Tor data directories -----------------------------------------------------
/// Base Tor data directory (`<base>/tor`).
fn base_path() -> PathBuf {
Settings::base_path(Some("tor".to_string()))
}
/// Tor state directory (consensus, guards, …). Used by [`engine`].
pub(crate) fn state_path() -> String {
let mut base = base_path();
base.push("state");
base.to_str().unwrap().to_string()
}
/// Tor cache directory (directory documents). Used by [`engine`].
pub(crate) fn cache_path() -> String {
let mut base = base_path();
base.push("cache");
base.to_str().unwrap().to_string()
}
// --- Onion resolution ---------------------------------------------------------
/// The pinned `.onion` `(host, port)` for a relay `url`, if one is configured.
/// The `GOBLIN_TOR_ONION` env override (for ad-hoc testing) wins; otherwise the
/// pool's per-relay `onion` field ([`crate::nostr::pool::RelayPool::onion_for`]).
/// `None` → this relay has no onion and is reached over a Tor exit to its clearnet
/// host instead (see [`transport`]).
pub(crate) fn onion_for(url: &str) -> Option<(String, u16)> {
if let Ok(env) = std::env::var("GOBLIN_TOR_ONION") {
let env = env.trim();
if !env.is_empty() {
return parse_onion(env);
}
}
crate::nostr::pool::load()
.onion_for(url)
.and_then(|o| parse_onion(&o))
}
/// Parse an onion target `host[:port]` → `(host, port)`. Defaults to port 80
/// (plain ws over the onion). Tolerant of a `ws://`/`http://` prefix and a
/// trailing slash so the pinned string may be written either way.
fn parse_onion(s: &str) -> Option<(String, u16)> {
let s = s
.trim()
.trim_start_matches("ws://")
.trim_start_matches("http://")
.trim_end_matches('/');
if s.is_empty() {
return None;
}
match s.rsplit_once(':') {
Some((host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => {
Some((host.to_string(), port.parse().ok()?))
}
_ => Some((s.to_string(), 80)),
}
}
// --- HTTP over Tor ------------------------------------------------------------
/// An HTTP request routed over Tor: dial the host over Tor (an onion via a real
/// onion circuit, a clearnet host via a Tor exit — arti resolves the name
/// internally, so nothing leaks a clearnet DNS lookup), then rustls (webpki
/// roots) for https, then HTTP/1.1. Follows redirects. Returns `(status, body)`.
///
/// For now clearnet-over-Tor is fine for the small lookups (names at goblin.st,
/// relay hints, pool refresh, price, avatars); pinning those behind onions is a
/// later pass.
pub async fn http_request_bytes(
method: &str,
url: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
if !wait_ready(TUNNEL_WAIT).await {
warn!("tor http: client not bootstrapped, dropping request");
return None;
}
let mut url = url::Url::parse(&url).ok()?;
let mut method = method.to_uppercase();
let mut body = body;
for _ in 0..=MAX_REDIRECTS {
let (status, resp_body, location) = tokio::time::timeout(
HTTP_TIMEOUT,
request_once(&method, &url, body.clone(), &headers),
)
.await
.map_err(|_| warn!("tor http: request to {} timed out", redacted(&url)))
.ok()??;
match location {
Some(loc) => {
url = url.join(&loc).ok()?;
// 303 (and legacy 301/302) turn into a bodiless GET; 307/308 replay.
if matches!(status, 301..=303) {
method = "GET".to_string();
body = None;
}
debug!(
"tor http: following {status} redirect to {}",
redacted(&url)
);
}
None => return Some((status, resp_body)),
}
}
warn!("tor http: too many redirects for {}", redacted(&url));
None
}
/// String-bodied convenience wrapper around [`http_request_bytes`].
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers)
.await
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
}
/// Host without path/query, for logs (never log full URLs).
fn redacted(url: &url::Url) -> String {
url.host_str().unwrap_or("<no-host>").to_string()
}
/// A single HTTP/1.1 exchange over Tor. Returns the status, the collected body
/// and, for 3xx responses, the `Location` target.
async fn request_once(
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
let host = url.host_str()?.to_string();
let https = url.scheme() == "https";
let port = url.port().unwrap_or(if https { 443 } else { 80 });
let tcp = connect(&host, port)
.await
.map_err(|e| warn!("tor http: connect to {host} failed: {e}"))
.ok()?;
let io: Box<dyn Stream> = if https {
Box::new(tls_connect(&host, tcp).await?)
} else {
Box::new(tcp)
};
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.map_err(|e| warn!("tor http: handshake with {host} failed: {e}"))
.ok()?;
// Drive the connection in the background for this one exchange.
tokio::spawn(async move {
let _ = conn.await;
});
let m = hyper::Method::from_bytes(method.as_bytes()).ok()?;
let path = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
let host_header = if (https && port == 443) || (!https && port == 80) {
host.clone()
} else {
format!("{host}:{port}")
};
let mut req = hyper::Request::builder()
.method(m)
.uri(path)
.header(hyper::header::HOST, host_header)
.header(hyper::header::USER_AGENT, "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!("tor http: request to {host} failed: {e}"))
.ok()?;
let status = resp.status().as_u16();
let location = if resp.status().is_redirection() {
resp.headers()
.get(hyper::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec();
Some((status, bytes, location))
}
/// Everything hyper (and the TLS layer) needs from a Tor-carried stream, boxable
/// for the plain-http / https split.
pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Stream for T {}
lazy_static::lazy_static! {
/// Shared rustls client config (webpki roots; ring provider installed at
/// startup — see lib.rs), reused by every clearnet-over-Tor https handshake.
/// Never the platform verifier — it panics on Android outside a full app
/// context.
static ref TLS_CONFIG: Arc<rustls::ClientConfig> = {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
};
}
/// The shared rustls client config (cheap `Arc` bump).
pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
TLS_CONFIG.clone()
}
/// TLS-wrap a Tor-carried TCP stream with rustls + webpki roots. The certificate
/// is validated against the HOSTNAME, so a hostile Tor exit cannot MITM a
/// clearnet https fetch.
async fn tls_connect<S>(host: &str, stream: S) -> Option<tokio_rustls::client::TlsStream<S>>
where
S: AsyncRead + AsyncWrite + Send + Unpin,
{
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?;
tokio_rustls::TlsConnector::from(tls_config())
.connect(server_name, stream)
.await
.map_err(|e| warn!("tor http: tls handshake with {host} failed: {e}"))
.ok()
}
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! WebSocket transport for the Nostr relay pool routed over embedded Tor.
//! ANCHOR: a relay whose pool entry pins an `.onion`
//! ([`crate::nostr::pool::PoolRelay::onion`]) is dialed straight to that onion
//! over Tor — a real onion circuit, no exit node — and spoken to in PLAIN
//! websocket ([`tokio_tungstenite::client_async`]). The onion connection is
//! already encrypted AND authenticated end to end (the `.onion` address IS the
//! relay's public key), so a TLS wrapper is redundant and the relay backend does
//! not serve it. EXIT PATH (every relay without a pinned onion — e.g. a
//! recipient's arbitrary DM relay a send fans out to): dial the relay's clearnet
//! host over a Tor exit and run the usual hostname-validated TLS + websocket
//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. Either way the payload
//! and in-flight destination never touch the clear, and the wallet's own IP is
//! never exposed.
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
use async_wsocket::{ConnectionMode, Message};
use nostr_relay_pool::transport::error::TransportError;
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
use nostr_sdk::Url;
use nostr_sdk::util::BoxedFuture;
use tokio_tungstenite::tungstenite::Message as TgMessage;
/// A backend transport error (failures outside the websocket layer) carrying
/// `msg` as its display text.
fn terr(msg: impl Into<String>) -> TransportError {
TransportError::backend(std::io::Error::other(msg.into()))
}
/// Nostr websocket transport over embedded Tor.
#[derive(Debug, Clone, Copy, Default)]
pub struct TorWebSocketTransport;
impl WebSocketTransport for TorWebSocketTransport {
fn support_ping(&self) -> bool {
true
}
fn connect<'a>(
&'a self,
url: &'a Url,
_mode: &'a ConnectionMode,
timeout: Duration,
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
Box::pin(async move {
let host = url
.host_str()
.ok_or_else(|| terr("relay url has no host"))?
.to_string();
// The embedded Tor client must be bootstrapped before any dial.
if !crate::tor::wait_ready(timeout).await {
return Err(terr("tor client not bootstrapped"));
}
// MONEY-PATH ANCHOR: when the pool pins this relay's `.onion`, dial it
// directly over Tor and speak PLAIN websocket — the onion connection is
// already encrypted+authenticated end to end (the `.onion` IS the
// relay's public key), so no TLS on top.
if let Some((onion, port)) = crate::tor::onion_for(url.as_str()) {
let t = Instant::now();
let stream = tokio::time::timeout(timeout, crate::tor::connect(&onion, port))
.await
.map_err(|_| terr("tor onion connect timeout"))?
.map_err(terr)?;
// PLAIN ws over the onion (client_async, NOT client_async_tls). The
// handshake targets the onion host itself.
let ws_url = format!("ws://{onion}/");
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async(ws_url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout (onion)"))?
.map_err(|e| terr(format!("websocket handshake failed (onion): {e}")))?;
log::info!(
"[timing] tor: relay {host} CONNECTED via onion — stream+ws {}ms",
t.elapsed().as_millis()
);
return Ok(split_ws(ws));
}
// EXIT PATH: no pinned onion → reach the relay's clearnet host over a
// Tor exit, with the usual TLS + websocket for wss (SNI = the relay
// host). This is what lets a send fan out to a recipient's arbitrary
// public DM relays over Tor.
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
let t = Instant::now();
let stream = tokio::time::timeout(timeout, crate::tor::connect(&host, port))
.await
.map_err(|_| terr("tor connect timeout"))?
.map_err(terr)?;
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
log::info!(
"[timing] tor: relay {host} CONNECTED via exit — tls+ws {}ms",
t.elapsed().as_millis()
);
Ok(split_ws(ws))
})
}
}
/// Split a websocket into the pool's boxed sink/stream halves — shared by the
/// onion and exit 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(TorSink(tx)) as WebSocketSink;
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
match msg {
Ok(tg) => tg_to_message(tg).map(Ok),
Err(e) => Some(Err(TransportError::backend(e))),
}
})) as WebSocketStream;
(sink, stream)
}
/// Convert a tungstenite message into an async-wsocket pool message.
/// Returns `None` for raw frames (never surfaced while reading).
fn tg_to_message(msg: TgMessage) -> Option<Message> {
match msg {
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
TgMessage::Close(_) => Some(Message::Close(None)),
TgMessage::Frame(_) => None,
}
}
/// Sink adapter converting pool messages into tungstenite messages.
struct TorSink<S>(S);
impl<S> Sink<Message> for TorSink<S>
where
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
{
type Error = TransportError;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_ready_unpin(cx)
.map_err(TransportError::backend)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.0)
.start_send_unpin(TgMessage::from(item))
.map_err(TransportError::backend)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_flush_unpin(cx)
.map_err(TransportError::backend)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_close_unpin(cx)
.map_err(TransportError::backend)
}
}
+5 -5
View File
@@ -175,9 +175,9 @@ mod tests {
// The app installs these at startup (src/lib.rs); a bare test must too.
let _ = rustls::crypto::ring::default_provider().install_default();
crate::nym::warm_up();
crate::tor::warm_up();
assert!(
wait_until("nym tunnel is_ready", 180, crate::nym::is_ready),
wait_until("nym tunnel is_ready", 180, crate::tor::is_ready),
"nym tunnel never came up"
);
@@ -470,14 +470,14 @@ mod tests {
"money relay {RELAY} advertises no scoped exit in the pool; the split money path cannot be verified"
);
crate::nym::warm_up();
crate::tor::warm_up();
assert!(
wait_until("nym tunnel is_ready", 180, crate::nym::is_ready),
wait_until("nym tunnel is_ready", 180, crate::tor::is_ready),
"nym tunnel never came up"
);
println!(
"[fe2e] nym ready; tunnel_generation={}",
crate::nym::tunnel_generation()
crate::tor::tunnel_generation()
);
// One external node for BOTH wallets: the money path splits at the RELAY