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.