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:
Generated
+2197
-4486
File diff suppressed because it is too large
Load Diff
+38
-11
@@ -31,6 +31,17 @@ lto = true
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
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]
|
[dependencies]
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
|
|
||||||
@@ -124,18 +135,34 @@ rustls = { version = "0.23", features = ["ring"] }
|
|||||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||||
webpki-roots = "1"
|
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
|
## Path deps into the local nym checkout, PINNED at rev
|
||||||
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local
|
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
|
||||||
## commit "http-api-client: preconfigured webpki roots on Android". Do not
|
## webpki roots on Android").
|
||||||
## float the checkout past that rev without re-verifying the Android build.
|
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||||
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
# smolmix = { path = "../nym/smolmix/core" }
|
||||||
## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit —
|
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||||
## 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"] }
|
|
||||||
|
|
||||||
## NIP-98 payload hashing
|
## NIP-98 payload hashing
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.8"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "Anonym"
|
anonymous: "Anonym"
|
||||||
connected_nym: "Über Nym verbunden"
|
connected_nym: "Über Tor verbunden"
|
||||||
nym_ready: "Nym bereit · Relays…"
|
nym_ready: "Tor bereit · Relays…"
|
||||||
connecting_nym: "Verbinde mit Nym…"
|
connecting_nym: "Verbinde mit Tor…"
|
||||||
cant_reach_node: "Node nicht erreichbar"
|
cant_reach_node: "Node nicht erreichbar"
|
||||||
node_synced: "Node synchronisiert"
|
node_synced: "Node synchronisiert"
|
||||||
syncing: "Synchronisiere…"
|
syncing: "Synchronisiere…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "Keine"
|
fee_none: "Keine"
|
||||||
network_fee: "Netzwerkgebühr"
|
network_fee: "Netzwerkgebühr"
|
||||||
privacy: "Privatsphäre"
|
privacy: "Privatsphäre"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "Transaktion"
|
transaction: "Transaktion"
|
||||||
cancel_request: "Anfrage abbrechen"
|
cancel_request: "Anfrage abbrechen"
|
||||||
cancel_send: "Zahlung abbrechen"
|
cancel_send: "Zahlung abbrechen"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "Wallet wechseln"
|
switch_wallet: "Wallet wechseln"
|
||||||
advanced: "Erweitert"
|
advanced: "Erweitert"
|
||||||
privacy: "Privatsphäre"
|
privacy: "Privatsphäre"
|
||||||
mixnet_routing: "Mixnet-Routing"
|
mixnet_routing: "Tor-Routing"
|
||||||
messages_lookups: "Nachrichten & Abfragen"
|
messages_lookups: "Nachrichten & Abfragen"
|
||||||
auto_accept: "Automatisch annehmen"
|
auto_accept: "Automatisch annehmen"
|
||||||
pairing: "Preiswährung"
|
pairing: "Preiswährung"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
network: "Netzwerk"
|
network: "Netzwerk"
|
||||||
network_value: "MW + Nym mixnet + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "Drittanbieter"
|
third_party: "Drittanbieter"
|
||||||
grim: "GRIM (Upstream-Wallet)"
|
grim: "GRIM (Upstream-Wallet)"
|
||||||
grin_node: "Grin-Node"
|
grin_node: "Grin-Node"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "QR ausblenden"
|
hide_qr: "QR ausblenden"
|
||||||
privacy:
|
privacy:
|
||||||
title: "Netzwerk-Privatsphäre"
|
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: "Zahlungen"
|
||||||
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
|
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
|
||||||
usernames: "usernames"
|
usernames: "usernames"
|
||||||
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
|
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
|
||||||
price_avatars: "Preis"
|
price_avatars: "Preis"
|
||||||
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
|
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
|
||||||
over_mixnet: "Über das mixnet"
|
over_mixnet: "Über Tor"
|
||||||
direct_connection: "Direkte Verbindung"
|
direct_connection: "Direkte Verbindung"
|
||||||
grin_node: "Grin-Node"
|
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."
|
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"
|
title: "Kopplung"
|
||||||
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
|
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
|
||||||
pair_with: "Koppeln mit"
|
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:
|
relays:
|
||||||
title: "Relays"
|
title: "Relays"
|
||||||
intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen."
|
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_head: "Privates Geld"
|
||||||
private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain."
|
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_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_head: "Nur deins"
|
||||||
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
|
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
|
||||||
get_started: "Loslegen"
|
get_started: "Loslegen"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
|
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
|
||||||
title: "Deine Zahlungsidentität"
|
title: "Deine Zahlungsidentität"
|
||||||
key_being_made: "Schlüssel wird erstellt…"
|
key_being_made: "Schlüssel wird erstellt…"
|
||||||
connected_nym: "über Nym verbunden"
|
connected_nym: "über Tor verbunden"
|
||||||
connecting_nym: "verbinde über Nym…"
|
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."
|
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."
|
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"
|
pick_username: "Benutzernamen wählen — optional"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "deinname"
|
username_field_hint: "deinname"
|
||||||
working: "Arbeite…"
|
working: "Arbeite…"
|
||||||
claim_username: "Benutzernamen sichern"
|
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}"
|
youre: "Du bist %{name}"
|
||||||
claimed_title: "%{name} gehört dir"
|
claimed_title: "%{name} gehört dir"
|
||||||
claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet."
|
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: "Sie zahlen"
|
||||||
row_they_pay_val: "Nur wenn sie zustimmen"
|
row_they_pay_val: "Nur wenn sie zustimmen"
|
||||||
row_delivery: "Zustellung"
|
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: "Netzwerkgebühr"
|
||||||
row_network_fee_val: "Von deinem Guthaben abgezogen"
|
row_network_fee_val: "Von deinem Guthaben abgezogen"
|
||||||
row_privacy: "Privatsphäre"
|
row_privacy: "Privatsphäre"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "Anfrage senden"
|
send_request_btn: "Anfrage senden"
|
||||||
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
|
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
|
||||||
hold_to_send: "Zum Senden halten"
|
hold_to_send: "Zum Senden halten"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "Anonymous"
|
anonymous: "Anonymous"
|
||||||
connected_nym: "Connected over Nym"
|
connected_nym: "Connected over Tor"
|
||||||
nym_ready: "Nym ready · relays…"
|
nym_ready: "Tor ready · relays…"
|
||||||
connecting_nym: "Connecting to Nym…"
|
connecting_nym: "Connecting to Tor…"
|
||||||
cant_reach_node: "Can't reach node"
|
cant_reach_node: "Can't reach node"
|
||||||
node_synced: "Node synced"
|
node_synced: "Node synced"
|
||||||
syncing: "Syncing…"
|
syncing: "Syncing…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "None"
|
fee_none: "None"
|
||||||
network_fee: "Network fee"
|
network_fee: "Network fee"
|
||||||
privacy: "Privacy"
|
privacy: "Privacy"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "Transaction"
|
transaction: "Transaction"
|
||||||
cancel_request: "Cancel request"
|
cancel_request: "Cancel request"
|
||||||
cancel_send: "Cancel payment"
|
cancel_send: "Cancel payment"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "Switch wallet"
|
switch_wallet: "Switch wallet"
|
||||||
advanced: "Advanced"
|
advanced: "Advanced"
|
||||||
privacy: "Privacy"
|
privacy: "Privacy"
|
||||||
mixnet_routing: "Mixnet routing"
|
mixnet_routing: "Tor routing"
|
||||||
messages_lookups: "Messages & lookups"
|
messages_lookups: "Messages & lookups"
|
||||||
auto_accept: "Auto-accept"
|
auto_accept: "Auto-accept"
|
||||||
pairing: "Price currency"
|
pairing: "Price currency"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
network: "Network"
|
network: "Network"
|
||||||
network_value: "MW + Nym mixnet + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "Third party"
|
third_party: "Third party"
|
||||||
grim: "GRIM (upstream wallet)"
|
grim: "GRIM (upstream wallet)"
|
||||||
grin_node: "Grin node"
|
grin_node: "Grin node"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "Hide QR"
|
hide_qr: "Hide QR"
|
||||||
privacy:
|
privacy:
|
||||||
title: "Network 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: "Payments"
|
||||||
payments_blurb: "Every nostr message carrying a slatepack."
|
payments_blurb: "Every nostr message carrying a slatepack."
|
||||||
usernames: "usernames"
|
usernames: "usernames"
|
||||||
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
|
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
|
||||||
price_avatars: "Price"
|
price_avatars: "Price"
|
||||||
price_avatars_blurb: "The live fiat rate shown next to amounts."
|
price_avatars_blurb: "The live fiat rate shown next to amounts."
|
||||||
over_mixnet: "Over the mixnet"
|
over_mixnet: "Over Tor"
|
||||||
direct_connection: "Direct connection"
|
direct_connection: "Direct connection"
|
||||||
grin_node: "Grin node"
|
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."
|
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"
|
title: "Pairing"
|
||||||
intro: "What your balance and amounts are shown against."
|
intro: "What your balance and amounts are shown against."
|
||||||
pair_with: "Pair with"
|
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:
|
relays:
|
||||||
title: "Relays"
|
title: "Relays"
|
||||||
intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive."
|
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_head: "Private money"
|
||||||
private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain."
|
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_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_head: "Yours alone"
|
||||||
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
|
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
|
||||||
get_started: "Get started"
|
get_started: "Get started"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "STEP 3 OF 3 · IDENTITY"
|
kicker: "STEP 3 OF 3 · IDENTITY"
|
||||||
title: "Your payment identity"
|
title: "Your payment identity"
|
||||||
key_being_made: "key being made…"
|
key_being_made: "key being made…"
|
||||||
connected_nym: "connected over Nym"
|
connected_nym: "connected over Tor"
|
||||||
connecting_nym: "connecting over Nym…"
|
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."
|
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."
|
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"
|
pick_username: "Pick a username — optional"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "yourname"
|
username_field_hint: "yourname"
|
||||||
working: "Working…"
|
working: "Working…"
|
||||||
claim_username: "Claim username"
|
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}"
|
youre: "You're %{name}"
|
||||||
claimed_title: "%{name} is yours"
|
claimed_title: "%{name} is yours"
|
||||||
claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet."
|
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: "They pay"
|
||||||
row_they_pay_val: "Only if they approve"
|
row_they_pay_val: "Only if they approve"
|
||||||
row_delivery: "Delivery"
|
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: "Network fee"
|
||||||
row_network_fee_val: "Deducted from your balance"
|
row_network_fee_val: "Deducted from your balance"
|
||||||
row_privacy: "Privacy"
|
row_privacy: "Privacy"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "Send request"
|
send_request_btn: "Send request"
|
||||||
request_approve_hint: "They'll get a request to approve"
|
request_approve_hint: "They'll get a request to approve"
|
||||||
hold_to_send: "Hold to send"
|
hold_to_send: "Hold to send"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "Anonyme"
|
anonymous: "Anonyme"
|
||||||
connected_nym: "Connecté via Nym"
|
connected_nym: "Connecté via Tor"
|
||||||
nym_ready: "Nym prêt · relais…"
|
nym_ready: "Tor prêt · relais…"
|
||||||
connecting_nym: "Connexion à Nym…"
|
connecting_nym: "Connexion à Tor…"
|
||||||
cant_reach_node: "Nœud injoignable"
|
cant_reach_node: "Nœud injoignable"
|
||||||
node_synced: "Nœud synchronisé"
|
node_synced: "Nœud synchronisé"
|
||||||
syncing: "Synchronisation…"
|
syncing: "Synchronisation…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "Aucun"
|
fee_none: "Aucun"
|
||||||
network_fee: "Frais de réseau"
|
network_fee: "Frais de réseau"
|
||||||
privacy: "Confidentialité"
|
privacy: "Confidentialité"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "Transaction"
|
transaction: "Transaction"
|
||||||
cancel_request: "Annuler la demande"
|
cancel_request: "Annuler la demande"
|
||||||
cancel_send: "Annuler le paiement"
|
cancel_send: "Annuler le paiement"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "Changer de portefeuille"
|
switch_wallet: "Changer de portefeuille"
|
||||||
advanced: "Avancé"
|
advanced: "Avancé"
|
||||||
privacy: "Confidentialité"
|
privacy: "Confidentialité"
|
||||||
mixnet_routing: "Routage par mixnet"
|
mixnet_routing: "Routage par Tor"
|
||||||
messages_lookups: "Messages et recherches"
|
messages_lookups: "Messages et recherches"
|
||||||
auto_accept: "Acceptation auto"
|
auto_accept: "Acceptation auto"
|
||||||
pairing: "Devise des prix"
|
pairing: "Devise des prix"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
network: "Réseau"
|
network: "Réseau"
|
||||||
network_value: "MW + mixnet Nym + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "Tiers"
|
third_party: "Tiers"
|
||||||
grim: "GRIM (portefeuille amont)"
|
grim: "GRIM (portefeuille amont)"
|
||||||
grin_node: "Nœud grin"
|
grin_node: "Nœud grin"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "Masquer le QR"
|
hide_qr: "Masquer le QR"
|
||||||
privacy:
|
privacy:
|
||||||
title: "Confidentialité réseau"
|
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: "Paiements"
|
||||||
payments_blurb: "Chaque message nostr transportant un slatepack."
|
payments_blurb: "Chaque message nostr transportant un slatepack."
|
||||||
usernames: "usernames"
|
usernames: "usernames"
|
||||||
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
|
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
|
||||||
price_avatars: "Prix"
|
price_avatars: "Prix"
|
||||||
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
|
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"
|
direct_connection: "Connexion directe"
|
||||||
grin_node: "Nœud grin"
|
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é."
|
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"
|
title: "Appairage"
|
||||||
intro: "Ce à quoi votre solde et vos montants sont comparés."
|
intro: "Ce à quoi votre solde et vos montants sont comparés."
|
||||||
pair_with: "Apparier avec"
|
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:
|
relays:
|
||||||
title: "Relais"
|
title: "Relais"
|
||||||
intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir."
|
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_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."
|
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_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_head: "À vous seul"
|
||||||
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
|
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
|
||||||
get_started: "Commencer"
|
get_started: "Commencer"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
|
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
|
||||||
title: "Votre identité de paiement"
|
title: "Votre identité de paiement"
|
||||||
key_being_made: "clé en cours de création…"
|
key_being_made: "clé en cours de création…"
|
||||||
connected_nym: "connecté via Nym"
|
connected_nym: "connecté via Tor"
|
||||||
connecting_nym: "connexion via Nym…"
|
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."
|
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."
|
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"
|
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "votrenom"
|
username_field_hint: "votrenom"
|
||||||
working: "En cours…"
|
working: "En cours…"
|
||||||
claim_username: "Réserver le nom d'utilisateur"
|
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}"
|
youre: "Vous êtes %{name}"
|
||||||
claimed_title: "%{name} est à vous"
|
claimed_title: "%{name} est à vous"
|
||||||
claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille."
|
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: "Ils paient"
|
||||||
row_they_pay_val: "Seulement s'ils approuvent"
|
row_they_pay_val: "Seulement s'ils approuvent"
|
||||||
row_delivery: "Livraison"
|
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: "Frais de réseau"
|
||||||
row_network_fee_val: "Déduit de votre solde"
|
row_network_fee_val: "Déduit de votre solde"
|
||||||
row_privacy: "Confidentialité"
|
row_privacy: "Confidentialité"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "Envoyer la demande"
|
send_request_btn: "Envoyer la demande"
|
||||||
request_approve_hint: "Ils recevront une demande à approuver"
|
request_approve_hint: "Ils recevront une demande à approuver"
|
||||||
hold_to_send: "Maintenir pour envoyer"
|
hold_to_send: "Maintenir pour envoyer"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "Аноним"
|
anonymous: "Аноним"
|
||||||
connected_nym: "Подключено через Nym"
|
connected_nym: "Подключено через Tor"
|
||||||
nym_ready: "Nym готов · реле…"
|
nym_ready: "Tor готов · реле…"
|
||||||
connecting_nym: "Подключение к Nym…"
|
connecting_nym: "Подключение к Tor…"
|
||||||
cant_reach_node: "Нет связи с узлом"
|
cant_reach_node: "Нет связи с узлом"
|
||||||
node_synced: "Узел синхронизирован"
|
node_synced: "Узел синхронизирован"
|
||||||
syncing: "Синхронизация…"
|
syncing: "Синхронизация…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "Нет"
|
fee_none: "Нет"
|
||||||
network_fee: "Сетевая комиссия"
|
network_fee: "Сетевая комиссия"
|
||||||
privacy: "Приватность"
|
privacy: "Приватность"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "Транзакция"
|
transaction: "Транзакция"
|
||||||
cancel_request: "Отменить запрос"
|
cancel_request: "Отменить запрос"
|
||||||
cancel_send: "Отменить платёж"
|
cancel_send: "Отменить платёж"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "Сменить кошелёк"
|
switch_wallet: "Сменить кошелёк"
|
||||||
advanced: "Дополнительно"
|
advanced: "Дополнительно"
|
||||||
privacy: "Приватность"
|
privacy: "Приватность"
|
||||||
mixnet_routing: "Маршрутизация через mixnet"
|
mixnet_routing: "Маршрутизация через Tor"
|
||||||
messages_lookups: "Сообщения и поиск"
|
messages_lookups: "Сообщения и поиск"
|
||||||
auto_accept: "Автоприём"
|
auto_accept: "Автоприём"
|
||||||
pairing: "Валюта цены"
|
pairing: "Валюта цены"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Сборка %{build}"
|
build: "Сборка %{build}"
|
||||||
network: "Сеть"
|
network: "Сеть"
|
||||||
network_value: "MW + mixnet Nym + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "Сторонние"
|
third_party: "Сторонние"
|
||||||
grim: "GRIM (исходный кошелёк)"
|
grim: "GRIM (исходный кошелёк)"
|
||||||
grin_node: "Узел Grin"
|
grin_node: "Узел Grin"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "Скрыть QR"
|
hide_qr: "Скрыть QR"
|
||||||
privacy:
|
privacy:
|
||||||
title: "Сетевая приватность"
|
title: "Сетевая приватность"
|
||||||
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
|
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
|
||||||
payments: "Платежи"
|
payments: "Платежи"
|
||||||
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
||||||
usernames: "usernames"
|
usernames: "usernames"
|
||||||
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
|
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
|
||||||
price_avatars: "Цена"
|
price_avatars: "Цена"
|
||||||
price_avatars_blurb: "Текущий курс рядом с суммами."
|
price_avatars_blurb: "Текущий курс рядом с суммами."
|
||||||
over_mixnet: "Через mixnet"
|
over_mixnet: "Через Tor"
|
||||||
direct_connection: "Прямое соединение"
|
direct_connection: "Прямое соединение"
|
||||||
grin_node: "Узел Grin"
|
grin_node: "Узел Grin"
|
||||||
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
|
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
|
||||||
@@ -632,7 +632,7 @@ goblin:
|
|||||||
title: "Привязка"
|
title: "Привязка"
|
||||||
intro: "К чему привязаны отображаемые баланс и суммы."
|
intro: "К чему привязаны отображаемые баланс и суммы."
|
||||||
pair_with: "Привязать к"
|
pair_with: "Привязать к"
|
||||||
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||||
relays:
|
relays:
|
||||||
title: "Реле"
|
title: "Реле"
|
||||||
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
||||||
@@ -674,7 +674,7 @@ goblin:
|
|||||||
private_money_head: "Приватные деньги"
|
private_money_head: "Приватные деньги"
|
||||||
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
|
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
|
||||||
send_like_message_head: "Отправляйте как сообщение"
|
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_head: "Только ваше"
|
||||||
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
|
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
|
||||||
get_started: "Начать"
|
get_started: "Начать"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
|
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
|
||||||
title: "Ваша платёжная личность"
|
title: "Ваша платёжная личность"
|
||||||
key_being_made: "ключ создаётся…"
|
key_being_made: "ключ создаётся…"
|
||||||
connected_nym: "подключено через Nym"
|
connected_nym: "подключено через Tor"
|
||||||
connecting_nym: "подключение через Nym…"
|
connecting_nym: "подключение через Tor…"
|
||||||
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
|
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
|
||||||
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
|
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
|
||||||
pick_username: "Выберите имя — необязательно"
|
pick_username: "Выберите имя — необязательно"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "yourname"
|
username_field_hint: "yourname"
|
||||||
working: "Обработка…"
|
working: "Обработка…"
|
||||||
claim_username: "Занять имя"
|
claim_username: "Занять имя"
|
||||||
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
|
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
|
||||||
youre: "Вы %{name}"
|
youre: "Вы %{name}"
|
||||||
claimed_title: "%{name} теперь ваше"
|
claimed_title: "%{name} теперь ваше"
|
||||||
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
||||||
@@ -792,11 +792,11 @@ goblin:
|
|||||||
row_they_pay: "Они платят"
|
row_they_pay: "Они платят"
|
||||||
row_they_pay_val: "Только если они одобрят"
|
row_they_pay_val: "Только если они одобрят"
|
||||||
row_delivery: "Доставка"
|
row_delivery: "Доставка"
|
||||||
row_delivery_val: "Зашифровано NIP-44, через Nym"
|
row_delivery_val: "Зашифровано NIP-44, через Tor"
|
||||||
row_network_fee: "Сетевая комиссия"
|
row_network_fee: "Сетевая комиссия"
|
||||||
row_network_fee_val: "Списывается с вашего баланса"
|
row_network_fee_val: "Списывается с вашего баланса"
|
||||||
row_privacy: "Приватность"
|
row_privacy: "Приватность"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "Отправить запрос"
|
send_request_btn: "Отправить запрос"
|
||||||
request_approve_hint: "Они получат запрос на одобрение"
|
request_approve_hint: "Они получат запрос на одобрение"
|
||||||
hold_to_send: "Удерживайте для отправки"
|
hold_to_send: "Удерживайте для отправки"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "Anonim"
|
anonymous: "Anonim"
|
||||||
connected_nym: "Nym üzerinden bağlı"
|
connected_nym: "Tor üzerinden bağlı"
|
||||||
nym_ready: "Nym hazır · relaylar…"
|
nym_ready: "Tor hazır · relaylar…"
|
||||||
connecting_nym: "Nym'e bağlanılıyor…"
|
connecting_nym: "Tor'a bağlanılıyor…"
|
||||||
cant_reach_node: "Düğüme ulaşılamıyor"
|
cant_reach_node: "Düğüme ulaşılamıyor"
|
||||||
node_synced: "Düğüm eşitlendi"
|
node_synced: "Düğüm eşitlendi"
|
||||||
syncing: "Eşitleniyor…"
|
syncing: "Eşitleniyor…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "Yok"
|
fee_none: "Yok"
|
||||||
network_fee: "Ağ ücreti"
|
network_fee: "Ağ ücreti"
|
||||||
privacy: "Gizlilik"
|
privacy: "Gizlilik"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "İşlem"
|
transaction: "İşlem"
|
||||||
cancel_request: "İsteği iptal et"
|
cancel_request: "İsteği iptal et"
|
||||||
cancel_send: "Ödemeyi iptal et"
|
cancel_send: "Ödemeyi iptal et"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "Cüzdan değiştir"
|
switch_wallet: "Cüzdan değiştir"
|
||||||
advanced: "Gelişmiş"
|
advanced: "Gelişmiş"
|
||||||
privacy: "Gizlilik"
|
privacy: "Gizlilik"
|
||||||
mixnet_routing: "Mixnet yönlendirme"
|
mixnet_routing: "Tor yönlendirme"
|
||||||
messages_lookups: "Mesajlar ve aramalar"
|
messages_lookups: "Mesajlar ve aramalar"
|
||||||
auto_accept: "Otomatik kabul"
|
auto_accept: "Otomatik kabul"
|
||||||
pairing: "Fiyat para birimi"
|
pairing: "Fiyat para birimi"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Sürüm %{build}"
|
build: "Sürüm %{build}"
|
||||||
network: "Ağ"
|
network: "Ağ"
|
||||||
network_value: "MW + Nym mixnet + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "Üçüncü taraf"
|
third_party: "Üçüncü taraf"
|
||||||
grim: "GRIM (üst kaynak cüzdan)"
|
grim: "GRIM (üst kaynak cüzdan)"
|
||||||
grin_node: "Grin düğümü"
|
grin_node: "Grin düğümü"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "QR gizle"
|
hide_qr: "QR gizle"
|
||||||
privacy:
|
privacy:
|
||||||
title: "Ağ gizliliği"
|
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: "Ödemeler"
|
||||||
payments_blurb: "Slatepack taşıyan her nostr mesajı."
|
payments_blurb: "Slatepack taşıyan her nostr mesajı."
|
||||||
usernames: "usernamelar"
|
usernames: "usernamelar"
|
||||||
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
|
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
|
||||||
price_avatars: "Fiyat"
|
price_avatars: "Fiyat"
|
||||||
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
|
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ı"
|
direct_connection: "Doğrudan bağlantı"
|
||||||
grin_node: "Grin düğümü"
|
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."
|
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"
|
title: "Eşleştirme"
|
||||||
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
|
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
|
||||||
pair_with: "Eşleştir"
|
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:
|
relays:
|
||||||
title: "Relaylar"
|
title: "Relaylar"
|
||||||
intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir."
|
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_head: "Özel para"
|
||||||
private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit."
|
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_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_head: "Yalnızca senin"
|
||||||
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
|
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
|
||||||
get_started: "Başla"
|
get_started: "Başla"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "ADIM 3 / 3 · KİMLİK"
|
kicker: "ADIM 3 / 3 · KİMLİK"
|
||||||
title: "Ödeme kimliğin"
|
title: "Ödeme kimliğin"
|
||||||
key_being_made: "anahtar oluşturuluyor…"
|
key_being_made: "anahtar oluşturuluyor…"
|
||||||
connected_nym: "Nym üzerinden bağlı"
|
connected_nym: "Tor üzerinden bağlı"
|
||||||
connecting_nym: "Nym üzerinden bağlanılıyor…"
|
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."
|
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."
|
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ı"
|
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "adınız"
|
username_field_hint: "adınız"
|
||||||
working: "Çalışıyor…"
|
working: "Çalışıyor…"
|
||||||
claim_username: "Kullanıcı adı al"
|
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"
|
youre: "Sen %{name}'sin"
|
||||||
claimed_title: "%{name} artık senin"
|
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ç."
|
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: "Onlar öder"
|
||||||
row_they_pay_val: "Yalnızca onaylarlarsa"
|
row_they_pay_val: "Yalnızca onaylarlarsa"
|
||||||
row_delivery: "Teslimat"
|
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: "Ağ ücreti"
|
||||||
row_network_fee_val: "Bakiyenden düşülür"
|
row_network_fee_val: "Bakiyenden düşülür"
|
||||||
row_privacy: "Gizlilik"
|
row_privacy: "Gizlilik"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "İstek gönder"
|
send_request_btn: "İstek gönder"
|
||||||
request_approve_hint: "Onaylamaları için bir istek alacaklar"
|
request_approve_hint: "Onaylamaları için bir istek alacaklar"
|
||||||
hold_to_send: "Göndermek için basılı tut"
|
hold_to_send: "Göndermek için basılı tut"
|
||||||
|
|||||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
|||||||
goblin:
|
goblin:
|
||||||
home:
|
home:
|
||||||
anonymous: "匿名"
|
anonymous: "匿名"
|
||||||
connected_nym: "已通过 Nym 连接"
|
connected_nym: "已通过 Tor 连接"
|
||||||
nym_ready: "Nym 就绪 · 连接中继…"
|
nym_ready: "Tor 就绪 · 连接中继…"
|
||||||
connecting_nym: "正在连接 Nym…"
|
connecting_nym: "正在连接 Tor…"
|
||||||
cant_reach_node: "无法连接节点"
|
cant_reach_node: "无法连接节点"
|
||||||
node_synced: "节点已同步"
|
node_synced: "节点已同步"
|
||||||
syncing: "同步中…"
|
syncing: "同步中…"
|
||||||
@@ -416,7 +416,7 @@ goblin:
|
|||||||
fee_none: "无"
|
fee_none: "无"
|
||||||
network_fee: "网络费用"
|
network_fee: "网络费用"
|
||||||
privacy: "隐私"
|
privacy: "隐私"
|
||||||
privacy_value: "Mimblewimble + Nym"
|
privacy_value: "Mimblewimble + Tor"
|
||||||
transaction: "交易"
|
transaction: "交易"
|
||||||
cancel_request: "取消请求"
|
cancel_request: "取消请求"
|
||||||
cancel_send: "取消付款"
|
cancel_send: "取消付款"
|
||||||
@@ -474,7 +474,7 @@ goblin:
|
|||||||
switch_wallet: "切换钱包"
|
switch_wallet: "切换钱包"
|
||||||
advanced: "高级"
|
advanced: "高级"
|
||||||
privacy: "隐私"
|
privacy: "隐私"
|
||||||
mixnet_routing: "mixnet 路由"
|
mixnet_routing: "Tor 路由"
|
||||||
messages_lookups: "消息和查询"
|
messages_lookups: "消息和查询"
|
||||||
auto_accept: "自动接受"
|
auto_accept: "自动接受"
|
||||||
pairing: "价格货币"
|
pairing: "价格货币"
|
||||||
@@ -497,7 +497,7 @@ goblin:
|
|||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "构建 %{build}"
|
build: "构建 %{build}"
|
||||||
network: "网络"
|
network: "网络"
|
||||||
network_value: "MW + Nym mixnet + nostr"
|
network_value: "MW + Tor + nostr"
|
||||||
third_party: "第三方"
|
third_party: "第三方"
|
||||||
grim: "GRIM(上游钱包)"
|
grim: "GRIM(上游钱包)"
|
||||||
grin_node: "Grin 节点"
|
grin_node: "Grin 节点"
|
||||||
@@ -617,14 +617,14 @@ goblin:
|
|||||||
hide_qr: "隐藏二维码"
|
hide_qr: "隐藏二维码"
|
||||||
privacy:
|
privacy:
|
||||||
title: "网络隐私"
|
title: "网络隐私"
|
||||||
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
|
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
|
||||||
payments: "付款"
|
payments: "付款"
|
||||||
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
|
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
|
||||||
usernames: "用户名"
|
usernames: "用户名"
|
||||||
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
|
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
|
||||||
price_avatars: "价格"
|
price_avatars: "价格"
|
||||||
price_avatars_blurb: "金额旁显示的实时法币汇率。"
|
price_avatars_blurb: "金额旁显示的实时法币汇率。"
|
||||||
over_mixnet: "经由 mixnet"
|
over_mixnet: "经由 Tor"
|
||||||
direct_connection: "直接连接"
|
direct_connection: "直接连接"
|
||||||
grin_node: "Grin 节点"
|
grin_node: "Grin 节点"
|
||||||
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
|
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
|
||||||
@@ -632,7 +632,7 @@ goblin:
|
|||||||
title: "配对"
|
title: "配对"
|
||||||
intro: "你的余额和金额以何种货币显示。"
|
intro: "你的余额和金额以何种货币显示。"
|
||||||
pair_with: "配对货币"
|
pair_with: "配对货币"
|
||||||
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||||
relays:
|
relays:
|
||||||
title: "中继"
|
title: "中继"
|
||||||
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
||||||
@@ -674,7 +674,7 @@ goblin:
|
|||||||
private_money_head: "私密货币"
|
private_money_head: "私密货币"
|
||||||
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
|
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
|
||||||
send_like_message_head: "像发消息一样付款"
|
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_head: "完全属于你"
|
||||||
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
|
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
|
||||||
get_started: "开始使用"
|
get_started: "开始使用"
|
||||||
@@ -725,8 +725,8 @@ goblin:
|
|||||||
kicker: "步骤 3 / 3 · 身份"
|
kicker: "步骤 3 / 3 · 身份"
|
||||||
title: "你的付款身份"
|
title: "你的付款身份"
|
||||||
key_being_made: "正在生成密钥…"
|
key_being_made: "正在生成密钥…"
|
||||||
connected_nym: "已通过 Nym 连接"
|
connected_nym: "已通过 Tor 连接"
|
||||||
connecting_nym: "正在通过 Nym 连接…"
|
connecting_nym: "正在通过 Tor 连接…"
|
||||||
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
|
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
|
||||||
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
|
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
|
||||||
pick_username: "选择用户名 — 可选"
|
pick_username: "选择用户名 — 可选"
|
||||||
@@ -734,7 +734,7 @@ goblin:
|
|||||||
username_field_hint: "你的用户名"
|
username_field_hint: "你的用户名"
|
||||||
working: "处理中…"
|
working: "处理中…"
|
||||||
claim_username: "注册用户名"
|
claim_username: "注册用户名"
|
||||||
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
|
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
|
||||||
youre: "你是 %{name}"
|
youre: "你是 %{name}"
|
||||||
claimed_title: "%{name} 已归你所有"
|
claimed_title: "%{name} 已归你所有"
|
||||||
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
||||||
@@ -792,11 +792,11 @@ goblin:
|
|||||||
row_they_pay: "对方支付"
|
row_they_pay: "对方支付"
|
||||||
row_they_pay_val: "仅当对方同意时"
|
row_they_pay_val: "仅当对方同意时"
|
||||||
row_delivery: "传输"
|
row_delivery: "传输"
|
||||||
row_delivery_val: "NIP-44 加密,经由 Nym"
|
row_delivery_val: "NIP-44 加密,经由 Tor"
|
||||||
row_network_fee: "网络费用"
|
row_network_fee: "网络费用"
|
||||||
row_network_fee_val: "从你的余额中扣除"
|
row_network_fee_val: "从你的余额中扣除"
|
||||||
row_privacy: "隐私"
|
row_privacy: "隐私"
|
||||||
row_privacy_val: "Mimblewimble + Nym"
|
row_privacy_val: "Mimblewimble + Tor"
|
||||||
send_request_btn: "发送请求"
|
send_request_btn: "发送请求"
|
||||||
request_approve_hint: "对方将收到一条待同意的请求"
|
request_approve_hint: "对方将收到一条待同意的请求"
|
||||||
hold_to_send: "长按发送"
|
hold_to_send: "长按发送"
|
||||||
|
|||||||
@@ -795,9 +795,9 @@ impl GoblinWalletView {
|
|||||||
ui.label(
|
ui.label(
|
||||||
// Relay-gated: "Connected over Nym" only once a
|
// Relay-gated: "Connected over Nym" only once a
|
||||||
// relay is live on the current tunnel generation.
|
// 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")
|
t!("goblin.home.connected_nym")
|
||||||
} else if crate::nym::is_ready() {
|
} else if crate::tor::is_ready() {
|
||||||
t!("goblin.home.nym_ready")
|
t!("goblin.home.nym_ready")
|
||||||
} else {
|
} else {
|
||||||
t!("goblin.home.connecting_nym")
|
t!("goblin.home.connecting_nym")
|
||||||
@@ -2412,9 +2412,9 @@ impl GoblinWalletView {
|
|||||||
// tunnel being warm is not enough — a relay must actually carry
|
// tunnel being warm is not enough — a relay must actually carry
|
||||||
// our traffic on the current exit. Otherwise show the tunnel is
|
// our traffic on the current exit. Otherwise show the tunnel is
|
||||||
// up but relays are still connecting/reconnecting.
|
// 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")
|
t!("goblin.home.connected_nym")
|
||||||
} else if crate::nym::is_ready() {
|
} else if crate::tor::is_ready() {
|
||||||
t!("goblin.home.nym_ready")
|
t!("goblin.home.nym_ready")
|
||||||
} else {
|
} else {
|
||||||
t!("goblin.home.connecting_nym")
|
t!("goblin.home.connecting_nym")
|
||||||
@@ -2435,7 +2435,7 @@ impl GoblinWalletView {
|
|||||||
.font(FontId::new(12.0, fonts::regular()))
|
.font(FontId::new(12.0, fonts::regular()))
|
||||||
.color(t.surface_text_mute),
|
.color(t.surface_text_mute),
|
||||||
);
|
);
|
||||||
if !crate::nym::transport_ready() || !connected {
|
if !crate::tor::transport_ready() || !connected {
|
||||||
ui.ctx()
|
ui.ctx()
|
||||||
.request_repaint_after(std::time::Duration::from_millis(600));
|
.request_repaint_after(std::time::Duration::from_millis(600));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -804,7 +804,7 @@ impl OnboardingContent {
|
|||||||
ui.label(
|
ui.label(
|
||||||
// Relay-gated readiness: "connected over Nym" only once a
|
// Relay-gated readiness: "connected over Nym" only once a
|
||||||
// relay is actually live, not merely when the tunnel is warm.
|
// 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")
|
t!("goblin.onboarding.identity.connected_nym")
|
||||||
} else {
|
} else {
|
||||||
t!("goblin.onboarding.identity.connecting_nym")
|
t!("goblin.onboarding.identity.connecting_nym")
|
||||||
|
|||||||
+5
-5
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::AppConfig;
|
use crate::AppConfig;
|
||||||
use crate::nym;
|
use crate::tor;
|
||||||
|
|
||||||
/// Cache refresh interval (seconds).
|
/// Cache refresh interval (seconds).
|
||||||
const REFRESH_SECS: i64 = 300;
|
const REFRESH_SECS: i64 = 300;
|
||||||
@@ -154,7 +154,7 @@ pub fn eager_refresh() {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let generation = nym::tunnel_generation();
|
let generation = tor::tunnel_generation();
|
||||||
let mut ok = false;
|
let mut ok = false;
|
||||||
for attempt in 1..=PROBE_ATTEMPTS {
|
for attempt in 1..=PROBE_ATTEMPTS {
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
|
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
|
// 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
|
// 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.
|
// probed generation so a reselect that already happened is never hit.
|
||||||
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
|
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
|
||||||
nym::condemn_exit(generation);
|
tor::condemn_exit(generation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
FETCHING.write().remove(&vs);
|
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,
|
// CoinGecko rejects requests without a User-Agent (403). A static,
|
||||||
// non-identifying UA is fine over the mixnet.
|
// non-identifying UA is fine over the mixnet.
|
||||||
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
|
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)
|
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
|
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
|
||||||
|
|||||||
+12
-5
@@ -38,8 +38,14 @@ mod http;
|
|||||||
pub mod logger;
|
pub mod logger;
|
||||||
mod node;
|
mod node;
|
||||||
pub mod nostr;
|
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;
|
pub mod nym;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
pub mod tor;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
|
||||||
/// Upstream GRIM version the fork is based on (third-party credit).
|
/// 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,
|
// 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).
|
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
|
// Pre-warm the embedded Tor client FIRST, before i18n/node setup, so the Tor
|
||||||
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
|
// bootstrap (the long pole on cold start) overlaps everything else and
|
||||||
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
|
// price/NIP-05/nostr are ready at first use. All of Goblin's relay + HTTP
|
||||||
// traffic egresses through it; nothing clearnet.
|
// traffic egresses through Tor; the Grin node stays on the clear internet
|
||||||
nym::warm_up();
|
// 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
|
// 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.
|
// (stale-marked) fiat value while the first live fetch is still in flight.
|
||||||
crate::http::price::seed_from_disk();
|
crate::http::price::seed_from_disk();
|
||||||
|
|||||||
+39
-85
@@ -35,7 +35,7 @@ use crate::nostr::relays::MAX_DM_RELAYS;
|
|||||||
use crate::nostr::types::*;
|
use crate::nostr::types::*;
|
||||||
use crate::nostr::wrapv3;
|
use crate::nostr::wrapv3;
|
||||||
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
||||||
use crate::nym::NymWebSocketTransport;
|
use crate::tor::TorWebSocketTransport;
|
||||||
use crate::wallet::Wallet;
|
use crate::wallet::Wallet;
|
||||||
use crate::wallet::types::WalletTask;
|
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);
|
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
|
||||||
/// Money-path safety: a payment/control DM is only reported "sent" once a relay
|
/// 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
|
/// 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
|
/// proof of delivery — over the transport a wrap can trail its local "sent" by
|
||||||
/// its local "sent" by many seconds to minutes (exit backpressure / gateway
|
/// seconds (transport buffering / a slow relay), so reporting on the write alone
|
||||||
/// bandwidth), so reporting on the write alone silently loses payments. Total
|
/// silently loses payments. Total budget to confirm via read-back before
|
||||||
/// budget to confirm via read-back before surfacing failure to the caller.
|
/// surfacing failure to the caller.
|
||||||
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
/// Per-attempt read-back timeout while confirming (short, so one dead relay
|
/// Per-attempt read-back timeout while confirming (short, so one dead relay
|
||||||
/// doesn't consume the whole confirm budget in a single poll).
|
/// doesn't consume the whole confirm budget in a single poll).
|
||||||
@@ -615,13 +615,12 @@ impl NostrService {
|
|||||||
let event_id = res.val;
|
let event_id = res.val;
|
||||||
|
|
||||||
// SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the
|
// SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the
|
||||||
// moment the gift wrap is written to the (mixnet) transport sink — NOT
|
// moment the gift wrap is written to the transport sink — NOT when a relay
|
||||||
// when a relay has actually stored it. Over the scoped Nym exit a
|
// has actually stored it. Over the transport a wrap can trail its local
|
||||||
// multi-fragment wrap can trail its local "sent" by many seconds to
|
// "sent" by seconds (transport buffering / a slow relay), so a bare success
|
||||||
// minutes (exit backpressure / gateway bandwidth), so a bare success is a
|
// is a FALSE "sent" that silently loses the payment. Require a genuine
|
||||||
// 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
|
||||||
// read-back: poll the target relays for the event id (it may still be
|
// flight right after send) until one confirms it holds the wrap, or the
|
||||||
// egressing right after send) until one confirms it holds the wrap, or the
|
|
||||||
// CONFIRM_TIMEOUT budget is spent — then surface failure so the caller
|
// CONFIRM_TIMEOUT budget is spent — then surface failure so the caller
|
||||||
// retries / falls back instead of dropping the payment.
|
// retries / falls back instead of dropping the payment.
|
||||||
let confirm_filter = Filter::new().id(event_id).limit(1);
|
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()
|
let client = Client::builder()
|
||||||
.signer(svc.keys.clone())
|
.signer(svc.keys.clone())
|
||||||
.websocket_transport(NymWebSocketTransport)
|
.websocket_transport(TorWebSocketTransport)
|
||||||
.build();
|
.build();
|
||||||
// Wait for the in-process Nym mixnet tunnel before any network work
|
// Wait for the embedded Tor client before any network work (relay dials, pool
|
||||||
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
|
// refresh, NIP-11 probes). `warm_up()` starts it at launch, but a fast
|
||||||
// launch, but a fast wallet-open can beat the cold mixnet bootstrap — and
|
// wallet-open can beat the cold Tor bootstrap — and dialing before it's up
|
||||||
// dialing before it's up drops every relay into nostr-sdk's backing-off
|
// drops every relay into nostr-sdk's backing-off reconnect, leaving the wallet
|
||||||
// reconnect, leaving the wallet on "Connecting…" long after the mixnet is
|
// on "Connecting…" long after Tor is actually ready. Once it's bootstrapped
|
||||||
// actually ready. Once it's warm this returns immediately.
|
// this returns immediately.
|
||||||
for i in 0..60u32 {
|
for i in 0..240u32 {
|
||||||
if crate::nym::is_ready() {
|
if crate::tor::is_ready() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
info!(
|
info!("nostr: Tor ready after ~{}ms, dialing relays", i * 500);
|
||||||
"nostr: Nym tunnel ready after ~{}ms, dialing relays",
|
|
||||||
i * 500
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
// We are now a relay consumer: arm nymproc's relay-reachability governance of
|
// We are now a relay consumer (API parity with the old transport; inert under
|
||||||
// exit health for our lifetime, so a DNS-ok-but-relay-dead exit gets
|
// Tor, which manages its own circuit health). Disarmed when the loop exits.
|
||||||
// condemned. Disarmed when the loop exits (see below), so plain HTTP-only
|
crate::tor::set_relay_consumer(true);
|
||||||
// usage of the tunnel never condemns an otherwise-healthy exit.
|
// Refresh the relay candidate pool cache (gist over Tor) when stale.
|
||||||
crate::nym::set_relay_consumer(true);
|
|
||||||
// Refresh the relay candidate pool cache (gist over Nym) when stale.
|
|
||||||
tokio::spawn(crate::nostr::pool::refresh_if_stale());
|
tokio::spawn(crate::nostr::pool::refresh_if_stale());
|
||||||
// Select this identity's advertised relay set if it hasn't one yet.
|
// Select this identity's advertised relay set if it hasn't one yet.
|
||||||
ensure_advertised_set(&svc).await;
|
ensure_advertised_set(&svc).await;
|
||||||
@@ -906,59 +900,19 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
svc.npub(),
|
svc.npub(),
|
||||||
relays
|
relays
|
||||||
);
|
);
|
||||||
// Prewarm mix-dns for the hosts we're about to (or will soon) hit — the
|
// (No DNS prewarm here: unlike the old mixnet path, arti resolves relay and
|
||||||
// relays being dialed, the NIP-05 name authority (Claim username), and the
|
// HTTP hostnames internally as part of the circuit dial — there is no
|
||||||
// price API — so those resolutions are already cached by the time the user
|
// separate in-tunnel DoT round trip to warm. The node host was never on this
|
||||||
// acts, rather than each paying a cold mixnet round trip inline. The node host
|
// path and still isn't — it never rides the private transport.)
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for relay in &relays {
|
for relay in &relays {
|
||||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||||
warn!("nostr: add relay {relay} failed: {e}");
|
warn!("nostr: add relay {relay} failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The tunnel generation these relays are being dialed on. If the exit is
|
// The transport generation these relays are being dialed on. With Tor this is
|
||||||
// later reselected (generation bumped by nymproc), the status loop drops
|
// stable (arti rebuilds circuits transparently), so the reselect-driven
|
||||||
// these now-dead sockets and re-dials through the fresh tunnel.
|
// re-dial below simply never fires — the status loop still re-checks liveness.
|
||||||
let mut dial_gen = crate::nym::tunnel_generation();
|
let mut dial_gen = crate::tor::tunnel_generation();
|
||||||
let connect_started = std::time::Instant::now();
|
let connect_started = std::time::Instant::now();
|
||||||
client.connect().await;
|
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,
|
// window as soon as the exit is proven to carry relay traffic,
|
||||||
// independent of the up-to-30s catch-up fetch below (a slow
|
// independent of the up-to-30s catch-up fetch below (a slow
|
||||||
// catch-up must not get a good exit wrongly condemned).
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if svc_probe.shutdown.load(Ordering::SeqCst)
|
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
|
// 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 nymproc's relay-readiness window closes successfully.
|
||||||
if connected {
|
if connected {
|
||||||
crate::nym::report_relay_live(dial_gen);
|
crate::tor::report_relay_live(dial_gen);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
@@ -1123,7 +1077,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
// subscription — a reselect thus transparently restores
|
// subscription — a reselect thus transparently restores
|
||||||
// receive+send. (An individual relay bounce with the exit still
|
// receive+send. (An individual relay bounce with the exit still
|
||||||
// healthy is left to nostr-sdk's own auto-reconnect + resubscribe.)
|
// 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 {
|
if generation != dial_gen {
|
||||||
info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit");
|
info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit");
|
||||||
redial_on_new_tunnel(&client, &relays, &filter).await;
|
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
|
// a live relay closes/keeps-open nymproc's readiness window; all
|
||||||
// relays down for too long condemns the exit and reselects.
|
// relays down for too long condemns the exit and reselects.
|
||||||
if connected {
|
if connected {
|
||||||
crate::nym::report_relay_live(dial_gen);
|
crate::tor::report_relay_live(dial_gen);
|
||||||
} else {
|
} else {
|
||||||
crate::nym::report_relay_down(dial_gen);
|
crate::tor::report_relay_down(dial_gen);
|
||||||
}
|
}
|
||||||
let now = unix_time();
|
let now = unix_time();
|
||||||
if now - last_heartbeat >= 30 {
|
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
|
// No longer a relay consumer: disarm relay-reachability governance so the
|
||||||
// idle tunnel isn't condemned for "no relay" once we stop dialing.
|
// 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();
|
let mut w_client = svc.client.write();
|
||||||
*w_client = None;
|
*w_client = None;
|
||||||
|
|||||||
+9
-9
@@ -22,7 +22,7 @@ use serde_json::Value;
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::nostr::relays::HOME_NIP05_DOMAIN;
|
use crate::nostr::relays::HOME_NIP05_DOMAIN;
|
||||||
use crate::nym;
|
use crate::tor;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
/// The active name-authority "home" domain, mirrored here from the wallet config
|
/// 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,
|
domain,
|
||||||
urlencode(name)
|
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)
|
parse_well_known(&body, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option<String> {
|
|||||||
domain,
|
domain,
|
||||||
urlencode(pubkey_hex)
|
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()?;
|
let doc: Value = serde_json::from_str(&body).ok()?;
|
||||||
doc.get("name")
|
doc.get("name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -159,7 +159,7 @@ pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check {
|
|||||||
domain,
|
domain,
|
||||||
urlencode(name)
|
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;
|
return Nip05Check::Unreachable;
|
||||||
};
|
};
|
||||||
check_body(&body, pubkey, name)
|
check_body(&body, pubkey, name)
|
||||||
@@ -218,7 +218,7 @@ pub async fn check_availability(server: &str, name: &str) -> Availability {
|
|||||||
server.trim_end_matches('/'),
|
server.trim_end_matches('/'),
|
||||||
urlencode(name)
|
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,
|
Some(b) => b,
|
||||||
None => return Availability::Unknown,
|
None => return Availability::Unknown,
|
||||||
};
|
};
|
||||||
@@ -284,7 +284,7 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
|
|||||||
("Authorization".to_string(), auth),
|
("Authorization".to_string(), auth),
|
||||||
("Content-Type".to_string(), "application/json".to_string()),
|
("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;
|
return RegisterResult::Network;
|
||||||
};
|
};
|
||||||
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
|
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());
|
return Err("couldn't sign the request".to_string());
|
||||||
};
|
};
|
||||||
let headers = vec![("Authorization".to_string(), auth)];
|
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) if resp.contains("\"released\":true") => Ok(()),
|
||||||
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
|
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
|
||||||
.ok()
|
.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>> {
|
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
|
||||||
let server = server.trim_end_matches('/');
|
let server = server.trim_end_matches('/');
|
||||||
let url = format!("{}/api/v1/profile/{}", server, urlencode(name));
|
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 {
|
if code == 404 {
|
||||||
return Some(None);
|
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 server = server.trim_end_matches('/');
|
||||||
let url = format!("{}/api/v1/avatar/{}.png", server, hash);
|
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']) {
|
if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-11
@@ -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.",
|
"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,
|
"min_message_length": 131072,
|
||||||
"relays": [
|
"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.primal.net", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||||
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
|
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||||
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
|
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||||
@@ -103,6 +103,16 @@ pub struct PoolRelay {
|
|||||||
/// it is meant to replace.
|
/// it is meant to replace.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exit: Option<String>,
|
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 {
|
impl PoolRelay {
|
||||||
@@ -189,6 +199,27 @@ impl RelayPool {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.exit.as_deref().is_some_and(|e| !e.trim().is_empty()))
|
.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.
|
/// Disk path of the cached pool file.
|
||||||
@@ -202,12 +233,12 @@ pub fn load() -> RelayPool {
|
|||||||
std::fs::read_to_string(cache_path())
|
std::fs::read_to_string(cache_path())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|raw| RelayPool::parse(&raw))
|
.and_then(|raw| RelayPool::parse(&raw))
|
||||||
// A cache written by a pre-exit build parses fine but hides the
|
// A cache written by a pre-Tor build parses fine but hides the onion
|
||||||
// scoped-exit money path (and the current primary relay) for up to
|
// money path (and the current primary relay) for up to CACHE_MAX_AGE_SECS
|
||||||
// CACHE_MAX_AGE_SECS after an app update — relay connects then ride
|
// after an app update — relay connects then have no onion to dial for days.
|
||||||
// the slow public-IPR path for days. The pinned pool is newer than
|
// The pinned pool is newer than any onion-less file, so prefer it until the
|
||||||
// any exit-less file, so prefer it until the next gist refresh.
|
// next gist refresh.
|
||||||
.filter(RelayPool::has_exit)
|
.filter(RelayPool::has_onion)
|
||||||
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
|
.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())
|
.and_then(|t| t.elapsed().ok())
|
||||||
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
|
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
|
||||||
.unwrap_or(false)
|
.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
|
// which already ignores it) — replace it now instead of serving the
|
||||||
// pinned fallback for the rest of the file's 7 days.
|
// pinned fallback for the rest of the file's 7 days.
|
||||||
&& std::fs::read_to_string(&path)
|
&& std::fs::read_to_string(&path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|raw| RelayPool::parse(&raw))
|
.and_then(|raw| RelayPool::parse(&raw))
|
||||||
.is_some_and(|p| p.has_exit());
|
.is_some_and(|p| p.has_onion());
|
||||||
if fresh {
|
if fresh {
|
||||||
return;
|
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 {
|
else {
|
||||||
warn!("relay pool: refresh fetch failed, keeping current pool");
|
warn!("relay pool: refresh fetch failed, keeping current pool");
|
||||||
return;
|
return;
|
||||||
@@ -305,7 +336,7 @@ pub async fn probe(url: &str) -> bool {
|
|||||||
let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())];
|
let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())];
|
||||||
let ok = tokio::time::timeout(
|
let ok = tokio::time::timeout(
|
||||||
PROBE_TIMEOUT,
|
PROBE_TIMEOUT,
|
||||||
crate::nym::http_request("GET", http_url, None, headers),
|
crate::tor::http_request("GET", http_url, None, headers),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -385,6 +416,15 @@ mod tests {
|
|||||||
assert_eq!(dm.len(), 10);
|
assert_eq!(dm.len(), 10);
|
||||||
assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev"));
|
assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev"));
|
||||||
assert!(dm.iter().all(|r| r.vetted.is_some()));
|
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();
|
let disc = pool.discovery_relays();
|
||||||
// relay.floonet.dev carries both roles; the two indexers
|
// relay.floonet.dev carries both roles; the two indexers
|
||||||
// are discovery-only.
|
// are discovery-only.
|
||||||
@@ -505,6 +545,7 @@ mod tests {
|
|||||||
roles: vec!["dm".to_string()],
|
roles: vec!["dm".to_string()],
|
||||||
vetted: vetted.then(|| "2026-07-01".to_string()),
|
vetted: vetted.then(|| "2026-07-01".to_string()),
|
||||||
exit: None,
|
exit: None,
|
||||||
|
onion: None,
|
||||||
};
|
};
|
||||||
vec![
|
vec![
|
||||||
mk("wss://a.example", false),
|
mk("wss://a.example", false),
|
||||||
@@ -530,6 +571,7 @@ mod tests {
|
|||||||
roles: vec!["dm".to_string()],
|
roles: vec!["dm".to_string()],
|
||||||
vetted: Some("2026-07-01".to_string()),
|
vetted: Some("2026-07-01".to_string()),
|
||||||
exit: None,
|
exit: None,
|
||||||
|
onion: None,
|
||||||
});
|
});
|
||||||
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
|
let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0);
|
||||||
assert_eq!(order.len(), 4);
|
assert_eq!(order.len(), 4);
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -175,9 +175,9 @@ mod tests {
|
|||||||
|
|
||||||
// The app installs these at startup (src/lib.rs); a bare test must too.
|
// The app installs these at startup (src/lib.rs); a bare test must too.
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
crate::nym::warm_up();
|
crate::tor::warm_up();
|
||||||
assert!(
|
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"
|
"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"
|
"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!(
|
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"
|
"nym tunnel never came up"
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"[fe2e] nym ready; tunnel_generation={}",
|
"[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
|
// One external node for BOTH wallets: the money path splits at the RELAY
|
||||||
|
|||||||
Reference in New Issue
Block a user