1
0
forked from GRIN/grim

Remove dormant Nym/mixnet transport, commit fully to Tor

The wallet pivoted from the Nym mixnet to Tor in build134; the Nym code
had been left on disk behind an optional, always-off "nym" feature and
never ran since. Delete it entirely: no Nym/mixnet fallback and no
Nym/mixnet mentions remain. Tor is the sole transport.

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

The pool's `exit` schema slot and exit_for/exit_for_host/has_exit
helpers are kept (comments de-Nym'd): they are inert under Tor but let
a pool document that carries an exit still parse.
This commit is contained in:
2ro
2026-07-08 00:28:10 -04:00
parent 079fdd4841
commit 35c9ea3bbc
28 changed files with 172 additions and 3092 deletions
+15 -39
View File
@@ -17,8 +17,8 @@ path = "src/main.rs"
name="grim"
crate-type = ["rlib"]
# Desktop/CI release binaries ship stripped of debug symbols — the nym + nostr +
# grin tree leaves a large symbol table that's dead weight for users (~16 MB on
# Desktop/CI release binaries ship stripped of debug symbols — the nostr + grin
# tree leaves a large symbol table that's dead weight for users (~16 MB on
# Linux). opt-level stays at the default 3 for wallet/runtime speed.
[profile.release]
strip = true
@@ -32,15 +32,8 @@ codegen-units = 1
panic = "abort"
[features]
## Default build uses the Tor transport only. The `nym` feature gates the dormant
## mixnet path (src/nym/). Cargo resolves OPTIONAL deps into the graph too, so
## nym-sdk cannot merely be `optional` — it links a different libsqlite3-sys than
## arti (a native-lib `links` conflict Cargo rejects at resolution). The nym
## path-deps are therefore commented out below; the module code is retained on
## disk but building `--features nym` requires restoring them (and drops arti —
## the two transports cannot coexist in one binary, which is why Tor replaced Nym).
## Default build uses the Tor transport only.
default = []
nym = []
## Compiles the internal E2E harness (src/wallet/e2e.rs); off by default and the
## file is untracked, so a fresh clone builds without it.
e2e-internal = []
@@ -130,13 +123,13 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
regex = "1"
base64 = "0.22"
hex = "0.4"
## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
## so we install ring explicitly at startup (see lib.rs). Direct dep just to make
## rustls is pulled by our TLS (tungstenite, ring). We install the ring provider
## explicitly at startup (see lib.rs) so rustls 0.23 selects a default
## deterministically. Direct dep just to make
## `rustls::crypto::ring::default_provider()` reachable. NOTHING here may pull
## rustls-platform-verifier — it panics on Android outside a full app context.
rustls = { version = "0.23", features = ["ring"] }
## TLS-over-tunnel for the mixnet HTTP client (webpki roots, never the platform
## TLS for the HTTPS-over-Tor client (webpki roots, never the platform
## verifier — see the rustls note above).
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
webpki-roots = "1"
@@ -145,31 +138,15 @@ webpki-roots = "1"
## and to clearnet HTTP hosts through a Tor exit). Copied from our sister wallet
## GRIM's proven, shipping engine. Two choices inherited VERBATIM from GRIM: arti
## 0.43 across the family, and the native-tls Tor runtime (TokioNativeTlsRuntime),
## NOT rustls — this deliberately sidesteps the rustls/ring crypto-provider
## conflict fought during the Nym era (our relay/HTTP rustls still uses ring, see
## lib.rs; arti's own TLS is native-tls and never touches the rustls provider).
## NOT rustls — this keeps arti's TLS on native-tls and off the rustls provider
## entirely (our relay/HTTP rustls still uses ring, see lib.rs; arti's own TLS is
## native-tls and never touches the rustls provider).
## `static` vendors openssl (self-contained Android/cross builds, as GRIM ships);
## `onion-service-client` enables dialing .onion. We drop GRIM's `pt-client`
## (bridges) and `onion-service-service` (hosting) — Goblin only dials.
arti-client = { version = "0.43.0", features = ["static", "onion-service-client"] }
tor-rtcompat = { version = "0.43.0", features = ["static"] }
## Nym mixnet — DORMANT since the Tor transport swap. The mixnet path (src/nym/)
## is retained on disk but its deps are COMMENTED OUT, because arti's `tor-dirmgr`
## needs libsqlite3-sys 0.34 (rusqlite 0.36) while nym-sdk's credential-storage
## needs libsqlite3-sys 0.30 (sqlx) and BOTH link the native `sqlite3` library —
## Cargo forbids two packages linking the same native lib, and it rejects this at
## RESOLUTION even for optional/unused deps. The two transports therefore cannot
## coexist in one binary (exactly why Tor replaced Nym). To build the old path,
## restore these three deps and build `--features nym` (which then drops arti).
## Full deletion is a later phase; for now the code stays on disk for reference.
## Path deps into the local nym checkout, PINNED at rev
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
## webpki roots on Android").
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
# smolmix = { path = "../nym/smolmix/core" }
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
## NIP-98 payload hashing
sha2 = "0.10.8"
@@ -200,12 +177,11 @@ arboard = "3.2.0"
rfd = "0.17.2"
interprocess = { version = "2.2.1", features = ["tokio"] }
## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. Upstream Grim
## got a vendored, statically-linked OpenSSL for free through arti's `static`
## feature; dropping arti for Nym took that with it, breaking Android/cross
## builds (no system OpenSSL for the target) and leaving desktop dynamically
## linked to libssl. Restore the vendored build for exactly those two targets so
## each is self-contained. Windows (SChannel) and macOS (Security.framework)
## native-tls (via hyper-tls) uses OpenSSL only on Linux/Android. arti's `static`
## feature already vendors a statically-linked OpenSSL, but we pin the vendored
## build explicitly for exactly those two targets so each is self-contained even
## if that transitive path changes (no system OpenSSL is assumed for the target).
## Windows (SChannel) and macOS (Security.framework)
## don't use OpenSSL at all, so they must NOT pull it — building openssl-src
## there is both pointless and fragile (the Windows MSVC runner's bash perl is
## missing modules its Configure needs).
@@ -78,7 +78,7 @@ public class BackgroundService extends Service {
if (Build.VERSION.SDK_INT > 25) {
startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
}
// Goblin's background job is the light Nostr-over-Nym payment
// Goblin's background job is the light Nostr-over-Tor payment
// listen (the "Listening for payments" status); the heavy
// integrated node is never STARTED from this notification --
// Goblin defaults to an external node, so the GRIM "Enable"
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonym"
connected_nym: "Über Tor verbunden"
nym_ready: "Tor bereit · Relays…"
connecting_nym: "Verbinde mit Tor…"
connected_tor: "Über Tor verbunden"
tor_ready: "Tor bereit · Relays…"
connecting_tor: "Verbinde mit Tor…"
cant_reach_node: "Node nicht erreichbar"
node_synced: "Node synchronisiert"
syncing: "Synchronisiere…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Wallet wechseln"
advanced: "Erweitert"
privacy: "Privatsphäre"
mixnet_routing: "Tor-Routing"
tor_routing: "Tor-Routing"
messages_lookups: "Nachrichten & Abfragen"
auto_accept: "Automatisch annehmen"
pairing: "Preiswährung"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
price_avatars: "Preis"
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
over_mixnet: "Über Tor"
over_tor: "Über Tor"
direct_connection: "Direkte Verbindung"
grin_node: "Grin-Node"
grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft."
@@ -855,8 +855,8 @@ goblin:
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
title: "Deine Zahlungsidentität"
key_being_made: "Schlüssel wird erstellt…"
connected_nym: "über Tor verbunden"
connecting_nym: "verbinde über Tor…"
connected_tor: "über Tor verbunden"
connecting_tor: "verbinde über Tor…"
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
restoring: "Deine Identitäten werden wiederhergestellt…"
clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonymous"
connected_nym: "Connected over Tor"
nym_ready: "Tor ready · relays…"
connecting_nym: "Connecting to Tor…"
connected_tor: "Connected over Tor"
tor_ready: "Tor ready · relays…"
connecting_tor: "Connecting to Tor…"
cant_reach_node: "Can't reach node"
node_synced: "Node synced"
syncing: "Syncing…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Switch wallet"
advanced: "Advanced"
privacy: "Privacy"
mixnet_routing: "Tor routing"
tor_routing: "Tor routing"
messages_lookups: "Messages & lookups"
auto_accept: "Auto-accept"
pairing: "Price currency"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
price_avatars: "Price"
price_avatars_blurb: "The live fiat rate shown next to amounts."
over_mixnet: "Over Tor"
over_tor: "Over Tor"
direct_connection: "Direct connection"
grin_node: "Grin node"
grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity."
@@ -855,8 +855,8 @@ goblin:
kicker: "STEP 3 OF 3 · IDENTITY"
title: "Your payment identity"
key_being_made: "key being made…"
connected_nym: "connected over Tor"
connecting_nym: "connecting over Tor…"
connected_tor: "connected over Tor"
connecting_tor: "connecting over Tor…"
fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds."
restoring: "Restoring your identities…"
clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anónimo"
connected_nym: "Conectado por Tor"
nym_ready: "Tor listo · relés…"
connecting_nym: "Conectando a Tor…"
connected_tor: "Conectado por Tor"
tor_ready: "Tor listo · relés…"
connecting_tor: "Conectando a Tor…"
cant_reach_node: "No se puede conectar con el nodo"
node_synced: "Nodo sincronizado"
syncing: "Sincronizando…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Cambiar de billetera"
advanced: "Avanzado"
privacy: "Privacidad"
mixnet_routing: "Enrutamiento por Tor"
tor_routing: "Enrutamiento por Tor"
messages_lookups: "Mensajes y consultas"
auto_accept: "Aceptación automática"
pairing: "Moneda de referencia"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "Búsquedas de nombres NIP-05 hacia y desde goblin.st."
price_avatars: "Precio"
price_avatars_blurb: "La tasa de cambio en vivo que se muestra junto a los montos."
over_mixnet: "Por Tor"
over_tor: "Por Tor"
direct_connection: "Conexión directa"
grin_node: "Nodo de Grin"
grin_node_blurb: "Sincronización de bloques y transmisión de tu transacción a la red. Estos son datos públicos de la cadena, iguales para todos, y no están vinculados a tu identidad."
@@ -855,8 +855,8 @@ goblin:
kicker: "PASO 3 DE 3 · IDENTIDAD"
title: "Tu identidad de pago"
key_being_made: "creando clave…"
connected_nym: "conectado por Tor"
connecting_nym: "conectando por Tor…"
connected_tor: "conectado por Tor"
connecting_tor: "conectando por Tor…"
fresh_key_blurb: "Una clave de pago que no forma parte de tu frase de recuperación — rótala cuando quieras para mantener la privacidad, sin tocar tus fondos."
restoring: "Restaurando tus identidades…"
clean_slate_blurb: "¿Quieres empezar de cero? Cambia a una clave totalmente nueva cuando quieras — el nuevo tú no está vinculado al anterior. Misma billetera, cara nueva."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonyme"
connected_nym: "Connecté via Tor"
nym_ready: "Tor prêt · relais…"
connecting_nym: "Connexion à Tor…"
connected_tor: "Connecté via Tor"
tor_ready: "Tor prêt · relais…"
connecting_tor: "Connexion à Tor…"
cant_reach_node: "Nœud injoignable"
node_synced: "Nœud synchronisé"
syncing: "Synchronisation…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Changer de portefeuille"
advanced: "Avancé"
privacy: "Confidentialité"
mixnet_routing: "Routage par Tor"
tor_routing: "Routage par Tor"
messages_lookups: "Messages et recherches"
auto_accept: "Acceptation auto"
pairing: "Devise des prix"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
price_avatars: "Prix"
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
over_mixnet: "Via Tor"
over_tor: "Via Tor"
direct_connection: "Connexion directe"
grin_node: "Nœud grin"
grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité."
@@ -855,8 +855,8 @@ goblin:
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
title: "Votre identité de paiement"
key_being_made: "clé en cours de création…"
connected_nym: "connecté via Tor"
connecting_nym: "connexion via Tor…"
connected_tor: "connecté via Tor"
connecting_tor: "connexion via Tor…"
fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds."
restoring: "Restauration de vos identités…"
clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "Tor経由で接続済み"
nym_ready: "Tor準備完了 · リレー…"
connecting_nym: "Torに接続中…"
connected_tor: "Tor経由で接続済み"
tor_ready: "Tor準備完了 · リレー…"
connecting_tor: "Torに接続中…"
cant_reach_node: "ノードに接続できません"
node_synced: "ノード同期済み"
syncing: "同期中…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "ウォレットを切り替え"
advanced: "詳細設定"
privacy: "プライバシー"
mixnet_routing: "Torルーティング"
tor_routing: "Torルーティング"
messages_lookups: "メッセージと検索"
auto_accept: "自動承認"
pairing: "価格通貨"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "goblin.stとの間で行われるNIP-05の名前検索です。"
price_avatars: "価格"
price_avatars_blurb: "金額の隣に表示されるリアルタイムの法定通貨レートです。"
over_mixnet: "Tor経由"
over_tor: "Tor経由"
direct_connection: "直接接続"
grin_node: "Grinノード"
grin_node_blurb: "ブロックの同期と、取引をネットワークにブロードキャストする処理です。これは誰にとっても同じ公開されたチェーンデータであり、あなたのアイデンティティとは結び付いていません。"
@@ -856,8 +856,8 @@ goblin:
kicker: "ステップ3/3 · アイデンティティ"
title: "あなたの支払いアイデンティティ"
key_being_made: "キーを作成中…"
connected_nym: "Tor経由で接続済み"
connecting_nym: "Tor経由で接続中…"
connected_tor: "Tor経由で接続済み"
connecting_tor: "Tor経由で接続中…"
fresh_key_blurb: "リカバリーフレーズの一部ではない支払い用のキーです — 資金に影響を与えることなく、いつでもローテーションしてプライバシーを保てます。"
restoring: "アイデンティティを復元しています…"
clean_slate_blurb: "まっさらな状態にしたいですか? いつでもまったく新しいキーに切り替えられます — 新しいあなたは古いあなたと結び付きません。同じウォレットで、新しい顔になります。"
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "익명"
connected_nym: "Tor로 연결됨"
nym_ready: "Tor 준비 완료 · 릴레이…"
connecting_nym: "Tor에 연결하는 중…"
connected_tor: "Tor로 연결됨"
tor_ready: "Tor 준비 완료 · 릴레이…"
connecting_tor: "Tor에 연결하는 중…"
cant_reach_node: "노드에 연결할 수 없음"
node_synced: "노드 동기화됨"
syncing: "동기화하는 중…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "지갑 전환"
advanced: "고급"
privacy: "프라이버시"
mixnet_routing: "Tor 라우팅"
tor_routing: "Tor 라우팅"
messages_lookups: "메시지 및 조회"
auto_accept: "자동 수락"
pairing: "기준 통화"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "goblin.st와 주고받는 NIP-05 이름 조회입니다."
price_avatars: "가격"
price_avatars_blurb: "금액 옆에 표시되는 실시간 환율입니다."
over_mixnet: "Tor 경유"
over_tor: "Tor 경유"
direct_connection: "직접 연결"
grin_node: "Grin 노드"
grin_node_blurb: "블록 동기화와 거래를 네트워크에 전파하는 과정입니다. 이는 모두에게 동일한 공개 체인 데이터이며 신원과 연결되지 않습니다."
@@ -855,8 +855,8 @@ goblin:
kicker: "3/3단계 · 신원"
title: "나의 결제 신원"
key_being_made: "키를 만드는 중…"
connected_nym: "Tor로 연결됨"
connecting_nym: "Tor로 연결하는 중…"
connected_tor: "Tor로 연결됨"
connecting_tor: "Tor로 연결하는 중…"
fresh_key_blurb: "복구 문구에 속하지 않는 결제 키입니다 — 자금은 그대로 둔 채 언제든 교체해 프라이버시를 지키세요."
restoring: "신원을 복원하는 중…"
clean_slate_blurb: "새 출발을 원하시나요? 언제든 완전히 새로운 키로 바꾸세요 — 새로운 나는 이전과 연결되지 않습니다. 같은 지갑, 새로운 얼굴입니다."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Аноним"
connected_nym: "Подключено через Tor"
nym_ready: "Tor готов · реле…"
connecting_nym: "Подключение к Tor…"
connected_tor: "Подключено через Tor"
tor_ready: "Tor готов · реле…"
connecting_tor: "Подключение к Tor…"
cant_reach_node: "Нет связи с узлом"
node_synced: "Узел синхронизирован"
syncing: "Синхронизация…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Сменить кошелёк"
advanced: "Дополнительно"
privacy: "Приватность"
mixnet_routing: "Маршрутизация через Tor"
tor_routing: "Маршрутизация через Tor"
messages_lookups: "Сообщения и поиск"
auto_accept: "Автоприём"
pairing: "Валюта цены"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
price_avatars: "Цена"
price_avatars_blurb: "Текущий курс рядом с суммами."
over_mixnet: "Через Tor"
over_tor: "Через Tor"
direct_connection: "Прямое соединение"
grin_node: "Узел Grin"
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
@@ -855,8 +855,8 @@ goblin:
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
title: "Ваша платёжная личность"
key_being_made: "ключ создаётся…"
connected_nym: "подключено через Tor"
connecting_nym: "подключение через Tor…"
connected_tor: "подключено через Tor"
connecting_tor: "подключение через Tor…"
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
restoring: "Восстановление ваших личностей…"
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "Anonim"
connected_nym: "Tor üzerinden bağlı"
nym_ready: "Tor hazır · relaylar…"
connecting_nym: "Tor'a bağlanılıyor…"
connected_tor: "Tor üzerinden bağlı"
tor_ready: "Tor hazır · relaylar…"
connecting_tor: "Tor'a bağlanılıyor…"
cant_reach_node: "Düğüme ulaşılamıyor"
node_synced: "Düğüm eşitlendi"
syncing: "Eşitleniyor…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "Cüzdan değiştir"
advanced: "Gelişmiş"
privacy: "Gizlilik"
mixnet_routing: "Tor yönlendirme"
tor_routing: "Tor yönlendirme"
messages_lookups: "Mesajlar ve aramalar"
auto_accept: "Otomatik kabul"
pairing: "Fiyat para birimi"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
price_avatars: "Fiyat"
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
over_mixnet: "Tor üzerinden"
over_tor: "Tor üzerinden"
direct_connection: "Doğrudan bağlantı"
grin_node: "Grin düğümü"
grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez."
@@ -855,8 +855,8 @@ goblin:
kicker: "ADIM 3 / 3 · KİMLİK"
title: "Ödeme kimliğin"
key_being_made: "anahtar oluşturuluyor…"
connected_nym: "Tor üzerinden bağlı"
connecting_nym: "Tor üzerinden bağlanılıyor…"
connected_tor: "Tor üzerinden bağlı"
connecting_tor: "Tor üzerinden bağlanılıyor…"
fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür."
restoring: "Kimliklerin geri yükleniyor…"
clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz."
+7 -7
View File
@@ -360,9 +360,9 @@ keyboard:
goblin:
home:
anonymous: "匿名"
connected_nym: "已通过 Tor 连接"
nym_ready: "Tor 就绪 · 连接中继…"
connecting_nym: "正在连接 Tor…"
connected_tor: "已通过 Tor 连接"
tor_ready: "Tor 就绪 · 连接中继…"
connecting_tor: "正在连接 Tor…"
cant_reach_node: "无法连接节点"
node_synced: "节点已同步"
syncing: "同步中…"
@@ -481,7 +481,7 @@ goblin:
switch_wallet: "切换钱包"
advanced: "高级"
privacy: "隐私"
mixnet_routing: "Tor 路由"
tor_routing: "Tor 路由"
messages_lookups: "消息和查询"
auto_accept: "自动接受"
pairing: "价格货币"
@@ -647,7 +647,7 @@ goblin:
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
price_avatars: "价格"
price_avatars_blurb: "金额旁显示的实时法币汇率。"
over_mixnet: "经由 Tor"
over_tor: "经由 Tor"
direct_connection: "直接连接"
grin_node: "Grin 节点"
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
@@ -855,8 +855,8 @@ goblin:
kicker: "步骤 3 / 3 · 身份"
title: "你的付款身份"
key_being_made: "正在生成密钥…"
connected_nym: "已通过 Tor 连接"
connecting_nym: "正在通过 Tor 连接…"
connected_tor: "已通过 Tor 连接"
connecting_tor: "正在通过 Tor 连接…"
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
restoring: "正在恢复你的身份…"
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
+18 -18
View File
@@ -1596,14 +1596,14 @@ impl GoblinWalletView {
.color(t.surface_text),
);
ui.label(
// Relay-gated: "Connected over Nym" only once a
// Relay-gated: "Connected over Tor" only once a
// relay is live on the current tunnel generation.
RichText::new(if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
t!("goblin.home.connected_tor")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
t!("goblin.home.tor_ready")
} else {
t!("goblin.home.connecting_nym")
t!("goblin.home.connecting_tor")
})
.font(FontId::new(11.0, fonts::regular()))
.color(t.surface_text_mute),
@@ -3454,23 +3454,23 @@ impl GoblinWalletView {
}
});
// Transport status in place of the redundant second npub line.
// "Connected over Nym" is RELAY-GATED (transport_ready): the
// "Connected over Tor" is RELAY-GATED (transport_ready): the
// tunnel being warm is not enough — a relay must actually carry
// our traffic on the current exit. Otherwise show the tunnel is
// up but relays are still connecting/reconnecting.
let mixnet = if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
let transport = if crate::tor::transport_ready() {
t!("goblin.home.connected_tor")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
t!("goblin.home.tor_ready")
} else {
t!("goblin.home.connecting_nym")
t!("goblin.home.connecting_tor")
};
ui.label(
RichText::new(mixnet)
RichText::new(transport)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
// nostr relay status — the slower step (a relay reached over Nym).
// nostr relay status — the slower step (a relay reached over Tor).
let nostr = if connected {
t!("goblin.settings.connected_nostr")
} else {
@@ -3710,13 +3710,13 @@ impl GoblinWalletView {
let mut open_privacy = false;
let mut open_adv_privacy = false;
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
// Messages, names, price and avatars ride the mixnet; the grin
// Messages, names, price and avatars ride Tor; the grin
// node connects directly. Normal dim value ink: the salmon
// privacy color doubled as the destructive-action color on
// this page, making a plain navigable row read as a warning.
if settings_row_nav(
ui,
&t!("goblin.settings.mixnet_routing"),
&t!("goblin.settings.tor_routing"),
&t!("goblin.settings.messages_lookups"),
) {
open_privacy = true;
@@ -4018,7 +4018,7 @@ impl GoblinWalletView {
});
}
/// Network-privacy breakdown: what rides the Nym mixnet versus what connects
/// Network-privacy breakdown: what rides Tor versus what connects
/// directly. Honest by design — no claim that node traffic is mixed, and no
/// toggle to route it (chain sync is heavy and not tied to your identity).
fn privacy_ui(&mut self, ui: &mut egui::Ui) {
@@ -4038,7 +4038,7 @@ impl GoblinWalletView {
.color(t.text_dim),
);
ui.add_space(16.0);
let mixnet = [
let transport = [
(
t!("goblin.privacy.payments"),
t!("goblin.privacy.payments_blurb"),
@@ -4052,8 +4052,8 @@ impl GoblinWalletView {
t!("goblin.privacy.price_avatars_blurb"),
),
];
settings_group(ui, &t!("goblin.privacy.over_mixnet"), |ui| {
for (title, blurb) in &mixnet {
settings_group(ui, &t!("goblin.privacy.over_tor"), |ui| {
for (title, blurb) in &transport {
privacy_line(ui, t.neg, title, blurb);
}
});
@@ -8018,7 +8018,7 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
}
/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag
/// the always-on mixnet routing in the privacy color.
/// the always-on Tor routing in the privacy color.
fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) {
let t = theme::tokens();
ui.horizontal(|ui| {
+3 -3
View File
@@ -995,12 +995,12 @@ impl OnboardingContent {
);
}
ui.label(
// Relay-gated readiness: "connected over Nym" only once a
// Relay-gated readiness: "connected over Tor" only once a
// relay is actually live, not merely when the tunnel is warm.
RichText::new(if crate::tor::transport_ready() {
t!("goblin.onboarding.identity.connected_nym")
t!("goblin.onboarding.identity.connected_tor")
} else {
t!("goblin.onboarding.identity.connecting_nym")
t!("goblin.onboarding.identity.connecting_tor")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
+1 -1
View File
@@ -800,7 +800,7 @@ pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never
// Truncate so a long value (e.g. "Encrypted nostr DM over Tor") never
// runs past the edge or collides with the label on a narrow screen.
ui.add(
egui::Label::new(
+5 -12
View File
@@ -38,12 +38,6 @@ mod http;
pub mod logger;
mod node;
pub mod nostr;
/// The old Nym-mixnet transport, DORMANT since the Tor swap. Retained on disk but
/// only compiled with `--features nym` (its nym-sdk deps link a different
/// libsqlite3-sys than arti and cannot coexist with Tor in one binary). Deletion
/// is a later phase.
#[cfg(feature = "nym")]
pub mod nym;
mod settings;
pub mod tor;
mod wallet;
@@ -117,11 +111,10 @@ where
/// Entry point to start ui with [`eframe`].
pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe::Result<()> {
// Pin rustls to the ring provider process-wide. Linking nym-sdk brings
// aws-lc-rs into the graph alongside our ring; with two providers present
// rustls 0.23 won't auto-select a default, and tokio-tungstenite/reqwest
// would panic on the first TLS handshake. nym uses its own explicit provider,
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
// Pin rustls to the ring provider process-wide so our relay/HTTP TLS
// (tokio-tungstenite, tokio-rustls) selects a crypto provider deterministically
// rather than relying on rustls 0.23's auto-detection. Idempotent (Err if
// already set).
let _ = rustls::crypto::ring::default_provider().install_default();
// Pre-warm the embedded Tor client FIRST, before i18n/node setup, so the Tor
// bootstrap (the long pole on cold start) overlaps everything else and
@@ -537,7 +530,7 @@ pub fn mark_frame() {
/// True when the GUI drew a frame within the last few seconds — i.e. the app is
/// foreground and visible. While backgrounded (no frames), returns false, so
/// periodic background work (the @name re-verify sweep) can pause and catch up
/// on resume instead of burning mixnet round-trips while nobody's looking.
/// on resume instead of burning Tor round-trips while nobody's looking.
pub fn app_foreground() -> bool {
let last = LAST_FRAME_AT.load(std::sync::atomic::Ordering::Relaxed);
last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS
+1 -1
View File
@@ -724,7 +724,7 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
// The keep-alive notification must reflect the real connection: on the
// external-node default the integrated node is deliberately off, so "Node
// is down" is wrong — the service's actual background job is the
// Nostr-over-Nym payment listen.
// Nostr-over-Tor payment listen.
let status_text = if Node::is_running() || Node::is_starting() {
Node::get_sync_status_text()
} else {
+19 -19
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-wallet nostr service: relay connections over the Nym mixnet,
//! Per-wallet nostr service: relay connections over Tor,
//! identity event publishing, the guarded ingest loop and the DM send path.
use grin_core::core::amount_to_hr_string;
@@ -95,10 +95,10 @@ const RESEND_WINDOW_SECS: i64 = 7 * 86_400;
/// a released or reassigned name stops being shown. Doubles as the freshness
/// gate in `resolve_contact_identity`. Tuned for release/name-change detection
/// freshness, not liveness — a name rarely changes, so 6h is ample and keeps the
/// mixnet re-verify traffic off the interactive path.
/// Tor re-verify traffic off the interactive path.
const NAME_REVERIFY_INTERVAL_SECS: i64 = 6 * 3600;
/// Cap on contacts re-verified per sweep, so a large contact list rolls through
/// instead of bursting dozens of simultaneous mixnet lookups at once.
/// instead of bursting dozens of simultaneous Tor lookups at once.
const NAME_REVERIFY_MAX_PER_TICK: usize = 8;
/// One held identity live in memory: its decrypted keys (for unwrapping incoming
@@ -137,7 +137,7 @@ pub struct NostrService {
client: RwLock<Option<Client>>,
/// Handle to the service's tokio runtime. One-shot fetches (e.g. profile
/// lookups) from worker threads MUST run here, not on a throwaway runtime:
/// the relay connections (incl. the custom Nym mixnet transport) are driven
/// the relay connections (all driven over Tor) are driven
/// by this runtime, and a foreign runtime can't reach them.
rt_handle: RwLock<Option<tokio::runtime::Handle>>,
/// Service thread started flag.
@@ -473,8 +473,8 @@ impl NostrService {
let client = self.client.read().clone()?;
let pk = PublicKey::from_hex(hex).ok()?;
let hints: Vec<String> = hints.to_vec();
// Run on the SERVICE runtime — the relay connections (and the custom Nym
// mixnet transport) live there. A throwaway current-thread runtime can't
// Run on the SERVICE runtime — the relay connections (all driven over Tor)
// live there. A throwaway current-thread runtime can't
// drive them, which is why bare-npub profile lookups silently returned
// nothing even though the relay serves the kind-0 fine.
let handle = self.rt_handle.read().clone()?;
@@ -1284,7 +1284,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
svc.npub(),
relays
);
// (No DNS prewarm here: unlike the old mixnet path, arti resolves relay and
// (No DNS prewarm here: arti resolves relay and
// HTTP hostnames internally as part of the circuit dial — there is no
// separate in-tunnel DoT round trip to warm. The node host was never on this
// path and still isn't — it never rides the private transport.)
@@ -1304,7 +1304,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
*w_client = Some(client.clone());
}
// Log when the first relay reaches Connected over the mixnet, measured from
// Log when the first relay reaches Connected over Tor, measured from
// the connect() call. Non-blocking; exits on first success.
{
let client_probe = client.clone();
@@ -1331,10 +1331,10 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// over, a relay DROP wouldn't flip the flag back for up to ~30s
// (until the post-catch-up re-check re-syncs it to reality) — the
// same-order staleness as the old pessimistic gap, just optimistic
// instead. The transport watchdog (nymproc) still tracks real exit
// instead. The relay-gated readiness signal still tracks real relay
// health independently of this UI flag.
svc_probe.connected.store(true, Ordering::Relaxed);
// FAST relay-live report: closes nymproc's relay-readiness
// FAST relay-live report: closes the relay-readiness
// window as soon as the exit is proven to carry relay traffic,
// independent of the up-to-30s catch-up fetch below (a slow
// catch-up must not get a good exit wrongly condemned).
@@ -1446,9 +1446,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// a relay is typically already up.
let connected = relays_connected(&client).await;
svc.connected.store(connected, Ordering::Relaxed);
// Feed the relay-gated readiness signal so "Connected over Nym" reflects an
// Feed the relay-gated readiness signal so "Connected over Tor" reflects an
// actual connected+subscribed relay on THIS tunnel generation, not merely a
// warm tunnel — and so nymproc's relay-readiness window closes successfully.
// warm tunnel — and so the relay-readiness window closes successfully.
if connected {
crate::tor::report_relay_live(dial_gen);
}
@@ -1457,8 +1457,8 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
// Poll connection state on a SHORT, INDEPENDENT interval. This used to live in
// the `select!` behind a `sleep(30s)` that restarted on every notification, so
// the flag could lag the real relay state by 30s+ (or, under steady event
// flow, never update) — that's the "stuck on Connecting…" the mixnet gets
// blamed for, even though a relay handshake over Nym takes ~2s. An `interval`
// flow, never update) — that's the "stuck on Connecting…" Tor gets
// blamed for, even though a relay handshake over Tor takes ~2s. An `interval`
// fires on its own schedule regardless of notifications; the heavier heartbeat
// work (persisting last-seen, TTL pruning) stays on a ~30s cadence.
let mut status_tick = tokio::time::interval(Duration::from_secs(2));
@@ -1513,7 +1513,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
let connected = relays_connected(&client).await;
svc.connected.store(connected, Ordering::Relaxed);
// Relay-gated readiness + exit-health feedback for THIS generation:
// a live relay closes/keeps-open nymproc's readiness window; all
// a live relay closes/keeps-open the readiness window; all
// relays down for too long condemns the exit and reselects.
if connected {
crate::tor::report_relay_live(dial_gen);
@@ -1531,8 +1531,8 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
}
// Re-validate cached @usernames so a released/reassigned name
// stops showing. Only the stalest few per sweep (capped) to bound
// mixnet lookups; each worker re-checks against the identity server.
// Skipped while the app is backgrounded — no point spending mixnet
// Tor lookups; each worker re-checks against the identity server.
// Skipped while the app is backgrounded — no point spending Tor
// round-trips when nobody's looking. We DON'T advance last_name_sweep
// in that case, so the very next foreground tick runs the sweep
// immediately to catch up on resume.
@@ -1597,7 +1597,7 @@ async fn connect_relays(client: &Client, urls: &[String]) {
let url = url.clone();
async move {
let _ = client.add_relay(&url).await;
// Short cap: a reachable relay connects in ~2-4s over the mixnet; we
// Short cap: a reachable relay connects in ~2-4s over Tor; we
// don't want one dead relay in the list to stall the whole send. Once
// connected it stays connected, so only the first send pays this.
let _ = client.try_connect_relay(&url, Duration::from_secs(6)).await;
@@ -1763,7 +1763,7 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
}
// Discovery fan-out off the caller's path: each indexer is gated by the
// lazy NIP-11 probe (over Nym) before use.
// lazy NIP-11 probe (over Tor) before use.
let client = client.clone();
tokio::spawn(async move {
let targets: Vec<String> = crate::nostr::pool::usable_discovery_relays()
+1 -1
View File
@@ -14,7 +14,7 @@
//! Nostr payment-messaging subsystem: contacts are nostr users, slatepacks
//! travel as NIP-17 private DMs (NIP-44 encrypted, NIP-59 gift-wrapped) over
//! relays reached through the in-process Nym mixnet client.
//! relays reached through the embedded Tor client.
mod types;
pub use types::*;
+23 -32
View File
@@ -13,9 +13,9 @@
// limitations under the License.
//! Relay candidate pool: a maintained list of vetted public relays fetched
//! from the project gist over the Nym mixnet, cached on disk, with a pinned
//! from the project gist over Tor, cached on disk, with a pinned
//! copy compiled in for first-run/offline. Pool relays are gated LAZILY: a
//! NIP-11 probe (also over Nym) runs only right before a relay is actually
//! NIP-11 probe (also over Tor) runs only right before a relay is actually
//! used — no background sweeps.
use lazy_static::lazy_static;
@@ -45,7 +45,7 @@ const CACHE_MAX_AGE_SECS: u64 = 7 * 86_400;
/// NIP-11 probe results are reused for this long (24 h, in memory).
const PROBE_TTL_SECS: i64 = 24 * 3600;
/// Per-probe cap: a dead relay must not stall the caller for the full mixnet
/// Per-probe cap: a dead relay must not stall the caller for the full Tor
/// HTTP timeout — a failed probe just skips the relay this time.
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
@@ -82,16 +82,11 @@ pub struct PoolRelay {
/// Last-vetted date; presence marks the entry as vetted.
#[serde(default)]
pub vetted: Option<String>,
/// This relay operator's CO-LOCATED Nym exit address, when they run one (the
/// bundled floonet-rs / floonet-strfry `exit = true` feature). It is a Nym
/// `Recipient` (`<client>.<enc>@<gateway>`) for a SCOPED MixnetStream proxy
/// that forwards ONLY to this relay — so the wallet can reach the relay over
/// the mixnet WITHOUT public DNS and WITHOUT depending on a public IPR exit
/// (the anchor; see [`crate::nym::nymproc`]). Absent → this relay is reached
/// the old way (public-IPR smolmix + in-tunnel DoT). Carried in the pinned
/// pool so the money-path default relay's exit bootstraps OFFLINE, before any
/// network — breaking the chicken-and-egg of learning it over the very path
/// it is meant to replace.
/// Reserved pool-schema slot for a per-relay co-located exit address the
/// operator may advertise. The current Tor build does NOT consume it — every
/// relay is reached over a Tor exit to its clearnet host — but the field is
/// kept so a pool document that carries one still parses and the lookup
/// helpers below stay available. Absent for every relay in the pinned pool.
#[serde(default)]
pub exit: Option<String>,
}
@@ -141,10 +136,9 @@ impl RelayPool {
.collect()
}
/// The operator's co-located Nym exit address for `url`, if the pool
/// advertises one (url compared modulo a trailing slash). `None` → reach the
/// relay over the public-IPR path as before. This is how the wallet learns
/// the anchor exit for its money-path relay (see [`PoolRelay::exit`]).
/// The operator's co-located exit address for `url`, if the pool advertises
/// one (url compared modulo a trailing slash). `None` → no advertised exit.
/// See [`PoolRelay::exit`] (unused by the current Tor build).
pub fn exit_for(&self, url: &str) -> Option<String> {
let want = url.trim_end_matches('/');
self.relays
@@ -154,10 +148,9 @@ impl RelayPool {
.filter(|e| !e.trim().is_empty())
}
/// Like [`Self::exit_for`], but keyed on the HOSTNAME the HTTP dial site
/// ([`crate::nym::request_once`]) knows only `host`, never the relay's ws
/// URL. HTTPS to a host whose relay advertises a co-located exit (its
/// NIP-11 probe, in practice) rides that exit too.
/// Like [`Self::exit_for`], but keyed on the HOSTNAME rather than the ws URL,
/// for callers that know only `host`. Returns the advertised exit for a host
/// whose relay carries one, if any.
pub fn exit_for_host(&self, host: &str) -> Option<String> {
self.relays
.iter()
@@ -171,10 +164,8 @@ impl RelayPool {
.filter(|e| !e.trim().is_empty())
}
/// Whether ANY relay in the pool advertises a co-located exit. The cold-start
/// sequencer ([`crate::nym::nymproc`]) reads this to decide whether to give
/// the scoped-exit client its bandwidth-grant head start before building the
/// public-IPR tunnel — no exit anywhere → no wait, unchanged behavior.
/// Whether ANY relay in the pool advertises a co-located exit. Unused by the
/// current Tor build; retained for pool documents that still carry the field.
pub fn has_exit(&self) -> bool {
self.relays
.iter()
@@ -196,9 +187,9 @@ pub fn load() -> RelayPool {
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
}
/// Refresh the disk cache from the gist — over the Nym mixnet, like all other
/// Refresh the disk cache from the gist — over Tor, like all other
/// HTTP — when it is absent or older than 7 days. At most one attempt per app
/// run; call only once the Nym tunnel is up.
/// run; call only once Tor is up.
pub async fn refresh_if_stale() {
static TRIED: AtomicBool = AtomicBool::new(false);
if TRIED.swap(true, Ordering::SeqCst) {
@@ -265,7 +256,7 @@ fn nip11_pass(doc: &serde_json::Value, min_len: u64) -> bool {
.unwrap_or(true)
}
/// Lazy per-use probe: fetch the relay's NIP-11 document (HTTP over Nym,
/// Lazy per-use probe: fetch the relay's NIP-11 document (HTTP over Tor,
/// `Accept: application/nostr+json`) and apply the gate. Results are cached
/// for 24 h; an unreachable or unparseable document fails, which just skips
/// the relay this time.
@@ -301,7 +292,7 @@ pub async fn probe(url: &str) -> bool {
/// The pool's "discovery" relays that pass the lazy NIP-11 gate right now.
pub async fn usable_discovery_relays() -> Vec<String> {
// Probe every candidate CONCURRENTLY (each is a NIP-11 HTTP round trip over
// the mixnet — sequentially this cost ~N × a full round trip). The PROBES
// Tor — sequentially this cost ~N × a full round trip). The PROBES
// cache is RwLock-safe under concurrent access. Zip the pass/fail results back
// to the urls and keep the passing ones in the original pool order.
let urls = load().discovery_relays();
@@ -375,10 +366,10 @@ mod tests {
#[test]
fn exit_field_is_optional_and_looked_up_by_url() {
// The pinned pool no longer carries any co-located Nym exit — every relay
// is reached over the Tor exit now — so has_exit() is false for it. The
// The pinned pool carries no co-located exit — every relay
// is reached over a Tor exit to its clearnet host — so has_exit() is false. The
// exit_for / exit_for_host LOOKUP logic below still works for a pool that
// DOES advertise one, and the (dormant) src/nym transport still reads it.
// DOES advertise one.
let pinned = RelayPool::parse(PINNED_POOL).unwrap();
assert!(!pinned.has_exit());
assert!(pinned.exit_for("wss://relay.floonet.dev").is_none());
-662
View File
@@ -1,662 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! DNS resolution THROUGH the mixnet, over DoT (DNS-over-TLS, RFC 7858).
//! `Tunnel::tcp_connect` takes a `SocketAddr`, so resolving the hostname is our
//! job (the old SOCKS5 network requester resolved at the exit for us) — and it
//! rides the tunnel so neither the query nor its answer ever touches the clear:
//! a clearnet lookup would leak exactly which relays/nodes Goblin contacts,
//! defeating the mixnet.
//!
//! WHY DoT (TCP+TLS), not the old UDP mix-dns: the previous path sent raw UDP
//! datagrams over the mixnet, and mixnet UDP LOSES packets — a lost datagram
//! stalled behind a multi-second timeout, and Phase-1 measurements showed
//! resolves taking ~10s (21 lost-datagram retries) which tipped relay connects
//! past the exit-condemnation grace and drove the 2-3 minute reselect loop. DoT
//! runs the DNS query over a TCP+TLS connection through the tunnel: TCP
//! RETRANSMITS, so there are no packet-loss stalls, and TLS ENCRYPTS the query
//! end to end, so not even the IPR exit can see (or forge) which host we asked
//! for. Reliable AND private AND authenticated — smolmix is a TCP tunnel and is
//! good at TCP. (The exit policy allows :853 — verified live by the
//! `probe_dns_ports` harness before shipping this; if a future exit blocks 853,
//! DoH on 443 is the drop-in fallback.)
//!
//! Wire codec: hickory-proto — already in the dependency graph via
//! nym-http-api-client, so no vendored encode/parse is needed. DoT framing is
//! the DNS message prefixed with its 2-byte big-endian length (RFC 1035 §4.2.2).
//! Answers land in a TTL-respecting in-memory cache and hosts are prewarmed at
//! startup, so a warm entry (not a fresh mixnet round trip) serves the common
//! case. IPv4-only, like the rest of the app (GRIM audit).
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::stream::{FuturesUnordered, StreamExt};
use hickory_proto::op::{Message, MessageType, Query, ResponseCode};
use hickory_proto::rr::{Name, RData, RecordType};
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use lazy_static::lazy_static;
use log::{debug, warn};
use parking_lot::RwLock;
use smolmix::Tunnel;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A DoT resolver: the IP:853 to dial through the tunnel and the SNI / cert name
/// its DoT endpoint presents (the query is validated against this hostname, so a
/// hostile exit that redirects the IP cannot MITM the lookup).
struct DotResolver {
addr: SocketAddr,
sni: &'static str,
}
/// DoT resolvers, RACED against each other (not primary/fallback) so a slow or
/// unlucky handshake to one never stalls behind it — whichever answers first
/// wins. Addressed BY IP (no bootstrap chicken-and-egg); the SNI is validated.
const DOT_RESOLVERS: [DotResolver; 2] = [
DotResolver {
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 853),
sni: "cloudflare-dns.com",
},
DotResolver {
addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 853),
sni: "dns.quad9.net",
},
];
/// A DoH resolver: the IP:443 to dial through the tunnel, its SNI/cert + Host
/// name, and the RFC 8484 query path. DoH is the FALLBACK for an exit whose
/// policy blocks DoT (:853) — 443 is guaranteed reachable (relays + HTTPS ride
/// it), so DNS never has to touch the clearnet.
struct DohResolver {
ip: SocketAddr,
sni: &'static str,
host: &'static str,
path: &'static str,
}
const DOH_RESOLVERS: [DohResolver; 2] = [
DohResolver {
ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
sni: "cloudflare-dns.com",
host: "cloudflare-dns.com",
path: "/dns-query",
},
DohResolver {
ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
sni: "dns.quad9.net",
host: "dns.quad9.net",
path: "/dns-query",
},
];
/// Which in-tunnel DNS transport a lookup uses. NEVER clearnet.
#[derive(Clone, Copy)]
enum DnsMode {
/// DoT — DNS-over-TLS on :853 (preferred; smallest overhead).
Dot,
/// DoH — DNS-over-HTTPS on :443 (fallback when an exit blocks :853).
Doh,
}
/// Sticky: set once an exit is found to block DoT (:853), so we stop paying the
/// DoT timeout on every subsequent lookup and go straight to DoH (:443). Both
/// stay inside the tunnel — this only picks which in-tunnel transport to use.
static PREFER_DOH: AtomicBool = AtomicBool::new(false);
/// Per-query answer wait. DoT includes a TCP + TLS handshake over the mixnet
/// (a few seconds of deliberate per-hop delay), so allow more headroom than the
/// UDP path did; a round that exceeds this is retried rather than waited out.
const DOT_QUERY_TIMEOUT: Duration = Duration::from_secs(8);
/// Quick race-both-resolvers rounds before giving up. DoT is TCP-reliable within
/// a round, so two rounds is plenty (the second only matters if a whole
/// connection was dropped).
const DOT_ROUNDS: usize = 2;
/// DoH per-query wait (TCP + TLS + one HTTP round trip over the mixnet) and its
/// round count. Same reliability as DoT (TCP), a touch more per-request overhead
/// (HTTP framing), so the timeout is a shade more generous.
const DOH_QUERY_TIMEOUT: Duration = Duration::from_secs(10);
const DOH_ROUNDS: usize = 2;
/// TTL floor/ceiling for the cache: don't hammer resolvers for zero-TTL
/// records, don't trust a stale record for more than an hour.
const TTL_FLOOR_SECS: u32 = 60;
const TTL_CEILING_SECS: u32 = 3600;
/// TTL floor for KNOWN/stable hosts (relays, the name authority, the price API,
/// the DoT/DoH resolvers) — the ones we prewarm. Their addresses change rarely,
/// so we keep them cached at least 15 min (up to the 60-min ceiling) instead of
/// re-resolving every minute. Combined with serve-stale (below) this means a
/// dial to one of these NEVER blocks on a fresh mixnet DoT round trip.
const KNOWN_TTL_FLOOR_SECS: u32 = 900;
lazy_static! {
/// host → (addresses, expiry).
static ref CACHE: RwLock<HashMap<String, (Vec<Ipv4Addr>, Instant)>> =
RwLock::new(HashMap::new());
/// Hosts we treat as known/stable (populated by [`prewarm`]). Known hosts get
/// the longer [`KNOWN_TTL_FLOOR_SECS`] floor AND serve-stale-while-revalidate.
static ref KNOWN: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
/// Hosts with a background revalidation in flight — single-flight guard so a
/// burst of dials to a stale known host spawns exactly one refresh.
static ref REFRESHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
}
/// Whether `host` is a known/stable host (has been prewarmed at least once).
fn is_known(host: &str) -> bool {
KNOWN.read().contains(host)
}
/// Resolve `host` to a socket address for `tcp_connect`, entirely over the
/// mixnet via DoT. IP-literal hosts skip DNS; cached answers are honored until
/// their (clamped) TTL lapses. Each round RACES both resolvers concurrently and
/// takes the first valid answer; a round with no answer is retried. Returns
/// `None` only after every round fails.
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
// IP literals (v4 or v6) need no lookup at all.
if let Ok(ip) = host.parse::<IpAddr>() {
return Some(SocketAddr::new(ip, port));
}
match cache_hit(host) {
// Fresh entry: serve it, no network at all.
Some(CacheHit::Fresh(ip)) => return Some(SocketAddr::new(IpAddr::V4(ip), port)),
// SERVE-STALE-WHILE-REVALIDATE for known/stable hosts: hand back the
// last-known address immediately (so the dial never blocks on a cold DoT
// round trip) and refresh it in the background. Unknown hosts fall
// through to a blocking resolve, preserving correctness.
Some(CacheHit::Stale(ip)) if is_known(host) => {
spawn_revalidate(tunnel, host);
return Some(SocketAddr::new(IpAddr::V4(ip), port));
}
_ => {}
}
resolve_cold(tunnel, host, port).await
}
/// The blocking DoT-then-DoH resolve, run when there is no usable cache entry.
/// Writes the cache on success.
async fn resolve_cold(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
// If a previous lookup already learned this exit blocks DoT, go straight to
// DoH — still entirely inside the tunnel.
if PREFER_DOH.load(Ordering::Acquire) {
return resolve_via(tunnel, host, port, DnsMode::Doh).await;
}
// DoT first; on total failure (exit likely blocks :853) fall back to DoH on
// :443 — which is guaranteed reachable through the exit. There is NEVER a
// clearnet fallback: both transports ride the mixnet.
if let Some(addr) = resolve_via(tunnel, host, port, DnsMode::Dot).await {
return Some(addr);
}
if !PREFER_DOH.swap(true, Ordering::AcqRel) {
warn!("dns: DoT (:853) unavailable through this exit; using DoH (:443) over the tunnel");
}
resolve_via(tunnel, host, port, DnsMode::Doh).await
}
/// Kick off a background refresh of a stale known host through the current
/// tunnel, at most one in flight per host.
fn spawn_revalidate(tunnel: &Tunnel, host: &str) {
let host = host.to_string();
// Single-flight: skip if a refresh for this host is already running.
if !REFRESHING.write().insert(host.clone()) {
return;
}
let tunnel = tunnel.clone();
tokio::spawn(async move {
// Port is irrelevant here — only the host-keyed cache is refreshed.
let _ = resolve_cold(&tunnel, &host, 0).await;
REFRESHING.write().remove(&host);
});
}
/// Run the round loop for one in-tunnel DNS transport, writing the cache on the
/// first valid answer. Shared by DoT / DoH.
async fn resolve_via(tunnel: &Tunnel, host: &str, port: u16, mode: DnsMode) -> Option<SocketAddr> {
let (proto, rounds) = match mode {
DnsMode::Dot => ("dot-dns", DOT_ROUNDS),
DnsMode::Doh => ("doh-dns", DOH_ROUNDS),
};
let start = Instant::now();
for round in 0..rounds {
let answer = match mode {
DnsMode::Dot => race_dot(tunnel, host).await,
DnsMode::Doh => race_doh(tunnel, host).await,
};
if let Some((resolver, ips, ttl)) = answer {
// Known/stable hosts get the longer floor so they stay cached 15-60
// min; everything else keeps the tight 60s..1h window.
let floor = if is_known(host) {
KNOWN_TTL_FLOOR_SECS
} else {
TTL_FLOOR_SECS
};
let ttl = ttl.clamp(floor, TTL_CEILING_SECS);
debug!(
"{proto}: resolved {host} -> {} in {}ms (via {resolver}, round {}/{rounds}, \
ttl {ttl}s, {} record(s))",
ips[0],
start.elapsed().as_millis(),
round + 1,
ips.len()
);
let expiry = Instant::now() + Duration::from_secs(ttl as u64);
CACHE
.write()
.insert(host.to_string(), (ips.clone(), expiry));
return Some(SocketAddr::new(IpAddr::V4(ips[0]), port));
}
debug!(
"{proto}: no answer for {host} in round {}/{rounds}, retrying",
round + 1
);
}
debug!(
"{proto}: resolution failed for {host} after {rounds} rounds ({}ms)",
start.elapsed().as_millis()
);
None
}
/// One DoT round: fire an A query at EVERY resolver concurrently and return the
/// first valid, non-empty answer (with the resolver address that produced it). A
/// resolver that errors or times out is simply outrun.
async fn race_dot(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec<Ipv4Addr>, u32)> {
let mut inflight = FuturesUnordered::new();
for resolver in &DOT_RESOLVERS {
inflight.push(async move {
let answer = tokio::time::timeout(DOT_QUERY_TIMEOUT, query_dot(tunnel, host, resolver))
.await
.ok()
.flatten();
(resolver.addr, answer)
});
}
while let Some((addr, answer)) = inflight.next().await {
if let Some((ips, ttl)) = answer
&& !ips.is_empty()
{
return Some((addr, ips, ttl));
}
}
None
}
/// One DoT A query/response over the tunnel against `resolver`: TCP connect
/// through the mixnet, TLS (rustls, webpki roots, SNI-validated), then the DNS
/// message framed with its 2-byte big-endian length, and the length-framed
/// response read back.
async fn query_dot(
tunnel: &Tunnel,
host: &str,
resolver: &DotResolver,
) -> Option<(Vec<Ipv4Addr>, u32)> {
let tcp = tunnel
.tcp_connect(resolver.addr)
.await
.map_err(|e| debug!("dot-dns: connect to {} failed: {e}", resolver.addr))
.ok()?;
let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?;
let mut tls = tokio_rustls::TlsConnector::from(super::tls_config())
.connect(server_name, tcp)
.await
.map_err(|e| debug!("dot-dns: tls handshake with {} failed: {e}", resolver.sni))
.ok()?;
let id = rand::random::<u16>();
let query = encode_query(id, host)?;
// RFC 7858 / RFC 1035 §4.2.2: 2-byte big-endian length prefix + message.
let mut framed = Vec::with_capacity(2 + query.len());
framed.extend_from_slice(&(query.len() as u16).to_be_bytes());
framed.extend_from_slice(&query);
tls.write_all(&framed)
.await
.map_err(|e| debug!("dot-dns: send to {} failed: {e}", resolver.sni))
.ok()?;
tls.flush().await.ok()?;
let mut len_buf = [0u8; 2];
tls.read_exact(&mut len_buf)
.await
.map_err(|e| debug!("dot-dns: recv len from {} failed: {e}", resolver.sni))
.ok()?;
let len = u16::from_be_bytes(len_buf) as usize;
if len == 0 {
return None;
}
let mut resp = vec![0u8; len];
tls.read_exact(&mut resp)
.await
.map_err(|e| debug!("dot-dns: recv body from {} failed: {e}", resolver.sni))
.ok()?;
parse_response(id, &resp)
}
/// One DoH round: race both resolvers and take the first valid, non-empty
/// answer (with the resolver IP that produced it).
async fn race_doh(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec<Ipv4Addr>, u32)> {
let mut inflight = FuturesUnordered::new();
for resolver in &DOH_RESOLVERS {
inflight.push(async move {
let answer = tokio::time::timeout(DOH_QUERY_TIMEOUT, query_doh(tunnel, host, resolver))
.await
.ok()
.flatten();
(resolver.ip, answer)
});
}
while let Some((ip, answer)) = inflight.next().await {
if let Some((ips, ttl)) = answer
&& !ips.is_empty()
{
return Some((ip, ips, ttl));
}
}
None
}
/// One DoH A query over the tunnel against `resolver` (RFC 8484): TCP connect
/// through the mixnet, TLS (SNI-validated), then an HTTP/1.1 POST to the
/// resolver's /dns-query with the wire-format DNS message as the body and
/// `application/dns-message` content type; the wire-format response body is
/// parsed the same way as DoT/UDP.
async fn query_doh(
tunnel: &Tunnel,
host: &str,
resolver: &DohResolver,
) -> Option<(Vec<Ipv4Addr>, u32)> {
let id = rand::random::<u16>();
let query = encode_query(id, host)?;
let tcp = tunnel
.tcp_connect(resolver.ip)
.await
.map_err(|e| debug!("doh-dns: connect to {} failed: {e}", resolver.ip))
.ok()?;
let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?;
let tls = tokio_rustls::TlsConnector::from(super::tls_config())
.connect(server_name, tcp)
.await
.map_err(|e| debug!("doh-dns: tls handshake with {} failed: {e}", resolver.sni))
.ok()?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.map_err(|e| debug!("doh-dns: http handshake with {} failed: {e}", resolver.host))
.ok()?;
tokio::spawn(async move {
let _ = conn.await;
});
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(resolver.path)
.header(hyper::header::HOST, resolver.host)
.header(hyper::header::CONTENT_TYPE, "application/dns-message")
.header(hyper::header::ACCEPT, "application/dns-message")
.header(hyper::header::USER_AGENT, "goblin-wallet")
.body(Full::new(Bytes::from(query)))
.ok()?;
let resp = sender
.send_request(req)
.await
.map_err(|e| debug!("doh-dns: request to {} failed: {e}", resolver.host))
.ok()?;
if resp.status() != hyper::StatusCode::OK {
debug!("doh-dns: {} returned {}", resolver.host, resp.status());
return None;
}
let body = resp.into_body().collect().await.ok()?.to_bytes();
parse_response(id, &body)
}
/// Resolve a batch of hosts concurrently to populate the cache, so the first
/// real use (relay dial, NIP-05 name claim, price fetch) hits a warm entry
/// instead of paying the mixnet DoT round trip inline. Best-effort; the port is
/// irrelevant here (only the host-keyed cache is filled) so a placeholder is used.
pub async fn prewarm(tunnel: &Tunnel, hosts: &[String]) {
// Mark these as known/stable so they get the long TTL floor and serve-stale.
{
let mut known = KNOWN.write();
for host in hosts {
known.insert(host.clone());
}
}
let mut inflight = FuturesUnordered::new();
for host in hosts {
inflight.push(resolve(tunnel, host, 0));
}
while inflight.next().await.is_some() {}
}
/// A cache lookup outcome for `host`: fresh (within TTL) or stale (expired but
/// still remembered, usable via serve-stale for known hosts).
enum CacheHit {
Fresh(Ipv4Addr),
Stale(Ipv4Addr),
}
/// Look up `host` in the cache, distinguishing fresh from stale entries. Returns
/// `None` only when the host has never been resolved.
fn cache_hit(host: &str) -> Option<CacheHit> {
let cache = CACHE.read();
let (ips, expiry) = cache.get(host)?;
let ip = ips.first().copied()?;
Some(if Instant::now() < *expiry {
CacheHit::Fresh(ip)
} else {
CacheHit::Stale(ip)
})
}
/// Stable public addresses the liveness probe RACES through the tunnel: a tunnel
/// is alive if it can reach ANY of them. Racing (not one fixed target) is why a
/// momentarily slow path to a single resolver no longer false-declares a healthy
/// exit DEAD — the same reason the DoT/DoH resolvers above are raced, not tried in
/// series. Both are anycast resolvers on :443 (never exit-policy-firewalled, since
/// relays + HTTPS already ride it) and effectively always-on.
const PROBE_ADDRS: [SocketAddr; 2] = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
];
/// Per-target connect wait for the PATIENT probe of an ESTABLISHED tunnel
/// (watchdog keepalive + condemnation). A mixnet TCP handshake is a few seconds,
/// and an exit already in service must NEVER be thrown away over a momentary load
/// spike, so this stays deliberately generous at 8s — the pre-existing budget.
/// (The just-built-tunnel GATE uses the tighter [`FRESH_PROBE_TIMEOUT`]; the two
/// budgets are asymmetric on purpose — see [`probe_fresh`].)
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// Probe rounds before an ESTABLISHED tunnel is declared dead. A single lost
/// mixnet packet mid-handshake should not condemn a whole tunnel, so an all-miss
/// round is retried once (mirrors the DoT/DoH round loop). Only a tunnel that
/// reaches NEITHER stable target across BOTH rounds is DEAD — this is what stops a
/// healthy-but-unlucky tunnel from being thrown away and reselected forever.
const PROBE_ROUNDS: usize = 2;
/// Per-target connect wait for the FAST GATE of a FRESH, just-built tunnel (before
/// it is published). Tighter than the established [`PROBE_TIMEOUT`] because a
/// healthy fresh probe connects FAST: across 15 cold-start trials the SUCCESSFUL
/// exit probe completed in 4651197ms (median 774ms), so 5s is >4x the measured
/// worst case — ample headroom to never false-condemn a slow-but-healthy fresh
/// exit (the build130 single-shot regression we must not reintroduce). The point
/// of the asymmetry: a genuinely DEAD fresh exit (accepts the IPR handshake but
/// delivers nothing) is now condemned in ~10s instead of the ~32s the doubled
/// patient probe cost on this path, which dominated the cold-start latency tail.
const FRESH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Probe rounds for the fresh-tunnel gate. SAME 2-round retry as the established
/// path: a single lost mixnet datagram mid-handshake still gets a second chance
/// before the tunnel is condemned — the transient-loss protection the original
/// trigger-happy single-shot probe lacked. Worst-case fresh-gate budget is
/// therefore FRESH_PROBE_ROUNDS × FRESH_PROBE_TIMEOUT = 10s (vs the old ~32s).
const FRESH_PROBE_ROUNDS: usize = 2;
/// PATIENT end-to-end liveness probe of an ESTABLISHED tunnel, on the generous
/// [`PROBE_TIMEOUT`]/[`PROBE_ROUNDS`] budget (worst case ~16s). Used by the
/// watchdog keepalive and the condemnation exit-DNS check — an exit already in
/// service must never be false-condemned over a momentary hiccup. The FRESH,
/// just-built-tunnel gate uses [`probe_fresh`] instead (a tighter budget). See
/// [`probe_with_budget`] for the shared mechanics.
pub async fn probe(tunnel: &Tunnel) -> bool {
probe_with_budget(tunnel, PROBE_TIMEOUT, PROBE_ROUNDS).await
}
/// FAST end-to-end liveness GATE for a FRESH, just-built tunnel, run BEFORE it is
/// published, on the tighter [`FRESH_PROBE_TIMEOUT`]/[`FRESH_PROBE_ROUNDS`] budget
/// (worst case ~10s vs the ~32s the doubled patient probe cost on this path). A
/// fresh exit that accepts the IPR handshake yet delivers nothing (a DEAD EXIT) is
/// condemned quickly instead of dominating the cold-start tail — WITHOUT
/// reintroducing the false-condemn of a healthy exit (build130): the 5s per-target
/// timeout is >4x the measured worst-case healthy fresh probe (1197ms) and the
/// 2-round retry still absorbs a single lost datagram. See [`probe_with_budget`].
pub async fn probe_fresh(tunnel: &Tunnel) -> bool {
probe_with_budget(tunnel, FRESH_PROBE_TIMEOUT, FRESH_PROBE_ROUNDS).await
}
/// Shared raced-targets liveness probe on an explicit per-target `timeout` /
/// `rounds` budget: try to open a TCP connection THROUGH the tunnel to any of a few
/// stable public addresses (raced, retried a round) and drop the winner
/// immediately. Because TCP over the mixnet RETRANSMITS, a single lost datagram
/// does not spuriously fail a healthy exit; racing several targets over multiple
/// rounds additionally absorbs a momentarily slow single path — together they stop
/// the false-DEAD reselect churn the old single-target probe caused. Proves the
/// full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session from
/// idling out. Callers pick the budget: [`probe`] (patient, established tunnels)
/// vs [`probe_fresh`] (fast, fresh-tunnel gate) — the racing + multi-round
/// structure is identical, only the timeout/rounds differ.
async fn probe_with_budget(tunnel: &Tunnel, timeout: Duration, rounds: usize) -> bool {
for round in 0..rounds {
let mut inflight = FuturesUnordered::new();
for addr in PROBE_ADDRS {
inflight.push(async move {
matches!(
tokio::time::timeout(timeout, tunnel.tcp_connect(addr)).await,
Ok(Ok(_))
)
});
}
while let Some(reached) = inflight.next().await {
if reached {
return true;
}
}
debug!(
"probe: no stable target reachable through tunnel (round {}/{rounds})",
round + 1
);
}
debug!("probe: tunnel failed liveness — reached no stable target in {rounds} rounds");
false
}
/// Encode a recursive A query for `host` with transaction id `id`.
fn encode_query(id: u16, host: &str) -> Option<Vec<u8>> {
let name = Name::from_ascii(host).ok()?;
let mut msg = Message::query();
msg.metadata.id = id;
msg.metadata.recursion_desired = true;
msg.add_query(Query::query(name, RecordType::A));
msg.to_vec().ok()
}
/// Parse a response to transaction `id`: all A records in the answer section
/// plus the smallest TTL among them. `None` on id mismatch, non-response,
/// error rcode or no A records (CNAMEs and other types are skipped).
fn parse_response(id: u16, raw: &[u8]) -> Option<(Vec<Ipv4Addr>, u32)> {
let msg = Message::from_vec(raw).ok()?;
if msg.metadata.id != id
|| msg.metadata.message_type != MessageType::Response
|| msg.metadata.response_code != ResponseCode::NoError
{
return None;
}
let mut ips = Vec::new();
let mut ttl = u32::MAX;
for record in &msg.answers {
if let RData::A(a) = record.data {
ips.push(a.0);
ttl = ttl.min(record.ttl);
}
}
if ips.is_empty() {
None
} else {
Some((ips, ttl))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Query for `example.com` A/IN, id 0x1234, RD set — the canonical fixture
/// (same bytes smolmix's own docs use).
const QUERY_FIXTURE: &[u8] = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
/// Response to `QUERY_FIXTURE`: flags 0x8180 (QR, RD, RA, NOERROR), one
/// question, two answers — a CNAME (ttl 3600, rdata = compression pointer
/// back to the qname) that must be skipped, then an A record for
/// 93.184.216.34 with ttl 300.
const RESPONSE_FIXTURE: &[u8] = b"\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01\
\xc0\x0c\x00\x05\x00\x01\x00\x00\x0e\x10\x00\x02\xc0\x0c\
\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
#[test]
fn encode_query_matches_fixture() {
let bytes = encode_query(0x1234, "example.com").unwrap();
assert_eq!(bytes, QUERY_FIXTURE);
}
#[test]
fn parse_response_extracts_a_records_and_min_ttl() {
let (ips, ttl) = parse_response(0x1234, RESPONSE_FIXTURE).unwrap();
assert_eq!(ips, vec![Ipv4Addr::new(93, 184, 216, 34)]);
// The CNAME's larger ttl (3600) must not win: only A records count.
assert_eq!(ttl, 300);
}
#[test]
fn parse_response_rejects_wrong_id() {
assert!(parse_response(0x5678, RESPONSE_FIXTURE).is_none());
}
#[test]
fn parse_response_rejects_query_and_garbage() {
// A query (QR=0) is not an answer.
assert!(parse_response(0x1234, QUERY_FIXTURE).is_none());
// Truncated/garbage input parses to nothing.
assert!(parse_response(0x1234, &RESPONSE_FIXTURE[..7]).is_none());
assert!(parse_response(0x1234, b"\x00").is_none());
}
#[test]
fn parse_response_rejects_error_rcode() {
// Same fixture with rcode NXDOMAIN (flags 0x8183) and no answers.
let nx: &[u8] = b"\x12\x34\x81\x83\x00\x01\x00\x00\x00\x00\x00\x00\
\x07example\x03com\x00\x00\x01\x00\x01";
assert!(parse_response(0x1234, nx).is_none());
}
#[test]
fn ttl_clamp_bounds() {
assert_eq!(5u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 60);
assert_eq!(999_999u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 3600);
assert_eq!(300u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 300);
}
}
-411
View File
@@ -1,411 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an
//! auto-selected public IPR exit, so neither the payload nor the
//! destination-in-flight ever touches the clearnet. Hostnames resolve through
//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes
//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located
//! scoped exit in the pool is instead dialed over a MixnetStream straight to
//! that exit ([`streamexit`]) — no DNS and no public IPR at all — falling
//! back to the tunnel on any failure. The mixnet breaks the sender↔receiver
//! timing correlation that Mimblewimble's interactive slate exchange
//! otherwise leaks at the network layer.
//!
//! DNS reliability was the one weak spot: the original mix-dns sent UDP over the
//! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second
//! timeouts (~10s measured), tipping relay connects past the exit-condemnation
//! grace and driving a 2-3 minute reselect loop. Build 98 moves DNS to DoT
//! (TCP+TLS through the tunnel): TCP retransmits (no packet-loss stalls) and TLS
//! encrypts the query from the exit — reliable AND private.
pub mod dns;
pub mod nymproc;
pub mod streamexit;
pub mod transport;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::TokioIo;
use log::{debug, warn};
use tokio::io::{AsyncRead, AsyncWrite};
pub use nymproc::{
condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer,
transport_ready, tunnel_generation, warm_up,
};
pub use transport::NymWebSocketTransport;
/// How long a single HTTP exchange (one redirect hop) may take end to end.
/// The mixnet adds deliberate per-hop delay; allow generous time.
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the shared tunnel before giving up on a request.
const TUNNEL_WAIT: Duration = Duration::from_secs(30);
/// Redirect hops to follow before giving up (matches the old client, which
/// followed redirects transparently).
const MAX_REDIRECTS: usize = 5;
/// An HTTP request routed over the Nym mixnet: resolve the host over the tunnel
/// (DoT — see [`dns`]), then `tcp_connect` to that IP through the tunnel, then
/// rustls (webpki roots) for https, then HTTP/1.1. Follows redirects. Returns
/// `(status, body)`.
pub async fn http_request_bytes(
method: &str,
url: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
let tunnel = nymproc::wait_for_tunnel(TUNNEL_WAIT).await?;
let mut url = url::Url::parse(&url).ok()?;
let mut method = method.to_uppercase();
let mut body = body;
for _ in 0..=MAX_REDIRECTS {
let (status, resp_body, location) = tokio::time::timeout(
HTTP_TIMEOUT,
request_once(&tunnel, &method, &url, body.clone(), &headers),
)
.await
.map_err(|_| warn!("nym http: request to {} timed out", redacted(&url)))
.ok()??;
match location {
Some(loc) => {
url = url.join(&loc).ok()?;
// Like the old client: 303 (and legacy 301/302) turn into a
// bodiless GET; 307/308 replay the method + body.
if matches!(status, 301..=303) {
method = "GET".to_string();
body = None;
}
debug!(
"nym http: following {status} redirect to {}",
redacted(&url)
);
}
None => return Some((status, resp_body)),
}
}
warn!("nym http: too many redirects for {}", redacted(&url));
None
}
/// String-bodied convenience wrapper around [`http_request_bytes`].
pub async fn http_request(
method: &str,
url: String,
body: Option<String>,
headers: Vec<(String, String)>,
) -> Option<String> {
http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers)
.await
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
}
/// Host without path/query, for logs (never log full URLs).
fn redacted(url: &url::Url) -> String {
url.host_str().unwrap_or("<no-host>").to_string()
}
/// How long a pooled keep-alive connection may sit idle before we discard it
/// rather than reuse a possibly half-dead handle (hyper's `is_closed()` catches
/// cleanly-closed ones; this bounds the silent-death window).
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Pool key: a live HTTP/1.1 keep-alive connection is reusable only for the same
/// host, port and scheme.
#[derive(Clone, PartialEq, Eq, Hash)]
struct ConnKey {
host: String,
port: u16,
https: bool,
}
/// A pooled hyper request handle. The body type matches [`request_once`]'s.
type HttpSender = hyper::client::conn::http1::SendRequest<Full<Bytes>>;
struct Pooled {
sender: HttpSender,
idle_since: Instant,
}
lazy_static::lazy_static! {
/// Idle keep-alive connections, keyed by (host, port, https). A sender is
/// REMOVED while in use and reinserted when the exchange finishes, so the map
/// only ever holds idle handles and the lock is never held across an await.
static ref CONN_POOL: Mutex<HashMap<ConnKey, Pooled>> = Mutex::new(HashMap::new());
}
/// Take a live, non-idle-expired pooled sender for `key`, if one exists. A
/// closed or stale handle is dropped (tearing down its connection) and `None`
/// returned so the caller builds a fresh one.
fn take_pooled(key: &ConnKey) -> Option<HttpSender> {
let mut pool = CONN_POOL.lock().ok()?;
let pooled = pool.remove(key)?;
if pooled.sender.is_closed() || pooled.idle_since.elapsed() >= POOL_IDLE_TIMEOUT {
return None;
}
Some(pooled.sender)
}
/// Return a still-live sender to the pool for the next request to reuse.
fn store_pooled(key: ConnKey, sender: HttpSender) {
if sender.is_closed() {
return;
}
if let Ok(mut pool) = CONN_POOL.lock() {
pool.insert(
key,
Pooled {
sender,
idle_since: Instant::now(),
},
);
}
}
/// Send one request/response exchange on `sender`. On success returns the parsed
/// `(status, body, location)` AND the sender (drained and ready for the next
/// request, so the caller can pool it). `None` if the connection failed.
async fn exchange(
mut sender: HttpSender,
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
host: &str,
https: bool,
port: u16,
) -> Option<((u16, Vec<u8>, Option<String>), HttpSender)> {
let m = hyper::Method::from_bytes(method.as_bytes()).ok()?;
let path = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
let host_header = if (https && port == 443) || (!https && port == 80) {
host.to_string()
} else {
format!("{host}:{port}")
};
let mut req = hyper::Request::builder()
.method(m)
.uri(path)
.header(hyper::header::HOST, host_header)
.header(hyper::header::USER_AGENT, "goblin-wallet");
for (k, v) in headers {
req = req.header(k, v);
}
let req = req
.body(Full::new(Bytes::from(body.unwrap_or_default())))
.ok()?;
let resp = sender
.send_request(req)
.await
.map_err(|e| warn!("nym http: request to {host} failed: {e}"))
.ok()?;
let status = resp.status().as_u16();
let location = if resp.status().is_redirection() {
resp.headers()
.get(hyper::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec();
Some(((status, bytes, location), sender))
}
/// A single HTTP/1.1 exchange over the tunnel. Returns the status, the
/// collected body and, for 3xx responses, the `Location` target.
async fn request_once(
tunnel: &smolmix::Tunnel,
method: &str,
url: &url::Url,
body: Option<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
let host = url.host_str()?.to_string();
let https = url.scheme() == "https";
let port = url.port().unwrap_or(if https { 443 } else { 80 });
let key = ConnKey {
host: host.clone(),
port,
https,
};
// KEEP-ALIVE FAST PATH: reuse a pooled connection for this (host, port,
// https) when one is live, skipping a fresh mixnet TCP + TLS + HTTP handshake.
// This is what makes the many small reads (price, contact-name resolution)
// fast. Only steady-state tunnel connections are pooled (see below); the
// cold-start scoped-exit fallback is one-shot.
if let Some(sender) = take_pooled(&key) {
if let Some((resp, sender)) = exchange(
sender,
method,
url,
body.clone(),
headers,
&host,
https,
port,
)
.await
{
store_pooled(key, sender);
return Some(resp);
}
// Pooled connection died mid-exchange: fall through and build a fresh one.
}
// TUNNEL-FIRST for HTTP. NIP-11/HTTP is PUBLIC data (relay docs, price, name
// authority) and both egresses are mixnet-private, so in steady state we ride
// the already-warm tunnel — opening a fresh MixnetStream + settle to a scoped
// exit PER request was pure latency here. Only when the tunnel isn't up yet
// (`!is_ready()`) do we fall to a host's co-located scoped exit to avoid a cold
// wait; failure there just falls through to the tunnel path below. transport.rs
// (relay websockets) stays exit-first and is untouched — this is the HTTP path
// only.
let exit_io = if https && !nymproc::is_ready() {
match crate::nostr::pool::load().exit_for_host(&host) {
Some(exit) => exit_connect(&host, &exit).await,
None => None,
}
} else {
None
};
// The one-shot scoped-exit fallback is NOT pooled — it's a cold-start bridge
// while the tunnel comes up. Only tunnel-borne connections go in the pool.
let poolable = exit_io.is_none();
let io: Box<dyn Stream> = match exit_io {
Some(io) => io,
None => {
// Resolve the host over the tunnel (DoT — see dns), then dial that
// IP through the same tunnel so nothing (lookup or body) touches
// the clear.
let addr = dns::resolve(tunnel, &host, port).await?;
let tcp = match tunnel.tcp_connect(addr).await {
Ok(s) => s,
Err(e) => {
warn!("nym http: connect to {host} failed: {e}");
return None;
}
};
if https {
match tls_connect(&host, tcp).await {
Some(tls) => Box::new(tls),
None => return None,
}
} else {
Box::new(tcp)
}
}
};
let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.map_err(|e| warn!("nym http: handshake with {host} failed: {e}"))
.ok()?;
// Drive the connection in the background. It stays alive for keep-alive reuse
// as long as the pooled sender is held; it ends once the sender is dropped
// (evicted from the pool) or the peer closes the connection.
tokio::spawn(async move {
let _ = conn.await;
});
let (resp, sender) = exchange(sender, method, url, body, headers, &host, https, port).await?;
if poolable {
store_pooled(key, sender);
}
Some(resp)
}
/// Try the scoped-exit egress for an HTTPS `host`: a MixnetStream to the
/// relay operator's exit ([`streamexit`]), then the SAME hostname-validated
/// [`tls_connect`] as the tunnel path — SNI = `host`, so the exit sees only
/// ciphertext. `None` (logged) on ANY failure, and the whole attempt is
/// bounded by the shared bootstrap cap — a dead exit costs seconds inside the
/// caller's [`HTTP_TIMEOUT`] budget, leaving room to fall back to the tunnel.
async fn exit_connect(host: &str, exit: &str) -> Option<Box<dyn Stream>> {
let cap = nymproc::BOOTSTRAP_TIMEOUT;
let dial = async {
let stream = streamexit::open_stream(exit, cap)
.await
.map_err(|e| warn!("nym http: scoped exit for {host} unavailable: {e}"))
.ok()?;
let tls = tls_connect(host, stream).await?;
debug!("nym http: {host} riding its operator's scoped exit");
Some(Box::new(tls) as Box<dyn Stream>)
};
match tokio::time::timeout(cap, dial).await {
Ok(io) => io,
Err(_) => {
warn!(
"nym http: scoped exit dial for {host} exceeded {}s; falling back to the tunnel",
cap.as_secs()
);
None
}
}
}
/// Everything hyper (and the TLS/websocket layers) needs from a mixnet-carried
/// stream, boxable for the plain http / https / scoped-exit split. Shared with
/// the scoped-exit egress ([`streamexit::BoxedStream`]).
pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Stream for T {}
lazy_static::lazy_static! {
/// Shared rustls client config (webpki roots; ring provider installed at
/// startup — the Build 65/66 rule), reused by every in-tunnel TLS handshake
/// (HTTPS here, DoT/DoH in [`dns`]).
static ref TLS_CONFIG: Arc<rustls::ClientConfig> = {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
};
}
/// The shared rustls client config (cheap `Arc` bump).
pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
TLS_CONFIG.clone()
}
/// TLS-wrap a tunneled TCP stream with rustls + webpki roots (never the
/// platform verifier — it panics on Android outside a full app context). The
/// certificate is validated against the HOSTNAME even though the dial went to a
/// DoT-resolved IP, so a lying resolver or a hostile exit cannot MITM.
async fn tls_connect<S>(host: &str, stream: S) -> Option<tokio_rustls::client::TlsStream<S>>
where
S: AsyncRead + AsyncWrite + Send + Unpin,
{
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?;
tokio_rustls::TlsConnector::from(tls_config())
.connect(server_name, stream)
.await
.map_err(|e| warn!("nym http: tls handshake with {host} failed: {e}"))
.ok()
}
-1073
View File
File diff suppressed because it is too large Load Diff
-465
View File
@@ -1,465 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool
//! advertises a relay operator's CO-LOCATED Nym exit
//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit
//! directly over the mixnet with a [`MixnetStream`]; the exit pipes the bytes
//! to its ONE configured relay. No public DNS, no public IPR — the two flaky
//! dependencies of the fallback path are gone from the money path. The exit is
//! scoped (it forwards nowhere else), so the wallet writes nothing but the TLS
//! ClientHello: the dial sites run the SAME hostname-validated TLS (SNI = the
//! relay host) + websocket/HTTP wrap over this stream as over the smolmix
//! tunnel's TCP stream, and the exit sees only ciphertext.
//!
//! ANCHOR + FALLBACK, never pin-only: every failure here (bad address, client
//! bootstrap, stream open, timeout) just returns `Err`, and the dial sites
//! ([`super::transport`], [`super::request_once`]) fall through to the
//! public-IPR tunnel ([`super::nymproc`]) — losing the operator's exit never
//! locks the wallet out. Server side: the bundled `floonet-mixexit` binary
//! (design in ~/.claude/plans/floonet-nym-exit.md).
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use log::{info, warn};
use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient};
use tokio::sync::Mutex;
/// The boxed transport stream handed to the TLS/websocket layer — the same
/// seat the smolmix tunnel's TCP stream occupies on the fallback path
/// (everything that layer needs is the shared [`super::Stream`] trait).
pub(crate) type BoxedStream = Box<dyn super::Stream>;
/// After the Open is SENT, wait this long before handing back a writable
/// stream. `open_stream` returns once the Open message leaves the client, NOT
/// once the exit has `accept()`ed and wired its inbound half. But the caller
/// speaks first (TLS ClientHello over a raw-pipe exit), so a write landing in
/// that gap is dropped and the handshake stalls into a fallback. One mixnet
/// round of slack lets the exit be listening before the first byte.
/// ponytail: fixed settle (measured: 0s always stalls, 3s is reliable). The
/// exit pipes raw bytes to its relay, so it can't inject an accept-ack for the
/// client to wait on; if mixnet jitter ever makes 3s flaky, raise it.
const STREAM_SETTLE: Duration = Duration::from_secs(3);
/// Process-lifetime mixnet client for the scoped-exit egress, lazily connected
/// on first use (mirrors the tunnel singleton in [`super::nymproc`]).
/// Ephemeral in-memory identity, like the tunnel — a fresh mixnet identity per
/// run. Behind an async mutex because `open_stream` needs `&mut`; a dead
/// client (cancelled shutdown token or a failed open) is dropped so the next
/// dial reconnects fresh.
static CLIENT: Mutex<Option<MixnetClient>> = Mutex::const_new(None);
/// True once the exit's `MixnetClient` has bootstrapped and is usable. The
/// cold-start sequencer in [`super::nymproc`] reads this to hold the public-IPR
/// tunnel's bootstrap until the exit client has its Nym bandwidth grant (see the
/// NOTE below), so the money path connects in seconds instead of a minute.
static READY: AtomicBool = AtomicBool::new(false);
/// Whether the scoped-exit mixnet client is bootstrapped and usable.
pub fn is_ready() -> bool {
READY.load(Ordering::Relaxed)
}
// NOTE ON COLD-START LATENCY (and its fix): the exit rides a SECOND ephemeral
// MixnetClient (separate from the smolmix tunnel). When BOTH clients bootstrap
// at once on a cold start they serialize on Nym free-tier bandwidth grants — so
// whichever dials second waits ~a minute for its grant. The money path must not
// be the loser of that race. Fix (see nymproc's cold-start sequencer): the exit
// client is allowed to grab its grant FIRST, and the tunnel's bootstrap waits a
// bounded head-start for `is_ready()` before it competes — so only ONE client
// bootstraps at a time and the money-path relay connects in seconds. The tunnel
// (fallback / HTTP / discovery, all non-blocking) comes up right after. A
// startup pre-warm of BOTH in parallel does NOT help (measured) — sequencing,
// not parallelism, is what removes the stall. Sharing ONE client for tunnel +
// exit would remove the second grant entirely but couples the robust exit to
// the tunnel's per-reselect client rebuild; deferred as a future upgrade.
/// Open a scoped MixnetStream to `exit` — a pool-advertised Nym address
/// (`<client>.<enc>@<gateway>`) of a relay operator's co-located exit. The
/// whole dial (client bootstrap when cold + stream open) is capped at
/// `min(timeout, BOOTSTRAP_TIMEOUT)` so a stuck bootstrap fails FAST into the
/// caller's public-IPR fallback. NOTE: `open_stream` is fire-and-forget on the
/// mixnet — a DEAD exit still hands back a stream, and its death surfaces at
/// the caller's (timeout-bounded) TLS handshake, which doubles as the
/// liveness probe: no ServerHello through the pipe → fall back.
pub(crate) async fn open_stream(exit: &str, timeout: Duration) -> Result<BoxedStream, String> {
let recipient: Recipient = exit
.trim()
.parse()
.map_err(|e| format!("invalid exit address: {e}"))?;
let cap = timeout.min(super::nymproc::BOOTSTRAP_TIMEOUT);
let stream = match tokio::time::timeout(cap, open(recipient)).await {
Ok(result) => result?,
Err(_) => return Err(format!("exit dial exceeded {}s", cap.as_secs())),
};
// Let the exit accept() + wire its inbound half before the caller writes.
tokio::time::sleep(STREAM_SETTLE).await;
Ok(Box::new(stream) as BoxedStream)
}
/// Bootstrap the shared client ahead of the first dial. The cold-start
/// sequencer in [`super::nymproc`] spawns this when the pool advertises an
/// exit: without it the client would only bootstrap on the first relay dial —
/// which happens after a wallet opens, so the tunnel's bounded head-start wait
/// would just expire and both clients would race for their bandwidth grants
/// anyway. Failure is non-fatal (the first real dial retries the bootstrap).
pub async fn prewarm() {
if let Err(e) = ensure_client().await {
warn!("nym: streamexit prewarm failed: {e}");
}
}
/// Ensure the shared client is connected (bootstrapping it when absent or
/// dead) and READY reflects reality. Holds the client lock across the
/// bootstrap so concurrent callers coalesce onto one connect.
async fn ensure_client() -> Result<(), String> {
let mut guard = CLIENT.lock().await;
// A dead client (gateway dropped, hosting runtime gone) is discarded and
// rebuilt — the auto-reconnect-on-drop rule.
if guard
.as_ref()
.is_some_and(|c| c.cancellation_token().is_cancelled())
{
warn!("nym: streamexit client died; reconnecting");
*guard = None;
READY.store(false, Ordering::Relaxed);
}
if guard.is_none() {
let started = std::time::Instant::now();
let client = MixnetClient::connect_new()
.await
.map_err(|e| format!("mixnet client bootstrap failed: {e}"))?;
info!(
"[timing] nym: streamexit client CONNECTED in {}ms",
started.elapsed().as_millis()
);
*guard = Some(client);
READY.store(true, Ordering::Relaxed);
}
Ok(())
}
/// Ensure the shared client is connected, then open a stream on it.
async fn open(recipient: Recipient) -> Result<MixnetStream, String> {
ensure_client().await?;
let mut guard = CLIENT.lock().await;
// Re-acquired the lock after ensure_client — a concurrent failed dial may
// have dropped the client in between; error into the caller's fallback
// rather than panic.
let Some(client) = guard.as_mut() else {
return Err("exit client lost before dial".to_string());
};
match client.open_stream(recipient, None).await {
Ok(stream) => Ok(stream),
Err(e) => {
// `open_stream` fails only LOCALLY (the client's input channel) —
// it never waits on the peer — so an error means the client itself
// is broken, not the exit. Drop it; the next dial reconnects.
*guard = None;
READY.store(false, Ordering::Relaxed);
Err(format!("open_stream failed: {e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bad_exit_address_fails_fast_without_touching_the_mixnet() {
// The address parse runs BEFORE any client bootstrap, so garbage from
// a hostile pool costs nothing and degrades to the fallback path.
let err = open_stream("not-a-recipient", Duration::from_secs(5))
.await
.err()
.expect("garbage address must fail");
assert!(err.contains("invalid exit address"), "got: {err}");
}
/// LIVE end-to-end smoke test of the money path against the DEPLOYED
/// floonet-mixexit (.8): dial the pinned pool's `exit` for relay.goblin.st
/// over the mixnet with the real [`open_stream`], run the SAME
/// hostname-validated TLS + websocket wrap the wallet uses
/// ([`super::super::transport`]), then send a nostr REQ and require the
/// relay to answer (EVENT/EOSE). Proves mixnet -> exit -> relay:443 ->
/// nostr actually carries traffic. Ignored (needs network + a cold mixnet
/// bootstrap). Run:
/// cargo test --lib nym::streamexit::tests::live_exit_roundtrip -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn live_exit_roundtrip() {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
// The app installs this at startup (src/lib.rs); an isolated test must
// too, or rustls 0.23 can't pick a provider for the TLS handshake.
let _ = rustls::crypto::ring::default_provider().install_default();
let exit = crate::nostr::pool::load()
.exit_for("wss://relay.floonet.dev")
.expect("pinned pool advertises the relay.floonet.dev exit");
println!("dialing scoped exit {exit}");
// A cold ephemeral mixnet bootstrap can exceed the per-dial cap; the
// real wallet just falls back and retries, so retry until one dial wins.
let mut stream = None;
for attempt in 1..=6 {
let t = std::time::Instant::now();
match open_stream(&exit, Duration::from_secs(90)).await {
Ok(s) => {
println!(
"open_stream OK on attempt {attempt} in {}ms",
t.elapsed().as_millis()
);
stream = Some(s);
break;
}
Err(e) => println!(
"attempt {attempt} failed in {}ms: {e}",
t.elapsed().as_millis()
),
}
}
let stream = stream.expect("exit stream opened within retries");
let url = "wss://relay.floonet.dev";
let (mut ws, _resp) = tokio::time::timeout(
Duration::from_secs(45),
tokio_tungstenite::client_async_tls(url, stream),
)
.await
.expect("TLS+ws handshake timed out (dead exit?)")
.expect("TLS+ws handshake through exit failed");
println!("TLS+ws handshake through .8 exit OK");
ws.send(Message::Text(
r#"["REQ","smoke",{"kinds":[1],"limit":1}]"#.into(),
))
.await
.expect("send REQ");
let reply = tokio::time::timeout(Duration::from_secs(30), ws.next())
.await
.expect("relay reply timed out")
.expect("ws stream closed early")
.expect("ws frame error");
let txt = match reply {
Message::Text(t) => t.to_string(),
other => format!("{other:?}"),
};
println!("relay answered through exit: {txt}");
assert!(
txt.contains("EVENT") || txt.contains("EOSE"),
"unexpected relay reply: {txt}"
);
}
/// INCIDENT REPRO / VERIFICATION harness: publish a ~2.5KB and a ~66KB
/// kind-1059 EVENT over a SCRATCH scoped exit (address from env
/// `GOBLIN_SCRATCH_EXIT`) to relay.floonet.dev, plus a clearnet control, and
/// report which land (clearnet oracle = ground truth, waits past EOSE so a
/// LATE arrival is still caught). Proves whether the exit pump forwards
/// multi-fragment writes. Run:
/// GOBLIN_SCRATCH_EXIT=<addr> cargo test --lib \
/// nym::streamexit::tests::scratch_exit_publish_bytes -- --ignored --nocapture
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn scratch_exit_publish_bytes() {
use futures::{SinkExt, StreamExt};
use nostr_sdk::JsonUtil;
use nostr_sdk::prelude::*;
use tokio_tungstenite::tungstenite::Message;
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = env_logger::builder()
.is_test(false)
.filter_level(log::LevelFilter::Info)
.filter_module("grim::nym", log::LevelFilter::Debug)
.try_init();
let exit = std::env::var("GOBLIN_SCRATCH_EXIT")
.expect("set GOBLIN_SCRATCH_EXIT to the scratch exit's nym address");
let relay_url = "wss://relay.floonet.dev";
let keys = Keys::generate();
let mk = |n: usize| -> Event {
let nonce = format!("{:016x}", rand::random::<u64>());
EventBuilder::new(Kind::GiftWrap, format!("{nonce}{}", "x".repeat(n)))
.tag(Tag::public_key(keys.public_key()))
.sign_with_keys(&keys)
.expect("sign event")
};
let small = mk(2_000);
let big = mk(64_000);
let clear = mk(2_000);
println!(
"[repro] small id={} wire={}B | big id={} wire={}B | clear id={} wire={}B",
small.id.to_hex(),
small.as_json().len(),
big.id.to_hex(),
big.as_json().len(),
clear.id.to_hex(),
clear.as_json().len()
);
// Clearnet control FIRST (proves the events + relay are fine end to end).
let clear_ok = clearnet_publish(relay_url, &clear).await;
println!("[repro] clearnet publish OK-frame for clear = {clear_ok}");
// Open the SCRATCH scoped exit and run the SAME TLS+ws the wallet uses.
let mut stream = None;
for attempt in 1..=6 {
match open_stream(&exit, Duration::from_secs(90)).await {
Ok(s) => {
println!("[repro] open_stream OK on attempt {attempt}");
stream = Some(s);
break;
}
Err(e) => println!("[repro] open_stream attempt {attempt} failed: {e}"),
}
}
let stream = stream.expect("scratch exit stream opened within retries");
let (mut ws, _resp) = tokio::time::timeout(
Duration::from_secs(45),
tokio_tungstenite::client_async_tls(relay_url, stream),
)
.await
.expect("TLS+ws handshake timed out (dead exit?)")
.expect("TLS+ws handshake through scratch exit failed");
println!("[repro] TLS+ws through scratch exit OK");
for (label, ev) in [("small", &small), ("big", &big)] {
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
println!("[repro] EXIT sending {label} ({} B ws frame)", frame.len());
ws.send(Message::Text(frame.into()))
.await
.expect("ws send over exit");
}
// Keep draining the exit ws in the background so the relay->client OK path
// keeps moving while we measure landing time.
let drainer = tokio::spawn(async move {
let end = tokio::time::Instant::now() + Duration::from_secs(300);
while tokio::time::Instant::now() < end {
match tokio::time::timeout(Duration::from_secs(5), ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
println!("[repro] EXIT relay -> {}", t.as_str())
}
Ok(Some(Ok(_))) => {}
Ok(Some(Err(_))) | Ok(None) => break,
Err(_) => {}
}
}
});
// Measure delivery LATENCY via the clearnet oracle (waits past EOSE).
let t0 = tokio::time::Instant::now();
let probe = Duration::from_secs(180);
let small_id = small.id.to_hex();
let big_id = big.id.to_hex();
let small_fut = async {
let ok = oracle_landed(relay_url, &small_id, probe).await;
println!(
"[repro] ===== EXIT small landed={ok} after {}s =====",
t0.elapsed().as_secs()
);
ok
};
let big_fut = async {
let ok = oracle_landed(relay_url, &big_id, probe).await;
println!(
"[repro] ===== EXIT big landed={ok} after {}s =====",
t0.elapsed().as_secs()
);
ok
};
let (_s, _b) = tokio::join!(small_fut, big_fut);
let clear_landed =
oracle_landed(relay_url, &clear.id.to_hex(), Duration::from_secs(20)).await;
println!("[repro] ===== CLEARNET control clear landed={clear_landed} =====");
drainer.abort();
}
/// Clearnet publish `ev`; returns true on relay `OK ... true`. Positive control.
#[cfg(test)]
async fn clearnet_publish(url: &str, ev: &nostr_sdk::Event) -> bool {
use futures::{SinkExt, StreamExt};
use nostr_sdk::JsonUtil;
use tokio_tungstenite::tungstenite::Message;
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
Ok(x) => x,
Err(e) => {
println!("[oracle] clearnet connect err: {e}");
return false;
}
};
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
if ws.send(Message::Text(frame.into())).await.is_err() {
return false;
}
let id = ev.id.to_hex();
for _ in 0..20 {
match tokio::time::timeout(Duration::from_secs(10), ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
let t = t.as_str();
if t.starts_with("[\"OK\"") {
println!("[oracle] clearnet OK-frame: {t}");
return t.contains(&id) && t.contains("true");
}
}
_ => break,
}
}
false
}
/// Clearnet oracle: REQ for `id_hex`; true iff the relay returns the stored
/// EVENT within `timeout`. Ignores EOSE and keeps the sub OPEN so a LATE
/// arrival (the slow-exit case) is caught the instant the relay stores it.
#[cfg(test)]
async fn oracle_landed(url: &str, id_hex: &str, timeout: Duration) -> bool {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
Ok(x) => x,
Err(e) => {
println!("[oracle] connect err: {e}");
return false;
}
};
let req = format!(r#"["REQ","oracle",{{"ids":["{id_hex}"]}}]"#);
if ws.send(Message::Text(req.into())).await.is_err() {
return false;
}
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return false;
}
match tokio::time::timeout(remaining, ws.next()).await {
Ok(Some(Ok(Message::Text(t)))) => {
if t.as_str().starts_with("[\"EVENT\"") {
return true;
}
}
Ok(Some(Ok(_))) => {}
_ => return false,
}
}
}
}
-231
View File
@@ -1,231 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! WebSocket transport for the Nostr relay pool routed through the Nym
//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool
//! entry advertises its operator's co-located scoped exit
//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream
//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR.
//! FALLBACK (and every relay without an exit): Goblin's in-process smolmix
//! tunnel — the relay host is resolved by [`super::dns`], the TCP stream is
//! opened via `tunnel.tcp_connect`. Either way the SAME TLS (rustls, webpki
//! roots) + websocket handshake runs over the mixnet-carried stream, so the
//! payload + in-flight destination never touch the clear, and an exit failure
//! only ever falls back — never a lockout.
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
use async_wsocket::{ConnectionMode, Message};
use nostr_relay_pool::transport::error::TransportError;
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
use nostr_sdk::Url;
use nostr_sdk::util::BoxedFuture;
use tokio_tungstenite::tungstenite::Message as TgMessage;
/// A backend transport error (failures outside the websocket layer) carrying
/// `msg` as its display text.
fn terr(msg: impl Into<String>) -> TransportError {
TransportError::backend(std::io::Error::other(msg.into()))
}
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
#[derive(Debug, Clone, Copy, Default)]
pub struct NymWebSocketTransport;
impl WebSocketTransport for NymWebSocketTransport {
fn support_ping(&self) -> bool {
true
}
fn connect<'a>(
&'a self,
url: &'a Url,
_mode: &'a ConnectionMode,
timeout: Duration,
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
Box::pin(async move {
let host = url
.host_str()
.ok_or_else(|| terr("relay url has no host"))?
.to_string();
let port = url.port().unwrap_or(match url.scheme() {
"ws" => 80,
_ => 443,
});
// MONEY-PATH ANCHOR: when the pool advertises this relay
// operator's co-located scoped Nym exit, dial THROUGH it — a
// MixnetStream straight to the exit (which pipes to its one
// relay), no public DNS, no public IPR, no tunnel dependency. The
// TLS + websocket wrap inside is byte-for-byte the tunnel path's
// (same `client_async_tls`, SNI = the relay host), so the exit
// sees only ciphertext. ANY failure — bootstrap, open, handshake,
// timeout — falls through to the public-IPR tunnel dial below:
// anchor + fallback, never pin-only.
if let Some(exit) = crate::nostr::pool::load().exit_for(url.as_str()) {
let t_exit = std::time::Instant::now();
match exit_connect(url, &exit, timeout).await {
Ok(parts) => {
log::info!(
"[timing] nym: relay {host} CONNECTED via scoped exit — \
stream+tls+ws {}ms",
t_exit.elapsed().as_millis()
);
return Ok(parts);
}
Err(e) => log::warn!(
"nym: scoped exit dial for {host} failed after {}ms ({e}); \
falling back to the public-IPR tunnel",
t_exit.elapsed().as_millis()
),
}
}
// The shared mixnet tunnel (lazy-started at app launch).
let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout)
.await
.ok_or_else(|| terr("nym tunnel not ready"))?;
// Resolve the relay host (clearnet by default — see nym::dns), then
// dial the resolved IP THROUGH the same tunnel so the TCP, TLS and
// websocket all still ride the mixnet. Each stage is timed so the
// connect-timing harness can attribute cost per relay.
let t_resolve = std::time::Instant::now();
let addr =
tokio::time::timeout(timeout, crate::nym::dns::resolve(&tunnel, &host, port))
.await
.map_err(|_| terr("dns resolve timeout"))?
.ok_or_else(|| terr(format!("could not resolve relay host {host}")))?;
let resolve_ms = t_resolve.elapsed().as_millis();
let t_tcp = std::time::Instant::now();
let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr))
.await
.map_err(|_| terr("nym tunnel connect timeout"))?
.map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?;
let tcp_ms = t_tcp.elapsed().as_millis();
// Perform TLS (for wss) + websocket handshake over the mixnet stream.
let t_ws = std::time::Instant::now();
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
log::info!(
"[timing] nym: relay {host} CONNECTED — resolve {resolve_ms}ms, \
tcp_connect(mixnet) {tcp_ms}ms, tls+ws(mixnet) {}ms",
t_ws.elapsed().as_millis()
);
Ok(split_ws(ws))
})
}
}
/// Dial `url` through the relay operator's scoped Nym exit `exit`: a
/// MixnetStream to the exit (which pipes to its one configured relay), then
/// the SAME hostname-validated TLS + websocket handshake as the tunnel path.
/// The handshake doubles as the exit liveness probe — `open_stream` is
/// fire-and-forget, so a dead exit surfaces here as a (bounded) timeout and
/// the caller falls back.
async fn exit_connect(
url: &Url,
exit: &str,
timeout: Duration,
) -> Result<(WebSocketSink, WebSocketStream), TransportError> {
let stream = crate::nym::streamexit::open_stream(exit, timeout)
.await
.map_err(terr)?;
let (ws, _response) = tokio::time::timeout(
timeout,
tokio_tungstenite::client_async_tls(url.as_str(), stream),
)
.await
.map_err(|_| terr("websocket handshake timeout (exit stream)"))?
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
Ok(split_ws(ws))
}
/// Split a websocket into the pool's boxed sink/stream halves — shared by the
/// scoped-exit and tunnel dial paths, so everything above the byte transport
/// is identical whichever egress carried the connection.
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
{
let (tx, rx) = ws.split();
let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink;
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
match msg {
Ok(tg) => tg_to_message(tg).map(Ok),
Err(e) => Some(Err(TransportError::backend(e))),
}
})) as WebSocketStream;
(sink, stream)
}
/// Convert a tungstenite message into an async-wsocket pool message.
/// Returns `None` for raw frames (never surfaced while reading).
fn tg_to_message(msg: TgMessage) -> Option<Message> {
match msg {
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
TgMessage::Close(_) => Some(Message::Close(None)),
TgMessage::Frame(_) => None,
}
}
/// Sink adapter converting pool messages into tungstenite messages.
struct NymSink<S>(S);
impl<S> Sink<Message> for NymSink<S>
where
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
{
type Error = TransportError;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_ready_unpin(cx)
.map_err(TransportError::backend)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.0)
.start_send_unpin(TgMessage::from(item))
.map_err(TransportError::backend)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_flush_unpin(cx)
.map_err(TransportError::backend)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.0)
.poll_close_unpin(cx)
.map_err(TransportError::backend)
}
}
+1 -37
View File
@@ -110,14 +110,6 @@ pub struct AppConfig {
/// (dots until tapped to reveal). Presentation-only, no money-path or
/// storage effect. Default false.
anonymous_mode: Option<bool>,
/// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway
/// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect
/// can skip re-picking a (possibly dead) random first hop.
nym_entry_gateway: Option<String>,
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
/// warm reconnect can try the exit that worked last time before auto-selecting.
nym_last_ipr: Option<String>,
}
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
@@ -226,15 +218,13 @@ impl Default for AppConfig {
// On by default, like upstream Grim: checks Goblin's own GitHub
// releases direct over HTTPS (see http/release.rs). This is the same
// non-sensitive-metadata-over-clearnet posture Grim uses for its
// update check — payments, relays and identity still stay mixnet-only.
// update check — payments, relays and identity still egress over Tor.
check_updates: Some(true),
app_update: None,
hide_amounts: None,
notif_hide_names: None,
notif_hide_details: None,
anonymous_mode: None,
nym_entry_gateway: None,
nym_last_ipr: None,
}
}
}
@@ -518,32 +508,6 @@ impl AppConfig {
w_config.save();
}
/// Get the last-known-good Nym ENTRY gateway (base58 identity), if any.
pub fn nym_entry_gateway() -> Option<String> {
let r_config = Settings::app_config_to_read();
r_config.nym_entry_gateway.clone()
}
/// Save (or clear) the last-known-good Nym ENTRY gateway.
pub fn set_nym_entry_gateway(gw: Option<String>) {
let mut w_config = Settings::app_config_to_update();
w_config.nym_entry_gateway = gw;
w_config.save();
}
/// Get the last-known-good Nym IPR exit recipient string, if any.
pub fn nym_last_ipr() -> Option<String> {
let r_config = Settings::app_config_to_read();
r_config.nym_last_ipr.clone()
}
/// Save (or clear) the last-known-good Nym IPR exit recipient string.
pub fn set_nym_last_ipr(ipr: Option<String>) {
let mut w_config = Settings::app_config_to_update();
w_config.nym_last_ipr = ipr;
w_config.save();
}
/// Check if proxy for network requests is needed.
pub fn use_proxy() -> bool {
let r_config = Settings::app_config_to_read();
+15 -16
View File
@@ -21,8 +21,9 @@
//!
//! Two technical choices are inherited VERBATIM from GRIM because it already paid
//! for them: **arti 0.43** across the arti family, and the **native-tls Tor
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, to sidestep
//! the rustls/ring crypto-provider conflict Goblin fought during the Nym era.
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, so arti's TLS
//! stays on native-tls and never touches the rustls/ring provider our relay and
//! HTTP TLS install.
//!
//! The arti client runs on its OWN dedicated tokio runtime (created once, kept
//! alive for the process). `TorClient::connect()` returns a [`DataStream`] that
@@ -79,7 +80,7 @@ impl Tor {
}
}
// --- Readiness signals (re-pointed from `nym::nymproc`, same semantics) -------
// --- Readiness signals ---------------------------------------------------------
/// Set once arti has bootstrapped (mirrors `TUNNEL_GEN != 0`); cheap to poll.
static READY: AtomicBool = AtomicBool::new(false);
@@ -87,7 +88,7 @@ static READY: AtomicBool = AtomicBool::new(false);
/// Monotonic "transport generation". With Tor there is no exit-reselect churn —
/// arti rebuilds circuits transparently under the `DataStream` — so this simply
/// becomes 1 once bootstrapped and stays there. The relay-gated readiness logic
/// (copied from nym) still works: a relay-liveness report tagged with an older
/// still works this way: a relay-liveness report tagged with an older
/// generation can never mark a newer transport ready.
static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
@@ -96,9 +97,8 @@ static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
/// can compare it to `TUNNEL_GEN` in one shot.
static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0);
/// Whether a nostr consumer currently wants relays over Tor. Kept for API parity
/// with the nym transport (the UI/service bracket it); Tor needs no exit-health
/// governance, so it is otherwise inert.
/// Whether a nostr consumer currently wants relays over Tor. The UI/service
/// bracket it; Tor needs no exit-health governance, so it is otherwise inert.
static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false);
/// Pre-warm the embedded Tor client in the background so relays / NIP-05 / price
@@ -152,16 +152,15 @@ pub fn report_relay_down(generation: u64) {
let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire);
}
/// Bracket a nostr consumer's lifetime (API parity with the nym transport). Inert
/// for Tor — arti manages its own circuit health — but kept so the service's
/// existing calls compile unchanged.
/// Bracket a nostr consumer's lifetime. Inert for Tor — arti manages its own
/// circuit health — but kept so the service's existing calls compile unchanged.
pub fn set_relay_consumer(active: bool) {
RELAY_CONSUMER.store(active, Ordering::Release);
}
/// External condemnation request (API parity with the nym transport). Under Tor
/// there is no exit to abandon — arti rebuilds circuits itself — so this is a
/// logged no-op rather than triggering a reselect.
/// External condemnation request (kept for API parity with earlier transports).
/// Under Tor there is no exit to abandon — arti rebuilds circuits itself — so this
/// is a logged no-op rather than triggering a reselect.
pub fn condemn_exit(generation: u64) {
if generation != 0 {
warn!("tor: condemn_exit(gen {generation}) is a no-op (arti rebuilds circuits itself)");
@@ -275,8 +274,8 @@ fn bootstrap_once() {
"tor: bootstrapped and ready in {}ms (gen 1)",
started.elapsed().as_millis()
);
// Eager price fetch the moment Tor is ready (mirrors what the old mixnet
// bootstrap did): prefetch the pairing's rate so the amount preview has a live
// value by first use. One-shot — bootstrap_once only reaches here once.
// Eager price fetch the moment Tor is ready: prefetch the pairing's rate so the
// amount preview has a live value by first use. One-shot — bootstrap_once only
// reaches here once.
std::thread::spawn(crate::http::price::eager_refresh);
}
+2 -3
View File
@@ -21,9 +21,8 @@
//! could pin a per-relay `.onion` for a direct onion-circuit money path; that was
//! dropped in build134 — onion services flapped — in favour of Tor-exit only.)
//!
//! This replaces the Nym-mixnet transport (`crate::nym`, left dormant): Tor is
//! free, unmetered, has no token or grant to expire, and GRIM has already proven
//! the whole embedded path on desktop and Android.
//! Tor is free, unmetered, has no token or grant to expire, and GRIM has already
//! proven the whole embedded path on desktop and Android.
//!
//! The Grin blockchain node is NOT routed here — it stays on the clear internet
//! exactly as before; it never sees who pays whom.
+4 -4
View File
@@ -124,7 +124,7 @@ pub struct Wallet {
syncing: Arc<AtomicBool>,
/// On-demand node polling (Android battery): pause the heavy node sync at
/// sync thread while the app is backgrounded and nothing is in flight.
/// The relay+Nym nostr service keeps running regardless of this flag.
/// The relay+Tor nostr service keeps running regardless of this flag.
node_polling_paused: Arc<AtomicBool>,
/// Resume-signal counter closing the receipt-vs-pause race: bumped by
/// [`Wallet::resume_node_polling`]; the sync thread only pauses when no
@@ -439,7 +439,7 @@ impl Wallet {
// iteration (wallet.rs, top of the loop); if the thread races
// ahead of this, that first iteration finds no service, skips
// it, and the service doesn't start until the NEXT cycle — a
// full SYNC_DELAY (60s) later. That 60s gap (not the mixnet,
// full SYNC_DELAY (60s) later. That 60s gap (not Tor,
// which connects a relay in ~2s) is the "stuck on Connecting…
// for a minute" symptom. Synchronous + on this thread, so the
// service is guaranteed present when start_sync runs.
@@ -2676,7 +2676,7 @@ fn start_sync(wallet: Wallet) -> Thread {
// Start the nostr payment-messaging service the moment the wallet is
// open — BEFORE (and independent of) the grin node sync. Previously
// this lived deep in the sync body behind `!sync_error` and the node
// checks, so the Nym/relay connection could wait up to a full
// checks, so the Tor/relay connection could wait up to a full
// SYNC_DELAY (60s) — or never start while the node errored — leaving
// the profile stuck on "Connecting…". Idempotent.
if let Some(service) = wallet.nostr_service() {
@@ -2733,7 +2733,7 @@ fn start_sync(wallet: Wallet) -> Thread {
// On-demand node polling (Android battery): while the app is
// backgrounded and no transaction is waiting on the node, skip
// the heavy node sync. The relay+Nym nostr service started
// the heavy node sync. The relay+Tor nostr service started
// above keeps running and listening for gift wraps regardless;
// a slatepack receipt resumes polling instantly (see
// `resume_node_polling`). Foreground always polls.