diff --git a/Cargo.toml b/Cargo.toml index 0324afa4..82d60582 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java index 5d97e1d7..1283ce13 100644 --- a/android/app/src/main/java/mw/gri/android/BackgroundService.java +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -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" diff --git a/locales/de.yml b/locales/de.yml index d9552d34..89b8a00d 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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." diff --git a/locales/en.yml b/locales/en.yml index 9e8af8c8..3612bf7e 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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." diff --git a/locales/es.yml b/locales/es.yml index da94117a..7079f5c1 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -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." diff --git a/locales/fr.yml b/locales/fr.yml index 2545a4d2..0df3c46b 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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." diff --git a/locales/ja.yml b/locales/ja.yml index 0dc43029..8405b89d 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -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: "まっさらな状態にしたいですか? いつでもまったく新しいキーに切り替えられます — 新しいあなたは古いあなたと結び付きません。同じウォレットで、新しい顔になります。" diff --git a/locales/ko.yml b/locales/ko.yml index b8211e1e..e94ab898 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -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: "새 출발을 원하시나요? 언제든 완전히 새로운 키로 바꾸세요 — 새로운 나는 이전과 연결되지 않습니다. 같은 지갑, 새로운 얼굴입니다." diff --git a/locales/ru.yml b/locales/ru.yml index 6f15932e..09ef712b 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -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: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо." diff --git a/locales/tr.yml b/locales/tr.yml index 75eaa91c..e02da3e4 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 0ab6106f..42184311 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 73373d0b..fb368816 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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| { diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index ebda7504..879bcffe 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -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), diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index bb50ba16..c11d79f5 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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( diff --git a/src/lib.rs b/src/lib.rs index e84e1e65..e1b090eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/node/node.rs b/src/node/node.rs index 04f6809a..343a2a7e 100644 --- a/src/node/node.rs +++ b/src/node/node.rs @@ -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 { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 99da2b10..6ff16729 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -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>, /// 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>, /// 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 = 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, 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, 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, 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, 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, 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, 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, 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, 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 = crate::nostr::pool::usable_discovery_relays() diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 6a9f208b..dfaafbe1 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -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::*; diff --git a/src/nostr/pool.rs b/src/nostr/pool.rs index 5666a12f..a14cf76c 100644 --- a/src/nostr/pool.rs +++ b/src/nostr/pool.rs @@ -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, - /// 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` (`.@`) 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, } @@ -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 { 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 { 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 { // 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()); diff --git a/src/nym/dns.rs b/src/nym/dns.rs deleted file mode 100644 index 26bcafcb..00000000 --- a/src/nym/dns.rs +++ /dev/null @@ -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, 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> = 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> = 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 { - // IP literals (v4 or v6) need no lookup at all. - if let Ok(ip) = host.parse::() { - 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 { - // 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 { - 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, 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, 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::(); - 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, 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, u32)> { - let id = rand::random::(); - 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 { - 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 465–1197ms (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> { - 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, 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); - } -} diff --git a/src/nym/mod.rs b/src/nym/mod.rs deleted file mode 100644 index 93c10448..00000000 --- a/src/nym/mod.rs +++ /dev/null @@ -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>, - headers: Vec<(String, String)>, -) -> Option<(u16, Vec)> { - 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, - headers: Vec<(String, String)>, -) -> Option { - 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("").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>; - -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> = 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 { - 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>, - headers: &[(String, String)], - host: &str, - https: bool, - port: u16, -) -> Option<((u16, Vec, Option), 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>, - headers: &[(String, String)], -) -> Option<(u16, Vec, Option)> { - 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 = 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> { - 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) - }; - 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 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 = { - 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 { - 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(host: &str, stream: S) -> Option> -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() -} diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs deleted file mode 100644 index c8469c40..00000000 --- a/src/nym/nymproc.rs +++ /dev/null @@ -1,1073 +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. - -//! In-process Nym mixnet tunnel — the wallet's PUBLIC-EXIT path. Goblin links -//! smolmix directly (no sidecar, no bundled binary, no loopback SOCKS5 seam). -//! One process-lifetime [`Tunnel`] carries relay websockets and HTTP requests -//! as raw TCP over the mixnet to an IPR exit gateway, with PREFER-WITH-FALLBACK -//! selection ([`ExitSelector`]): `GOBLIN_NYM_IPR` may name a PREFERRED PUBLIC -//! IPR to try first each cycle; on bootstrap/liveness failure the cycle falls -//! back to an AUTO-SELECTED public exit and retries the preferred one on the -//! next reselect. Unset → pure auto-select, as before. Losing any one exit just -//! re-selects, so there is no single-exit SPOF. Hostnames resolve via -//! [`super::dns`] over DoT through the same tunnel, so nothing touches clearnet. -//! -//! This is the FALLBACK / discovery-and-secondary-relay path. The MONEY-PATH -//! primary relay is reached over a SCOPED MixnetStream to a Floonet operator's -//! CO-LOCATED exit when the pool advertises one ([`crate::nostr::pool::PoolRelay::exit`]), -//! which needs no public DNS and no public IPR — see the streamexit egress -//! (design in ~/.claude/plans/floonet-nym-exit.md). That anchor+fallback split -//! is the "prefer our exit, never pin-only" rule at the transport level. -//! -//! Should smolmix ever regress, the fallback design (SOCKS5 network requester -//! + ordered exit failover) is specified in the plan, section G14. -//! -//! Cover traffic: the public READ tunnel is now backed by a tuned -//! `MixnetClient` (built in [`build_tunnel`] via `IpMixStream::from_client`) on -//! the balanced "high default traffic volume" preset — ~250 real msgs/s, ~10 ms -//! per-hop delay, loop cover traffic effectively off. Per-hop mix delays are -//! KEPT (no `set_no_per_hop_delays`), so timing obfuscation stays on; only cover -//! traffic is reduced, for the G13 low-power posture. The MONEY-PATH scoped exit -//! ([`super::streamexit`]) is a SEPARATE client and keeps full SDK defaults. - -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::thread; -use std::time::{Duration, Instant}; - -use log::{debug, error, info, warn}; -use parking_lot::RwLock; -use smolmix::{Recipient, Tunnel}; - -use crate::AppConfig; - -/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes. -static TUNNEL: RwLock> = RwLock::new(None); - -/// Set once the tunnel is up (mirrors `TUNNEL`, but cheap to poll each frame). -static MIXNET_READY: AtomicBool = AtomicBool::new(false); - -/// Monotonic tunnel generation: bumped each time a NEW tunnel (a freshly -/// auto-selected exit) is published. This is the crux of relay-gated readiness: -/// a relay-liveness report tagged with an older generation can never mark the -/// current tunnel ready, so readiness cannot latch true on a stale exit. Starts -/// at 0 ("no tunnel yet"); the first tunnel is generation 1. -static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0); - -/// The tunnel generation on which the nostr client currently has a relay -/// connected AND subscribed, or 0 for "no relay live". A SINGLE atomic (not a -/// bool+gen pair) so [`transport_ready`] can compare it to `TUNNEL_GEN` in one -/// shot — no half-updated `(live, gen)` tuple can slip a stale-exit "ready" -/// through. Written by the nostr client via [`report_relay_live`] / -/// [`report_relay_down`], read by the watchdog and [`transport_ready`]. -static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0); - -/// Whether a nostr consumer (a running `NostrService`) currently WANTS relays -/// over the tunnel. Relay reachability governs exit health ONLY while this is -/// true: the tunnel also carries plain HTTP (NIP-05, price, relay pool) with no -/// relay at all — e.g. before a wallet is open — and such usage must NOT get an -/// otherwise-healthy exit condemned for "no relay". Bracketed by the service via -/// [`set_relay_consumer`]; when false the DNS keepalive is the sole health -/// signal, exactly as before this hardening. -static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false); - -/// Guards the background bootstrap thread so `warm_up()` is idempotent. -static STARTED: AtomicBool = AtomicBool::new(false); - -/// Guards the one-shot scoped-exit prewarm so it fires exactly once — after the -/// FIRST tunnel is published — and never again on a later reselect. -static PREWARMED: AtomicBool = AtomicBool::new(false); - -/// Guards the one-shot eager price fetch / end-to-end exit probe so it fires -/// exactly once — after the FIRST tunnel is published — and never again on a -/// later reselect. -static PRICE_KICKED: AtomicBool = AtomicBool::new(false); - -/// The highest tunnel generation for which an external caller (the eager price -/// probe) has requested condemnation. The watchdog compares it against the -/// generation it is watching each tick, so a dead exit whose cheap probes still -/// pass but which blackholes real HTTP can be abandoned in seconds. `fetch_max` -/// so a stale request can never move it backwards. Never triggers a reselect on -/// a NEWER generation than the one requested. -static CONDEMN_REQUEST_GEN: AtomicU64 = AtomicU64::new(0); - -/// Pre-warm the mixnet tunnel in the background so relays / NIP-05 / price are -/// ready by first use. Idempotent — later calls (including the lazy-init path -/// in [`wait_for_tunnel`]) are no-ops. -pub fn warm_up() { - if STARTED.swap(true, Ordering::SeqCst) { - return; - } - thread::spawn(run_tunnel); -} - -/// Whether the mixnet tunnel is warm. Cheap and cached — safe to poll from the -/// UI each frame. Distinct from a relay being connected (see -/// [`transport_ready`]): the tunnel can be up while no relay yet rides it. -pub fn is_ready() -> bool { - MIXNET_READY.load(Ordering::Relaxed) -} - -/// The current tunnel generation. The nostr client reads this right before it -/// dials so it can tag its relay-liveness reports with the exit they ride. -pub fn tunnel_generation() -> u64 { - TUNNEL_GEN.load(Ordering::Acquire) -} - -/// Relay-gated readiness — the AUTHORITATIVE "ready to receive/send over Nym" -/// signal, distinct from the tunnel-only [`is_ready`]. True only when the -/// tunnel is up AND a required relay is connected+subscribed on the CURRENT -/// generation. Money path: when in doubt this is false, so the UI shows -/// "connecting/reconnecting" rather than a false "Connected over Nym", and the -/// dead-for-our-purposes exit gets condemned rather than blackholing us. -pub fn transport_ready() -> bool { - let generation = TUNNEL_GEN.load(Ordering::Acquire); - generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation && is_ready() -} - -/// Client → transport report: a relay is connected+subscribed on `generation`. -/// `fetch_max` so a late report for an older exit can never move liveness -/// backwards over a newer one. -pub fn report_relay_live(generation: u64) { - RELAY_LIVE_GEN.fetch_max(generation, Ordering::AcqRel); -} - -/// Client → transport report: no relay is currently live on `generation` (all -/// dropped). Clears liveness only when `generation` is still the live one, so a -/// stale "down" can't wipe a fresh report from a newer exit. -pub fn report_relay_down(generation: u64) { - let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire); -} - -/// External condemnation request for `generation`: the end-to-end eager probe -/// found the exit up (cheap probes pass) yet blackholing real HTTP. The watchdog -/// picks this up on its next tick and re-selects a fresh exit. Bounded by design: -/// it only ever condemns the generation it targets, never a newer one, and the -/// probe that calls it is one-shot per tunnel generation. -pub fn condemn_exit(generation: u64) { - if generation == 0 { - return; - } - CONDEMN_REQUEST_GEN.fetch_max(generation, Ordering::AcqRel); - warn!("[timing] nym: eager probe requested condemnation of exit gen {generation}"); -} - -/// Bracket a nostr consumer's lifetime: the running `NostrService` sets this -/// true while it wants relays and false when it stops. Arms/disarms -/// relay-reachability governance of exit health (see [`RELAY_CONSUMER`]). -pub fn set_relay_consumer(active: bool) { - RELAY_CONSUMER.store(active, Ordering::Release); -} - -/// Whether a nostr consumer currently wants relays over the tunnel. -fn relay_consumer() -> bool { - RELAY_CONSUMER.load(Ordering::Acquire) -} - -/// Whether a relay is live on `generation` — the watchdog's authoritative view -/// of whether the current exit actually carries our relay traffic. -fn relay_live_for(generation: u64) -> bool { - generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation -} - -/// The shared tunnel, if it is up. Cloning is a cheap `Arc` bump. -pub fn tunnel() -> Option { - TUNNEL.read().clone() -} - -/// Wait until the shared tunnel is up, starting the bootstrap if nothing has -/// yet (lazy init on first use). Returns `None` once `timeout` lapses. -pub async fn wait_for_tunnel(timeout: Duration) -> Option { - warm_up(); - let deadline = Instant::now() + timeout; - loop { - if let Some(t) = tunnel() { - return Some(t); - } - if Instant::now() >= deadline { - return None; - } - tokio::time::sleep(Duration::from_millis(250)).await; - } -} - -/// Build the mixnet tunnel on a dedicated multi-thread tokio runtime, then -/// keep the tunnel (its bridge + smoltcp reactor tasks) AND the runtime alive -/// for the lifetime of the process. Retries with backoff on bootstrap failure -/// (a dead gateway pick just re-selects on the next attempt). Blocks the -/// calling thread. -fn run_tunnel() { - let rt = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - error!("nym: could not build mixnet runtime: {e}"); - return; - } - }; - rt.block_on(async move { - let mut delay = Duration::from_secs(5); - let mut attempt = 0u64; - let mut selector = ExitSelector::new(); - // True while a FALLBACK (auto-selected) exit carries the traffic even - // though an anchor is configured — makes the ANCHOR RECOVERED log honest. - let mut fell_back = false; - // WARM-CONNECT CACHES (biggest cold-connect win). The last-known-good ENTRY - // GATEWAY (item 1) is applied to EVERY build so a warm reconnect skips - // re-picking a random — and possibly dead — first hop; a build timeout/error - // while it was in use drops it for the rest of THIS process (disk untouched, - // since a blip must not throw away a good hint). The last-known-good IPR - // (item 2) is tried once per process as a pin, ordered Anchor -> Cached -> - // Auto by the selector. - let mut cached_gw = AppConfig::nym_entry_gateway(); - let mut cached_ipr = AppConfig::nym_last_ipr().and_then(|s| parse_anchor(&s)); - // Don't double up: if the cached IPR is the configured anchor, the anchor - // slot already covers it. - if let (Some(c), Some(a)) = (cached_ipr, anchor_recipient()) { - if c == a { - cached_ipr = None; - } - } - // COLD-START SEQUENCING (reads-first): the TUNNEL bootstraps first and takes - // its Nym free-tier bandwidth grant, so interactive reads get the tunnel - // ~2-3s sooner. The scoped money-path exit is prewarmed AFTER the first - // tunnel is published (see the `PREWARMED` guard below `MIXNET_READY`), which - // preserves grant-sequencing (tunnel first, then exit) without making reads - // wait out an exit head-start on cold start. - loop { - let started = Instant::now(); - attempt += 1; - // Prefer-with-fallback exit selection: the anchor (when configured) - // exactly once per select cycle, auto-select for every further - // attempt in the cycle. Env re-read each attempt so the timing - // harness / a debug session can flip it without a restart. - let anchor = anchor_recipient(); - let choice = selector.next_choice(anchor.is_some(), cached_ipr.is_some()); - let pin = match choice { - ExitChoice::Anchor => { - info!( - "[timing] nym: ANCHOR attempt — trying our preferred IPR exit first (attempt {attempt})" - ); - anchor - } - ExitChoice::Cached => { - info!( - "[timing] nym: CACHED attempt — trying last-known-good IPR exit (attempt {attempt})" - ); - // One-shot for this process: take it so a failure falls through - // to Auto and the slot never re-arms (unlike the anchor). - cached_ipr.take() - } - ExitChoice::Auto => None, - }; - info!( - "[timing] nym: BOOTSTRAP start (attempt {attempt}, {} exit select+build)", - choice.label() - ); - // Cap the build: a dead gateway pick otherwise blocks on the Nym SDK's - // own long "connection response" timeout (~74s measured) before we can - // reselect. Abandoning the future drops the half-built tunnel. - let build_cap = tunnel_build_timeout(); - let entry_gw = cached_gw.clone(); - let used_cached_gw = entry_gw.is_some(); - let build = match tokio::time::timeout(build_cap, build_tunnel(pin, entry_gw)).await { - Ok(result) => result, - Err(_) => { - // A cached entry gateway that timed out is not reused for the rest - // of this process (disk kept — it may be a transient blip). - if used_cached_gw { - cached_gw = None; - } - match choice { - ExitChoice::Anchor => { - // A dead anchor must not delay connectivity: fall back - // to auto-select IMMEDIATELY (no backoff), same cycle. - warn!( - "[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \ - FALLBACK to auto-select now", - build_cap.as_secs() - ); - } - ExitChoice::Cached => { - warn!( - "[timing] nym: CACHED IPR build exceeded {}s (attempt {attempt}); \ - clearing the cached exit and auto-selecting now", - build_cap.as_secs() - ); - AppConfig::set_nym_last_ipr(None); - } - ExitChoice::Auto => { - warn!( - "[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \ - re-selecting immediately", - build_cap.as_secs() - ); - delay = Duration::from_secs(5); - } - } - continue; - } - }; - match build { - Ok((tunnel, used_gw, used_ipr)) => { - let build_ms = started.elapsed().as_millis(); - info!( - "[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness" - ); - // Gate readiness on one end-to-end probe: some exits accept - // the IPR handshake but never deliver data (seen live); - // publishing such a tunnel would blackhole every consumer - // until the watchdog caught it minutes later. Re-select - // immediately instead. (This is a CHEAP early signal; relay - // reachability below is the authoritative one.) Uses the FAST - // fresh-gate budget (~10s worst case) — NOT the patient - // established-tunnel probe (~32s doubled here before) — so a - // dead fresh exit no longer dominates the cold-start tail; see - // `dns::probe_fresh`. - let probe_started = Instant::now(); - let alive = super::dns::probe_fresh(&tunnel).await; - let probe_ms = probe_started.elapsed().as_millis(); - if !alive { - warn!( - "[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe in {probe_ms}ms \ - ({}ms total incl. build; attempt {attempt}); {}", - choice.label(), - started.elapsed().as_millis(), - match choice { - ExitChoice::Anchor => "FALLBACK to auto-select now", - ExitChoice::Cached => - "clearing the cached exit and auto-selecting now", - ExitChoice::Auto => "re-selecting immediately", - } - ); - tunnel.shutdown().await; - match choice { - // A cached exit that fails its probe is stale: drop the - // disk hint so we don't keep re-trying a dead IPR. - ExitChoice::Cached => AppConfig::set_nym_last_ipr(None), - ExitChoice::Auto => { - delay = (delay * 2).min(Duration::from_secs(60)); - } - ExitChoice::Anchor => {} - } - continue; - } - // A NEW exit is live: bump the generation BEFORE publishing so - // any relay-liveness left over from the previous exit is - // instantly stale (RELAY_LIVE_GEN != TUNNEL_GEN) and cannot - // mark this tunnel ready. - let generation = TUNNEL_GEN.fetch_add(1, Ordering::AcqRel) + 1; - let published = Instant::now(); - info!( - "[timing] nym: TUNNEL READY in ~{}ms total (build {build_ms}ms + probe, \ - {} exit, allocated ip {}, gen {generation}, attempt {attempt})", - started.elapsed().as_millis(), - choice.label(), - tunnel.allocated_ips().ipv4 - ); - // Close the select cycle: the NEXT reselect tries the anchor - // first again, whichever exit won this one. - selector.tunnel_published(); - match choice { - ExitChoice::Anchor => { - if fell_back { - info!( - "[timing] nym: ANCHOR RECOVERED — back on our preferred exit (gen {generation})" - ); - } - fell_back = false; - } - // A cached exit only wins after the anchor slot was tried this - // cycle, so with an anchor configured this is still a FALLBACK — - // retry the anchor on the next reselect. - ExitChoice::Cached if anchor.is_some() => { - fell_back = true; - info!( - "[timing] nym: running on cached FALLBACK exit (gen {generation}); \ - anchor will be retried on the next reselect" - ); - } - ExitChoice::Cached => {} - ExitChoice::Auto if anchor.is_some() => { - fell_back = true; - info!( - "[timing] nym: running on FALLBACK auto-selected exit (gen {generation}); \ - anchor will be retried on the next reselect" - ); - } - ExitChoice::Auto => {} - } - // Persist the warm-connect caches for the next cold start: the ENTRY - // GATEWAY (item 1) and the winning IPR (item 2), each only when - // changed so a steady exit doesn't rewrite app.toml on every reselect. - if AppConfig::nym_entry_gateway().as_deref() != Some(used_gw.as_str()) { - info!("[timing] nym: caching entry gateway {used_gw} for warm reconnect"); - AppConfig::set_nym_entry_gateway(Some(used_gw.clone())); - } - cached_gw = Some(used_gw); - let ipr_str = used_ipr.to_string(); - if AppConfig::nym_last_ipr().as_deref() != Some(ipr_str.as_str()) { - AppConfig::set_nym_last_ipr(Some(ipr_str)); - } - *TUNNEL.write() = Some(tunnel.clone()); - MIXNET_READY.store(true, Ordering::Relaxed); - // Prewarm the scoped money-path exit ONCE, now that the tunnel is - // up (grant-sequencing: the tunnel already took its grant, the exit - // takes the next one) — but reads already have the tunnel. Guarded - // so a later reselect never re-fires it, and gated on the pool - // actually advertising a co-located exit. - if crate::nostr::pool::load().has_exit() - && !PREWARMED.swap(true, Ordering::SeqCst) - { - tokio::spawn(super::streamexit::prewarm()); - } - // Eager price fetch the moment the tunnel is ready (item 3) — it - // also serves as the end-to-end exit probe (item 5): if every - // attempt fails while the tunnel still reads ready, the exit is - // blackholing HTTP and gets condemned. One-shot, like the prewarm. - if !PRICE_KICKED.swap(true, Ordering::SeqCst) { - std::thread::spawn(crate::http::price::eager_refresh); - } - delay = Duration::from_secs(5); - // Hold the exit warm and govern its health. The watchdog weighs TWO - // signals: the cheap DNS keepalive (as before) AND — authoritatively, - // whenever a nostr consumer is present — RELAY REACHABILITY. The DNS - // probe only proves the exit reaches the internet; some exits pass it - // yet never carry our relay traffic (exit policy blocks the relay, relay - // unreachable through it, subscription never establishes). Such an exit - // is condemned and rebuilt on a fresh auto-selected one rather than left - // blackholing the wallet while the UI (falsely) reads "Connected over - // Nym". Losing any one exit must never take the wallet down. - watch_tunnel(&tunnel, generation).await; - error!( - "[timing] nym: exit gen {generation} condemned after {}s alive; rebuilding on a fresh exit", - published.elapsed().as_secs() - ); - MIXNET_READY.store(false, Ordering::Relaxed); - *TUNNEL.write() = None; - tunnel.shutdown().await; - // Rebuild floor: never re-select faster than once per - // MIN_EXIT_LIFETIME. Whatever condemned the exit (or any - // future bug), this is the hard guarantee that a condemnation - // can't thrash the mixnet into a tight reselect loop. - let alive = published.elapsed(); - if alive < MIN_EXIT_LIFETIME { - let floor = MIN_EXIT_LIFETIME - alive; - info!( - "[timing] nym: rebuild floor — waiting {}ms before next exit select", - floor.as_millis() - ); - tokio::time::sleep(floor).await; - } - } - Err(e) => { - // A cached entry gateway that errored is not reused for the rest - // of this process (disk kept — it may be a transient blip). - if used_cached_gw { - cached_gw = None; - } - match choice { - ExitChoice::Anchor => { - // Anchor unreachable (not bonded yet / condemned by the - // network / bad address): fall back to auto-select - // IMMEDIATELY — no backoff, connectivity first. - warn!( - "[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now" - ); - } - ExitChoice::Cached => { - warn!( - "[timing] nym: CACHED IPR failed to build: {e}; \ - clearing the cached exit and auto-selecting now" - ); - AppConfig::set_nym_last_ipr(None); - } - ExitChoice::Auto => { - error!( - "nym: mixnet tunnel failed to start: {e}; retrying in {}s", - delay.as_secs() - ); - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_secs(60)); - } - } - } - } - } - }); -} - -/// Exit-liveness keepalive period and the consecutive probe failures that -/// declare death (the probe is now a TCP connect through the tunnel, not UDP DNS). -const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60); -const KEEPALIVE_MAX_FAILS: u32 = 3; - -/// How long a running nostr consumer may go with ZERO reachable relays through -/// the current exit before the exit-liveness gate is consulted. Covers BOTH -/// cases the relay signal governs: an exit that never carries a relay after a -/// consumer starts dialing (relay-dead-on-arrival), and one that was carrying -/// relays and then can't re-establish any (exit went bad, as opposed to a single -/// relay bouncing — which nostr-sdk auto-reconnects within seconds, resetting -/// this timer). The timer resets on every live report, so only CONTINUOUS relay -/// absence counts. With clearnet DNS a healthy relay connects in ~1s, so this -/// window is never reached in normal operation; when it IS reached we do NOT -/// condemn on "no relay yet" alone — we first probe the exit for genuine -/// connectivity (see [`watch_tunnel`]). -const RELAY_GRACE: Duration = Duration::from_secs(25); - -/// Hard backstop: even if the exit keeps PASSING its connectivity probe (so it -/// reaches the internet) yet a consumer still has zero live relays for this -/// long, condemn anyway — this is the "exit reaches the net but its policy -/// blocks our relay port / the relay is unreachable through it" case the G14 -/// hardening guards. Long enough that a slow-but-working handshake never trips -/// it, so it can't drive a reselect loop. -const RELAY_HARD_GRACE: Duration = Duration::from_secs(90); - -/// Rebuild floor: an exit must live at least this long before the watchdog may -/// condemn+rebuild it, and `run_tunnel` waits out any remainder before selecting -/// the next exit. This bounds the reselect rate to at most once per -/// MIN_EXIT_LIFETIME no matter what, so a transient hiccup can never thrash the -/// mixnet into the 2-3 minute loop this build fixes. -const MIN_EXIT_LIFETIME: Duration = Duration::from_secs(20); - -/// The scoped-exit (money-path) mixnet dial cap: how long -/// [`super::streamexit::open_stream`] (and the HTTP exit fallback in -/// [`super::exit_connect`]) may spend bootstrapping before failing over. Without a -/// cap a DEAD pick blocked for ~74s (measured) on the Nym SDK's own "listening for -/// connection response" timeout. The TUNNEL's own build uses the shorter -/// [`TUNNEL_BUILD_TIMEOUT`]; this stays at 20s so the money path — which has no -/// tunnel to fall back to — gets more patience before it gives up. -pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20); - -/// Abandon a single `build_tunnel()` that hasn't finished within this and -/// re-select — the TUNNEL's build cap (the exit keeps [`BOOTSTRAP_TIMEOUT`] as -/// its money-path dial cap). A healthy gateway+IPR bootstrap completes in ~4-7s, -/// so 10s gives one slow-but-working build room while a dead first pick is -/// abandoned in a third of the old 30s. Runtime-overridable (seconds) via -/// `GOBLIN_NYM_BUILD_TIMEOUT` for the timing harness. -const TUNNEL_BUILD_TIMEOUT: Duration = Duration::from_secs(10); - -/// The effective tunnel build cap: [`TUNNEL_BUILD_TIMEOUT`] unless -/// `GOBLIN_NYM_BUILD_TIMEOUT` (whole seconds) overrides it. Re-read each attempt -/// so a timing harness can flip it without a restart. -fn tunnel_build_timeout() -> Duration { - std::env::var("GOBLIN_NYM_BUILD_TIMEOUT") - .ok() - .and_then(|s| s.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or(TUNNEL_BUILD_TIMEOUT) -} - -/// Watchdog poll cadence. The relay-reachability check is a bare atomic load -/// (free), so a short cadence costs nothing and never touches the network; the -/// DNS keepalive still only fires every [`KEEPALIVE_PERIOD`], preserving the -/// G13 low-power posture. -const WATCH_TICK: Duration = Duration::from_secs(5); - -/// Hold the tunnel warm and govern exit health for generation `generation`. Two -/// signals, cheapest first: -/// * relay reachability (AUTHORITATIVE, but only while a nostr consumer is -/// present — see [`RELAY_CONSUMER`]) — a bare atomic read every -/// [`WATCH_TICK`]; a consumer with zero live relays on this exit for -/// [`RELAY_GRACE`] condemns it. Without a consumer (onboarding / HTTP-only) -/// this signal is inert, so plain HTTP usage never condemns a good exit. -/// * DNS keepalive (cheaper backstop, always on) — one tiny mixnet round trip -/// every [`KEEPALIVE_PERIOD`]; [`KEEPALIVE_MAX_FAILS`] in a row condemns the -/// exit and, as a side effect, keeps the gateway/IPR session from idling out. -/// -/// Returns once either signal declares the current exit dead, whereupon -/// `run_tunnel` rebuilds on a fresh auto-selected exit. -async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) { - let published = Instant::now(); - let mut dns_fails = 0u32; - let mut since_dns = Duration::ZERO; - let mut relay_lost: Option = None; - loop { - tokio::time::sleep(WATCH_TICK).await; - // (0) External condemnation request — the eager end-to-end probe found this - // exit up (cheap probes pass) yet blackholing real HTTP. Honor it only for - // THIS generation (never a newer one): abandon the exit now so a fresh one - // is selected in seconds instead of the minutes a blackhole would otherwise - // cost. The MIN_EXIT_LIFETIME rebuild floor still bounds the reselect rate. - if CONDEMN_REQUEST_GEN.load(Ordering::Acquire) >= generation { - warn!( - "[timing] nym: CONDEMN gen {generation} reason=eager-probe-blackhole; \ - exit lived {}s, re-selecting", - published.elapsed().as_secs() - ); - return; - } - // (1) Relay reachability — authoritative, but ONLY when a nostr consumer - // actually wants relays on this exit. No consumer → the DNS keepalive - // below is the sole health signal, exactly as before this hardening. - if relay_consumer() && !relay_live_for(generation) { - let lost = *relay_lost.get_or_insert_with(Instant::now); - let absent = lost.elapsed(); - if published.elapsed() >= MIN_EXIT_LIFETIME && absent >= RELAY_GRACE { - // Past the settle floor AND relays absent for the grace. - // Don't condemn on "no relay yet" alone — first prove the exit - // itself has NO connectivity (a genuine blackhole). If the probe - // SUCCEEDS the exit reaches the internet, so relays are merely slow - // or the relay is blocked; only the HARD backstop condemns then. - let exit_reachable = super::dns::probe(tunnel).await; - if !exit_reachable { - warn!( - "[timing] nym: CONDEMN gen {generation} reason=exit-no-connectivity \ - (no relay {}s + probe failed); exit lived {}s, re-selecting", - absent.as_secs(), - published.elapsed().as_secs() - ); - return; - } - if absent >= RELAY_HARD_GRACE { - warn!( - "[timing] nym: CONDEMN gen {generation} reason=relay-blocked-{}s \ - (exit reaches net but no relay); exit lived {}s, re-selecting", - RELAY_HARD_GRACE.as_secs(), - published.elapsed().as_secs() - ); - return; - } - } - } else { - // Relay live, or no consumer demanding one: clear the timer. - relay_lost = None; - } - // (2) Backstop: cheap DNS keepalive, only every KEEPALIVE_PERIOD. This is a - // real mixnet round trip through the exit, so it is the authoritative - // "does this exit reach the internet at all" signal. - since_dns += WATCH_TICK; - if since_dns >= KEEPALIVE_PERIOD { - since_dns = Duration::ZERO; - if super::dns::probe(tunnel).await { - dns_fails = 0; - } else { - dns_fails += 1; - warn!("nym: tunnel keepalive probe failed ({dns_fails}/{KEEPALIVE_MAX_FAILS})"); - if dns_fails >= KEEPALIVE_MAX_FAILS { - warn!( - "[timing] nym: CONDEMN gen {generation} reason=keepalive-{}-fails; \ - exit lived {}s, re-selecting", - KEEPALIVE_MAX_FAILS, - published.elapsed().as_secs() - ); - return; - } - } - } - } -} - -/// Which exit the next tunnel build targets. Decided per attempt by -/// [`ExitSelector`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ExitChoice { - /// A PREFERRED public IPR exit (`GOBLIN_NYM_IPR`) tried first — the anchor - /// of the public-exit path. (The money-path anchor to a Floonet operator's - /// own co-located exit is the separate scoped-MixnetStream egress; this - /// selector governs only the public-IPR fallback layer.) - Anchor, - /// The last-known-good IPR from a previous run, tried once per process after - /// the anchor and before pure auto-select — a warm-connect hint, not a pin. - Cached, - /// A public exit auto-selected from the network pool — the FALLBACK. - Auto, -} - -impl ExitChoice { - /// Short tag for the `[timing]` logs. - fn label(self) -> &'static str { - match self { - ExitChoice::Anchor => "ANCHOR", - ExitChoice::Cached => "cached", - ExitChoice::Auto => "auto-selected", - } - } -} - -/// Prefer-with-fallback exit selection (the G14 anchor+fallback rule). A -/// SELECT CYCLE spans every build attempt between two published tunnels. The -/// policy, kept deliberately tiny so it is exhaustively unit-testable: -/// -/// * anchor configured → the FIRST attempt of each cycle targets the anchor; -/// * anchor failed (build timeout, build error or dead-exit probe) → every -/// further attempt in the SAME cycle auto-selects, so a dead anchor can -/// never lock the wallet out (this is why pin-ONLY is forbidden); -/// * a tunnel got published (either exit) → cycle over; the NEXT cycle — -/// i.e. the next reselect after a fallback — tries the anchor first again, -/// because it may have recovered while a public exit carried the traffic; -/// * no anchor configured → pure auto-select, byte-for-byte the old behavior. -/// -/// Thrash safety: the anchor adds at most one bounded attempt -/// ([`BOOTSTRAP_TIMEOUT`] + probe) per cycle, and cycles themselves are rate- -/// limited by [`MIN_EXIT_LIFETIME`] + the watchdog graces, so a permanently -/// dead anchor costs seconds per reselect, never a loop. -struct ExitSelector { - /// Whether the anchor has been tried in the current select cycle. - anchor_tried: bool, - /// Whether the cached last-known-good IPR has been tried. Unlike the anchor - /// this is ONCE PER PROCESS — a warm-connect hint spends itself and never - /// re-arms, so it can't keep re-pinning a possibly-stale exit on every cycle. - cached_tried: bool, -} - -impl ExitSelector { - const fn new() -> Self { - Self { - anchor_tried: false, - cached_tried: false, - } - } - - /// The exit to target for the next build attempt. Order per cycle: - /// anchor (if configured, once per cycle) → cached (if available, once per - /// process) → auto-select. - fn next_choice(&mut self, anchor_available: bool, cached_available: bool) -> ExitChoice { - if anchor_available && !self.anchor_tried { - self.anchor_tried = true; - ExitChoice::Anchor - } else if cached_available && !self.cached_tried { - self.cached_tried = true; - ExitChoice::Cached - } else { - ExitChoice::Auto - } - } - - /// A tunnel was published: the select cycle is over. Re-arms the anchor for - /// the next cycle. The cached slot is NOT re-armed — it is a one-shot - /// warm-connect hint (see [`cached_tried`](Self::cached_tried)). - fn tunnel_published(&mut self) { - self.anchor_tried = false; - } -} - -/// Compile-time default: building with `GOBLIN_NYM_IPR=` in the -/// environment BAKES a preferred PUBLIC IPR into the binary — the only way to -/// configure it on Android, where the app gets no user env. A runtime -/// `GOBLIN_NYM_IPR` still overrides the baked value (set it EMPTY to disable a -/// baked anchor, e.g. for a pure-auto-select measurement run). -const BAKED_ANCHOR: Option<&str> = option_env!("GOBLIN_NYM_IPR"); - -/// The PREFERRED public-IPR exit's recipient, if one is configured. Unset (no -/// runtime env, nothing baked) → `None` → pure auto-select, exactly the -/// behavior before the anchor existed — so the build works and ships fine -/// whether or not a Floonet exit is deployed. -fn anchor_recipient() -> Option { - let raw = match std::env::var("GOBLIN_NYM_IPR") { - Ok(runtime) => runtime, // runtime wins; "" disables - Err(_) => BAKED_ANCHOR?.to_string(), // baked default (release builds) - }; - parse_anchor(&raw) -} - -/// Parse an IPR recipient (`.@`). Empty or -/// whitespace disables the anchor silently; garbage warns and disables — a bad -/// placeholder degrades gracefully to pure auto-select, never a crash. -fn parse_anchor(raw: &str) -> Option { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - match raw.parse() { - Ok(recipient) => Some(recipient), - Err(e) => { - warn!("nym: ignoring invalid GOBLIN_NYM_IPR anchor (pure auto-select): {e}"); - None - } - } -} - -/// Build the tunnel — pinned to the anchor's IPR when `pin` is set, otherwise -/// with an auto-selected exit. When `entry_gateway` is set, the client REQUESTS -/// that specific first-hop gateway (a warm-connect hint) instead of a random one. -/// -/// Keys stay EPHEMERAL — a fresh mixnet identity per run, no sqlite, nothing -/// persisted about the client itself. The ONLY thing that persists across runs is -/// the gateway CHOICE (and the exit IPR), remembered by [`run_tunnel`] so a warm -/// reconnect skips re-picking a possibly-dead first hop; the requested gateway -/// resolves to `GatewaySelectionSpecification::Specified` while storage stays -/// ephemeral, so no gateway keys are written to disk. -/// -/// Returns the built tunnel PLUS the ENTRY GATEWAY it actually used (base58) and -/// the EXIT IPR recipient it rode — both captured so `run_tunnel` can persist the -/// last-known-good pair. The gateway is read from the client's own nym-address -/// BEFORE [`IpMixStream::from_client`] consumes the client. -/// -/// NEVER make the anchor the ONLY exit: `pin` must always be allowed to fall -/// back to `None` (see [`ExitSelector`]) or the single-exit SPOF — and a -/// single party seeing all exit traffic — comes back. -async fn build_tunnel( - pin: Option, - entry_gateway: Option, -) -> Result<(Tunnel, String, Recipient), smolmix::SmolmixError> { - use nym_sdk::DebugConfig; - use nym_sdk::ipr_wrapper::IpMixStream; - use nym_sdk::mixnet::MixnetClientBuilder; - - // READ-TUNNEL ANONYMITY TUNING — PUBLIC PATH ONLY. This tunes the mixnet - // client that backs the public read tunnel (relay/NIP-11/price/DoT); the - // MONEY-PATH scoped exit (`streamexit.rs`) is a SEPARATE MixnetClient and is - // deliberately left on full SDK defaults, untouched. - // - // The "balanced" preset (mirrors `Config::set_high_default_traffic_volume` - // upstream): ~10 ms average per-hop delay, ~250 real msgs/s send rate, and - // loop cover traffic effectively disabled. Per-hop delays are KEPT ON (we do - // NOT call `set_no_per_hop_delays`) so mix-layer timing obfuscation still - // applies to this public read tunnel — the tradeoff here is reduced *cover* - // traffic, not reduced mixing. - let mut cfg = DebugConfig::default(); - cfg.traffic.average_packet_delay = Duration::from_millis(10); - cfg.cover_traffic.loop_cover_traffic_average_delay = Duration::from_millis(2_000_000); - cfg.traffic.message_sending_average_delay = Duration::from_millis(4); - - // Mirror the mainnet env setup the SDK's own constructors run before connect. - // Done ONCE here (not per-raced-client): `setup_env` writes process-wide env - // vars and must not be raced across the two connect tasks on the cold path. - nym_sdk::setup_env(None::<&std::path::Path>); - - // GATEWAY CONNECT. Two shapes, both on the identical anonymity `cfg` (`Copy`): - // * WARM hint (`entry_gateway.is_some()`): reconnect to the KNOWN-good first - // hop — a Specified gateway, ephemeral storage, no persisted keys. NO race: - // we want that specific gateway. - // * COLD / auto (`entry_gateway.is_none()`): the first hop is a RANDOM draw and - // a dead draw blocks `connect_to_mixnet()` until `run_tunnel`'s 10s cap, with - // consecutive dead draws stacking into a multi-second tail. Race TWO ephemeral - // gateway connects and take the first up (see `connect_gateway_racing`). Only - // the gateway handshake is doubled — the exit/IPR below is still built ONCE. - let client = match entry_gateway { - Some(gw) => { - MixnetClientBuilder::new_ephemeral() - .debug_config(cfg) - .request_gateway(gw) - .build()? - .connect_to_mixnet() - .await? - } - None => connect_gateway_racing(cfg).await?, - }; - - // Capture the ENTRY GATEWAY actually used, from the client's own nym-address, - // BEFORE `from_client` consumes the client. - let entry_gw = client.nym_address().gateway().to_base58_string(); - - // Pinned anchor/cached exit when provided, else the auto-selected best public - // IPR — the same discovery the untuned `IpMixStream::new` path used, so - // anchor/fallback selection in `run_tunnel` is unchanged. - let ipr = match pin { - Some(recipient) => recipient, - None => IpMixStream::best_ipr().await?, - }; - let stream = IpMixStream::from_client(client, ipr).await?; - let tunnel = Tunnel::from_stream(stream).await?; - Ok((tunnel, entry_gw, ipr)) -} - -/// Cold/auto gateway connect with a BOUNDED latency tail — the fix for the Nym -/// cold-start "gateway lottery". Used ONLY on the auto path (no warm hint), where -/// the entry gateway is a RANDOM draw: a dead draw blocks `connect_to_mixnet()` -/// until `run_tunnel`'s 10s cap and consecutive dead draws stack into the tail. -/// -/// Race EXACTLY TWO ephemeral `MixnetClient`s — IDENTICAL anonymity `cfg`, -/// ephemeral keys, nothing persisted — through the gateway handshake and return -/// the FIRST that connects. Only the gateway handshake is doubled; the caller -/// builds the exit/IPR ONCE on the winner. Two (not more) bounds the Nym -/// free-tier bandwidth burst. -/// -/// The loser is REAPED so a CONNECTED client is never leaked: it is aborted (a -/// still-pending connect just drops its half-built client) and, in a DETACHED task -/// so the winner returns immediately, `disconnect()`ed IFF it had already -/// connected. If BOTH draws fail, the error is returned so `run_tunnel`'s loop -/// re-selects — the same contract as the single build. -async fn connect_gateway_racing( - cfg: nym_sdk::DebugConfig, -) -> Result { - use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder}; - - async fn connect_one(cfg: nym_sdk::DebugConfig) -> Result { - Ok(MixnetClientBuilder::new_ephemeral() - .debug_config(cfg) - .build()? - .connect_to_mixnet() - .await?) - } - - // Spawn both so the loser can be aborted cleanly. `cfg` is `Copy`, so each task - // gets the identical anonymity config. - let race_started = Instant::now(); - let mut a = tokio::spawn(connect_one(cfg)); - let mut b = tokio::spawn(connect_one(cfg)); - debug!("[timing] nym: gateway race START — 2 ephemeral draws, first up wins"); - - // Whichever finishes first; keep `other` to reap (on a win) or fall back to (if - // the first draw errored). `winner` tags WHICH draw finished first. - let (first, other, winner) = tokio::select! { - r = &mut a => (r, b, 'A'), - r = &mut b => (r, a, 'B'), - }; - // A JoinError (task panic) folds into an error so `other` still gets its turn. - let first = first.unwrap_or_else(|e| { - Err(smolmix::SmolmixError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("nym gateway connect task failed: {e}"), - ))) - }); - - match first { - // First to finish connected — it WINS. Reap the loser off the hot path. - Ok(client) => { - info!( - "[timing] nym: gateway race WON by draw {winner} in {}ms; reaping loser off the hot path", - race_started.elapsed().as_millis() - ); - other.abort(); - tokio::spawn(async move { - // If the loser connected before the abort landed, disconnect it so - // no live gateway session leaks; a pending connect was just dropped. - match other.await { - Ok(Ok(loser)) => { - debug!( - "[timing] nym: gateway race loser had connected before abort — \ - disconnecting so no gateway session leaks" - ); - loser.disconnect().await; - } - _ => debug!( - "[timing] nym: gateway race loser still pending at reap — dropped \ - (no session to close)" - ), - } - }); - Ok(client) - } - // First draw failed — a lone client has no dead-draw tail, so just await the - // survivor; if it fails too, surface an error and `run_tunnel` re-selects. - Err(first_err) => match other.await { - Ok(Ok(client)) => { - info!( - "[timing] nym: gateway race — draw {winner} errored, survivor connected in {}ms", - race_started.elapsed().as_millis() - ); - Ok(client) - } - Ok(Err(second_err)) => { - warn!( - "[timing] nym: both raced gateway connects failed \ - ({first_err}; {second_err}); run_tunnel will re-select" - ); - Err(second_err) - } - Err(_join) => Err(first_err), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn no_anchor_is_pure_auto_select() { - let mut s = ExitSelector::new(); - for _ in 0..5 { - assert_eq!(s.next_choice(false, false), ExitChoice::Auto); - } - // Publishing changes nothing without an anchor. - s.tunnel_published(); - assert_eq!(s.next_choice(false, false), ExitChoice::Auto); - } - - #[test] - fn anchor_first_then_auto_within_a_cycle() { - let mut s = ExitSelector::new(); - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - // Anchor failed — every further attempt in the cycle falls back. - assert_eq!(s.next_choice(true, false), ExitChoice::Auto); - assert_eq!(s.next_choice(true, false), ExitChoice::Auto); - } - - #[test] - fn anchor_retried_on_the_next_cycle_after_a_fallback() { - let mut s = ExitSelector::new(); - // Cycle 1: anchor fails, a fallback exit gets published. - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - assert_eq!(s.next_choice(true, false), ExitChoice::Auto); - s.tunnel_published(); - // Cycle 2 (the reselect after the fallback): anchor first again. - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - } - - #[test] - fn anchor_publish_also_rearms_the_anchor() { - let mut s = ExitSelector::new(); - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - s.tunnel_published(); // the anchor itself came up - // Condemned later → next cycle prefers the anchor again. - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - } - - #[test] - fn anchor_appearing_mid_cycle_is_tried() { - let mut s = ExitSelector::new(); - // No anchor yet (env unset / invalid): auto, without burning the try. - assert_eq!(s.next_choice(false, false), ExitChoice::Auto); - // Anchor becomes available (env fixed mid-run): tried on the next attempt. - assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); - assert_eq!(s.next_choice(true, false), ExitChoice::Auto); - } - - #[test] - fn cached_after_anchor_then_auto_within_a_cycle() { - let mut s = ExitSelector::new(); - // Order per cycle: anchor → cached → auto. - assert_eq!(s.next_choice(true, true), ExitChoice::Anchor); - assert_eq!(s.next_choice(true, true), ExitChoice::Cached); - assert_eq!(s.next_choice(true, true), ExitChoice::Auto); - assert_eq!(s.next_choice(true, true), ExitChoice::Auto); - } - - #[test] - fn cached_tried_before_auto_when_no_anchor() { - let mut s = ExitSelector::new(); - // No anchor, but a cached hint exists: cached first, then auto. - assert_eq!(s.next_choice(false, true), ExitChoice::Cached); - assert_eq!(s.next_choice(false, true), ExitChoice::Auto); - } - - #[test] - fn cached_is_one_shot_across_the_whole_process() { - let mut s = ExitSelector::new(); - // Spend the cached hint in cycle 1. - assert_eq!(s.next_choice(false, true), ExitChoice::Cached); - assert_eq!(s.next_choice(false, true), ExitChoice::Auto); - s.tunnel_published(); - // Cycle 2: the cached slot never re-arms, even if still "available". - assert_eq!(s.next_choice(false, true), ExitChoice::Auto); - // And with an anchor present the anchor is still retried each cycle. - assert_eq!(s.next_choice(true, true), ExitChoice::Anchor); - assert_eq!(s.next_choice(true, true), ExitChoice::Auto); - } - - #[test] - fn parse_anchor_disables_on_empty_or_garbage() { - assert!(parse_anchor("").is_none()); - assert!(parse_anchor(" ").is_none()); - assert!(parse_anchor("placeholder").is_none()); - assert!(parse_anchor("not.a@recipient").is_none()); - // A dead-but-well-formed anchor is exercised end to end by the - // connect_timing harness instead (needs a live mixnet). - } -} diff --git a/src/nym/streamexit.rs b/src/nym/streamexit.rs deleted file mode 100644 index c7904571..00000000 --- a/src/nym/streamexit.rs +++ /dev/null @@ -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; - -/// 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> = 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 -/// (`.@`) 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 { - 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 { - 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= 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::()); - 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, - } - } - } -} diff --git a/src/nym/transport.rs b/src/nym/transport.rs deleted file mode 100644 index 53eadf77..00000000 --- a/src/nym/transport.rs +++ /dev/null @@ -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) -> 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(ws: tokio_tungstenite::WebSocketStream) -> (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 { - 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); - -impl Sink for NymSink -where - S: Sink + Send + Unpin, -{ - type Error = TransportError; - - fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - 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> { - 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> { - Pin::new(&mut self.0) - .poll_close_unpin(cx) - .map_err(TransportError::backend) - } -} diff --git a/src/settings/config.rs b/src/settings/config.rs index f96433ec..b4b8e9e6 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -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, - - /// 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, - /// Last-known-good Nym IPR exit recipient (the `.@` string), so a - /// warm reconnect can try the exit that worked last time before auto-selecting. - nym_last_ipr: Option, } /// 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 { - 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) { - 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 { - 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) { - 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(); diff --git a/src/tor/engine.rs b/src/tor/engine.rs index 36890fe7..c69220a0 100644 --- a/src/tor/engine.rs +++ b/src/tor/engine.rs @@ -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); } diff --git a/src/tor/mod.rs b/src/tor/mod.rs index 7e677c70..c67dc6bd 100644 --- a/src/tor/mod.rs +++ b/src/tor/mod.rs @@ -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. diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index f0f8fc47..8081aa0f 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -124,7 +124,7 @@ pub struct Wallet { syncing: Arc, /// 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, /// 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.