diff --git a/src/nostr/client.rs b/src/nostr/client.rs index d71a5877..5dd6b8a4 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -59,12 +59,12 @@ const LOOKBACK_SECS: i64 = 3 * 86_400; const FETCH_TIMEOUT: Duration = Duration::from_secs(30); /// Send dispatch timeout. const SEND_TIMEOUT: Duration = Duration::from_secs(40); -/// Money-path safety: a payment/control DM is only reported "sent" once a relay -/// is confirmed to actually hold the gift wrap. A transport-write success is NOT -/// proof of delivery — over the transport a wrap can trail its local "sent" by -/// seconds (transport buffering / a slow relay), so reporting on the write alone -/// silently loses payments. Total budget to confirm via read-back before -/// surfacing failure to the caller. +/// Money-path safety: total budget to read-back-confirm a dispatched wrap on a +/// relay the recipient reads (a positive delivery proof — a transport-write +/// success alone is not). The confirm retries across transient transport drops +/// within this budget; on exhaustion the send is treated as sent-PENDING (the tx +/// waits for S2 / expiry), NOT a hard failure — see the confirm loop in +/// `dispatch_dm` for why a hard failure here would trigger duplicate re-dispatch. const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30); /// Per-attempt read-back timeout while confirming (short, so one dead relay /// doesn't consume the whole confirm budget in a single poll). @@ -614,30 +614,52 @@ impl NostrService { .map_err(|e| format!("send failed: {e}"))?; let event_id = res.val; - // SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the - // moment the gift wrap is written to the transport sink — NOT when a relay - // has actually stored it. Over the transport a wrap can trail its local - // "sent" by seconds (transport buffering / a slow relay), so a bare success - // is a FALSE "sent" that silently loses the payment. Require a genuine - // read-back: poll the target relays for the event id (it may still be in - // flight right after send) until one confirms it holds the wrap, or the - // CONFIRM_TIMEOUT budget is spent — then surface failure so the caller - // retries / falls back instead of dropping the payment. - let confirm_filter = Filter::new().id(event_id).limit(1); + // DELIVERY CONFIRM (money-path safety), reconnect-resilient. `send_*_to` + // returned success the moment the wrap was accepted for delivery to the + // relays — that IS write-level evidence, but not proof a relay the RECIPIENT + // reads has stored it. Confirm the way the recipient's inbox retrieves it: + // query {kinds:[1059], "#p":[receiver]} pinned to THIS wrap's id, over the + // SAME target set — which now always includes our own advertised relays + // (the shared-relay floor the recipient also reads; see `send_targets`). + // First-event-wins via `stream_events_from` returns the instant ANY + // targeted relay serves the wrap, so the spinner clears as fast as it lands. + // The loop retries across transient transport drops within the budget + // (arti rebuilds circuits during the CONFIRM_GAP sleeps), so a flapping + // onion doesn't defeat a wrap that actually landed. + // + // Two outcomes, NEVER a hard failure here (we already hold write-evidence): + // * confirmed -> Ok, report Sent (strongest evidence). + // * unconfirmed within the budget -> Ok as sent-PENDING, logged. Over a + // flapping onion the read-back can't complete even though the write + // landed; a hard error would mark the tx SendFailed, which `reconcile` + // re-dispatches -> DUPLICATE wraps + a stuck "sending" spinner. Instead + // the tx moves to AwaitingS2 (which `reconcile` never re-dispatches) and + // the normal S2-wait / expiry path resolves it. Only a genuine send + // FAILURE (the `send_*_to` error above, BEFORE this loop) ever + // re-dispatches — so we never claim Sent with zero evidence. + use futures::StreamExt; + let confirm_filter = Filter::new() + .kind(Kind::GiftWrap) + .pubkey(receiver) + .id(event_id) + .limit(1); let confirm_deadline = tokio::time::Instant::now() + CONFIRM_TIMEOUT; loop { - if let Ok(events) = client - .fetch_events_from(&urls, confirm_filter.clone(), CONFIRM_POLL) - .await && events.first().is_some() + if let Ok(mut stream) = client + .stream_events_from(urls.clone(), confirm_filter.clone(), CONFIRM_POLL) + .await && stream.next().await.is_some() { return Ok(event_id.to_hex()); } if tokio::time::Instant::now() >= confirm_deadline { - return Err(format!( - "payment not confirmed on any relay within {}s — the transport \ - reported it sent but no relay holds it yet; treat as UNSENT and retry", + warn!( + "nostr: wrap {} dispatched but not read-back-confirmed within {}s \ + (likely a transient transport drop); treating as sent-pending — \ + tx waits for S2 / expiry, NOT re-dispatched", + event_id.to_hex(), CONFIRM_TIMEOUT.as_secs() - )); + ); + return Ok(event_id.to_hex()); } tokio::time::sleep(CONFIRM_GAP).await; } @@ -645,27 +667,42 @@ impl NostrService { /// Publish targets for one DM plus the negotiated NIP-44 v3 capability: /// the recipient's advertised 10050 inbox (capped at 3) when they publish - /// one; otherwise the pragmatic fallback of nprofile relay hints plus our - /// own relay set (most Goblin peers share the Goblin relay). No extra - /// targets beyond that — wider fan-out adds metadata surface, not - /// deliverability. `true` means the recipient's 10050 `encryption` tag - /// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only. + /// one, PLUS the nprofile relay hints, ALWAYS unioned with our OWN advertised + /// set. `true` means the recipient's 10050 `encryption` tag advertises + /// `nip44_v3`; no tag (or no 10050 at all) = v2 only. + /// + /// MONEY-PATH SAFETY: we must NEVER return a target set that excludes our own + /// relays. Our advertised set always begins with the shared relay floor + /// (`relay.floonet.dev`, `DEFAULT_RELAYS[0]`, pinned first by + /// `ensure_advertised_set`), and every Goblin peer's inbox subscription + /// (`{kinds:[1059], "#p":[them]}`, see the service loop) likewise reads that + /// same shared relay. The prior code early-returned ONLY the recipient's + /// cached 10050 set: if that cache was stale or hint-seeded and missed the + /// shared relay, the wrap was published solely to relays the recipient never + /// reads — delivered nowhere while the sender saw success. Unioning our own + /// set guarantees the wrap always lands on a relay both parties read, even + /// when the recipient's cached relays are wrong. async fn send_targets( &self, client: &Client, receiver: &PublicKey, relay_hints: &[String], ) -> (Vec, bool) { - let (urls, v3) = self.fetch_dm_relays(client, receiver).await; - if !urls.is_empty() { - return (urls, v3); - } + let (recipient_relays, v3) = self.fetch_dm_relays(client, receiver).await; let mut urls: Vec = vec![]; - for r in relay_hints { - if !urls.contains(r) { - urls.push(r.clone()); + // The recipient's own advertised inbox first (best delivery target when + // fresh), then any nprofile relay hints... + for r in recipient_relays + .into_iter() + .chain(relay_hints.iter().cloned()) + { + if !urls.contains(&r) { + urls.push(r); } } + // ...and ALWAYS our own advertised set (the shared-relay floor). This is + // the load-bearing union: it never lets a stale recipient cache exclude + // the relay both parties actually read. for r in self.relays() { if !urls.contains(&r) { urls.push(r); @@ -1506,8 +1543,20 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly. let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await { Ok(u) => u, - Err(_) => { - svc.store.mark_processed(&wrap_id); + Err(e) => { + // A gift wrap that reached here is p-tagged to US (both the catch-up + // fetch and the live subscription filter name our pubkey), so a decrypt + // failure is a wrap ADDRESSED to us that we could not open — most often a + // NIP-44 v2/v3 negotiation mismatch or a decrypt bug, i.e. potentially a + // real incoming payment. Do NOT silently drop it: surface the failure, + // and do NOT mark it processed, so a corrected build can re-attempt the + // unwrap on the next catch-up instead of the dedup cache eating the + // payment forever. Total unwrap work stays bounded by the global decrypt + // ceiling checked above, which is the designed spam guard. + warn!( + "nostr: gift wrap {wrap_id} addressed to us failed to unwrap: {e}; \ + leaving unprocessed for retry" + ); return; } }; diff --git a/src/nostr/pool.rs b/src/nostr/pool.rs index 2cd80343..5666a12f 100644 --- a/src/nostr/pool.rs +++ b/src/nostr/pool.rs @@ -62,22 +62,13 @@ const MIN_BACKDATE_SECS: u64 = 172_800; /// offline behave exactly like a fresh fetch. const PINNED_POOL: &str = r#"{ "version": 1, - "updated": "2026-07-02", - "notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.", + "updated": "2026-07-04", + "notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. Every relay is reached over a Tor exit to its clearnet host, so the wallet's IP stays hidden behind Tor.", "min_message_length": 131072, "relays": [ - { "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe", "onion": "m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion" }, - { "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://relay.0xchat.com", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://relay.snort.social", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://nostr.mom", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://nostr.oxtr.dev", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://relay.nostr.net", "roles": ["dm"], "vetted": "2026-07-01" }, - { "url": "wss://purplepag.es", "roles": ["discovery"], "vetted": "2026-07-01" }, - { "url": "wss://indexer.coracle.social", "roles": ["discovery"], "vetted": "2026-07-01" } + { "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-04" }, + { "url": "wss://relay.0xchat.com", "roles": ["dm", "discovery"], "vetted": "2026-07-04" }, + { "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-04" } ] }"#; @@ -103,16 +94,6 @@ pub struct PoolRelay { /// it is meant to replace. #[serde(default)] pub exit: Option, - /// This relay's pinned `.onion` address (`.onion`, optional `:port`), - /// when its operator fronts the relay with a Tor onion service. The wallet - /// dials this over embedded Tor and speaks PLAIN websocket to it (the onion - /// connection is already encrypted+authenticated end to end). Absent → the - /// relay is reached over a Tor exit to its clearnet host instead. Added beside - /// `exit` the same tolerant way (no `deny_unknown_fields`, `version` stays 1), - /// so OLDER builds simply ignore it — no schema break, no flag day. Carried in - /// the pinned pool so the money-path relay's onion bootstraps OFFLINE. - #[serde(default)] - pub onion: Option, } impl PoolRelay { @@ -199,27 +180,6 @@ impl RelayPool { .iter() .any(|r| r.exit.as_deref().is_some_and(|e| !e.trim().is_empty())) } - - /// The pinned `.onion` for `url`, if the pool advertises one (url compared - /// modulo a trailing slash). `None` → reach the relay over a Tor exit to its - /// clearnet host. This is how the wallet learns the money-path relay's onion - /// (see [`PoolRelay::onion`]). - pub fn onion_for(&self, url: &str) -> Option { - let want = url.trim_end_matches('/'); - self.relays - .iter() - .find(|r| r.url.trim_end_matches('/') == want) - .and_then(|r| r.onion.clone()) - .filter(|o| !o.trim().is_empty()) - } - - /// Whether ANY relay in the pool pins an `.onion`. Used to prefer a pool that - /// carries the money-path onion (see [`load`]). - pub fn has_onion(&self) -> bool { - self.relays - .iter() - .any(|r| r.onion.as_deref().is_some_and(|o| !o.trim().is_empty())) - } } /// Disk path of the cached pool file. @@ -233,12 +193,6 @@ pub fn load() -> RelayPool { std::fs::read_to_string(cache_path()) .ok() .and_then(|raw| RelayPool::parse(&raw)) - // A cache written by a pre-Tor build parses fine but hides the onion - // money path (and the current primary relay) for up to CACHE_MAX_AGE_SECS - // after an app update — relay connects then have no onion to dial for days. - // The pinned pool is newer than any onion-less file, so prefer it until the - // next gist refresh. - .filter(RelayPool::has_onion) .unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses")) } @@ -256,14 +210,7 @@ pub async fn refresh_if_stale() { .and_then(|m| m.modified().ok()) .and_then(|t| t.elapsed().ok()) .map(|age| age.as_secs() < CACHE_MAX_AGE_SECS) - .unwrap_or(false) - // An onion-less cache predates the current pool shape (see `load`, - // which already ignores it) — replace it now instead of serving the - // pinned fallback for the rest of the file's 7 days. - && std::fs::read_to_string(&path) - .ok() - .and_then(|raw| RelayPool::parse(&raw)) - .is_some_and(|p| p.has_onion()); + .unwrap_or(false); if fresh { return; } @@ -411,37 +358,30 @@ mod tests { let pool = RelayPool::parse(PINNED_POOL).expect("pinned pool must parse"); assert_eq!(pool.version, 1); assert_eq!(pool.min_message_length, MIN_MESSAGE_LENGTH); - assert_eq!(pool.relays.len(), 12); + // Three Tor-friendly relays matching the live gist; no relay pins an onion + // any more (the onion money path was dropped in build134 — every relay is + // reached over a Tor exit to its clearnet host). + assert_eq!(pool.relays.len(), 3); let dm = pool.dm_relays(); - assert_eq!(dm.len(), 10); + assert_eq!(dm.len(), 3); assert!(dm.iter().any(|r| r.url == "wss://relay.floonet.dev")); assert!(dm.iter().all(|r| r.vetted.is_some())); - // The money-path relay pins its .onion so the Tor transport bootstraps - // OFFLINE, before any network; every other relay is onion-less (reached - // over a Tor exit). - assert!(pool.has_onion()); - assert_eq!( - pool.onion_for("wss://relay.floonet.dev"), - Some("m2ji5o6p6qapd4ies4wua64skjx2emd6lrp7hhvrib33ogveyihopryd.onion".to_string()) - ); - assert!(pool.onion_for("wss://nos.lol").is_none()); let disc = pool.discovery_relays(); - // relay.floonet.dev carries both roles; the two indexers - // are discovery-only. - assert_eq!(disc.len(), 3); - assert!(disc.contains(&"wss://purplepag.es".to_string())); - assert!(disc.contains(&"wss://indexer.coracle.social".to_string())); + // relay.floonet.dev and relay.0xchat.com carry the discovery role too. + assert_eq!(disc.len(), 2); + assert!(disc.contains(&"wss://relay.floonet.dev".to_string())); + assert!(disc.contains(&"wss://relay.0xchat.com".to_string())); } #[test] fn exit_field_is_optional_and_looked_up_by_url() { - // The pinned pool advertises the money-path relay's co-located scoped - // exit (the .AF floonet-mixexit) so it bootstraps OFFLINE, before any - // network; every other relay is exit-less (reached over the tunnel). + // 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 + // 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. let pinned = RelayPool::parse(PINNED_POOL).unwrap(); - assert!(pinned.has_exit()); - assert!(pinned.exit_for("wss://relay.floonet.dev").is_some()); - assert!(pinned.exit_for("wss://nos.lol").is_none()); + assert!(!pinned.has_exit()); + assert!(pinned.exit_for("wss://relay.floonet.dev").is_none()); // A pool that DOES advertise an exit for one relay. let pool = RelayPool::parse( @@ -545,7 +485,6 @@ mod tests { roles: vec!["dm".to_string()], vetted: vetted.then(|| "2026-07-01".to_string()), exit: None, - onion: None, }; vec![ mk("wss://a.example", false), @@ -571,7 +510,6 @@ mod tests { roles: vec!["dm".to_string()], vetted: Some("2026-07-01".to_string()), exit: None, - onion: None, }); let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0); assert_eq!(order.len(), 4); diff --git a/src/nostr/relays.rs b/src/nostr/relays.rs index 5244fc0e..a5731c0d 100644 --- a/src/nostr/relays.rs +++ b/src/nostr/relays.rs @@ -14,11 +14,20 @@ //! Default relay set and relay list helpers. -/// Default DM relays: the Floonet relay plus large public relays for redundancy. +/// Default DM relays: the Floonet relay (the pinned shared floor) plus +/// Tor-reachable public relays for redundancy. +/// +/// TRANSPORT CONSTRAINT: Goblin dials every relay over Tor, so the defaults MUST +/// be relays that accept Tor-exit connections. `relay.damus.io` and `nos.lol` +/// throttle/block Tor exits — a wallet left on the raw defaults (e.g. when pool +/// selection hasn't run or found nothing) then had NO working fallback whenever +/// the Floonet onion flapped, so its payments stopped flowing. `relay.0xchat.com` +/// and `offchain.pub` are Tor-friendly (and are also probe-vetted pool `dm` +/// candidates), giving a real fallback that survives an onion drop. pub const DEFAULT_RELAYS: &[&str] = &[ "wss://relay.floonet.dev", - "wss://relay.damus.io", - "wss://nos.lol", + "wss://relay.0xchat.com", + "wss://offchain.pub", ]; /// Default NIP-05 identity server. diff --git a/src/tor/mod.rs b/src/tor/mod.rs index 80f49631..957900d4 100644 --- a/src/tor/mod.rs +++ b/src/tor/mod.rs @@ -15,12 +15,11 @@ //! Embedded-Tor transport. Everything Goblin sends over the network — nostr relay //! websockets and every HTTP request (NIP-05, price, relay pool, avatars) — rides //! Tor, embedded in-process (arti), copied from our sister wallet GRIM's proven, -//! shipping engine. The wallet dials the relay's pinned `.onion` over Tor and -//! speaks PLAIN websocket to it (the onion connection is already encrypted + -//! authenticated end to end — the `.onion` address IS the relay's public key — so -//! a TLS wrapper is redundant and the relay backend does not serve it). Relays -//! WITHOUT a pinned onion (e.g. a recipient's arbitrary DM relay) are reached over -//! a Tor exit to their clearnet host, with the usual TLS for `wss://`. +//! shipping engine. Every relay is reached over a Tor exit to its clearnet host, +//! with the usual hostname-validated TLS for `wss://`: the wallet's own IP is +//! never exposed, while the relay stays a normal public endpoint. (Earlier builds +//! 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 @@ -81,45 +80,6 @@ pub(crate) fn cache_path() -> String { base.to_str().unwrap().to_string() } -// --- Onion resolution --------------------------------------------------------- - -/// The pinned `.onion` `(host, port)` for a relay `url`, if one is configured. -/// The `GOBLIN_TOR_ONION` env override (for ad-hoc testing) wins; otherwise the -/// pool's per-relay `onion` field ([`crate::nostr::pool::RelayPool::onion_for`]). -/// `None` → this relay has no onion and is reached over a Tor exit to its clearnet -/// host instead (see [`transport`]). -pub(crate) fn onion_for(url: &str) -> Option<(String, u16)> { - if let Ok(env) = std::env::var("GOBLIN_TOR_ONION") { - let env = env.trim(); - if !env.is_empty() { - return parse_onion(env); - } - } - crate::nostr::pool::load() - .onion_for(url) - .and_then(|o| parse_onion(&o)) -} - -/// Parse an onion target `host[:port]` → `(host, port)`. Defaults to port 80 -/// (plain ws over the onion). Tolerant of a `ws://`/`http://` prefix and a -/// trailing slash so the pinned string may be written either way. -fn parse_onion(s: &str) -> Option<(String, u16)> { - let s = s - .trim() - .trim_start_matches("ws://") - .trim_start_matches("http://") - .trim_end_matches('/'); - if s.is_empty() { - return None; - } - match s.rsplit_once(':') { - Some((host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => { - Some((host.to_string(), port.parse().ok()?)) - } - _ => Some((s.to_string(), 80)), - } -} - // --- HTTP over Tor ------------------------------------------------------------ /// An HTTP request routed over Tor: dial the host over Tor (an onion via a real diff --git a/src/tor/transport.rs b/src/tor/transport.rs index 216f3943..6de0f480 100644 --- a/src/tor/transport.rs +++ b/src/tor/transport.rs @@ -12,19 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! WebSocket transport for the Nostr relay pool routed over embedded Tor. -//! ANCHOR: a relay whose pool entry pins an `.onion` -//! ([`crate::nostr::pool::PoolRelay::onion`]) is dialed straight to that onion -//! over Tor — a real onion circuit, no exit node — and spoken to in PLAIN -//! websocket ([`tokio_tungstenite::client_async`]). The onion connection is -//! already encrypted AND authenticated end to end (the `.onion` address IS the -//! relay's public key), so a TLS wrapper is redundant and the relay backend does -//! not serve it. EXIT PATH (every relay without a pinned onion — e.g. a -//! recipient's arbitrary DM relay a send fans out to): dial the relay's clearnet -//! host over a Tor exit and run the usual hostname-validated TLS + websocket -//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. Either way the payload -//! and in-flight destination never touch the clear, and the wallet's own IP is -//! never exposed. +//! WebSocket transport for the Nostr relay pool routed over embedded Tor. Every +//! relay is dialed the same way: reach its clearnet host over a Tor exit and run +//! the usual hostname-validated TLS + websocket +//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. The payload and the +//! in-flight destination never touch the clear, and the wallet's own IP is never +//! exposed. (Earlier builds could dial a relay's pinned `.onion` directly over a +//! real onion circuit as a money-path anchor; that was dropped in build134 — the +//! onion services flapped — leaving the Tor-exit path as the only one.) use std::pin::Pin; use std::task::{Context, Poll}; @@ -70,37 +65,11 @@ impl WebSocketTransport for TorWebSocketTransport { return Err(terr("tor client not bootstrapped")); } - // MONEY-PATH ANCHOR: when the pool pins this relay's `.onion`, dial it - // directly over Tor and speak PLAIN websocket — the onion connection is - // already encrypted+authenticated end to end (the `.onion` IS the - // relay's public key), so no TLS on top. - if let Some((onion, port)) = crate::tor::onion_for(url.as_str()) { - let t = Instant::now(); - let stream = tokio::time::timeout(timeout, crate::tor::connect(&onion, port)) - .await - .map_err(|_| terr("tor onion connect timeout"))? - .map_err(terr)?; - // PLAIN ws over the onion (client_async, NOT client_async_tls). The - // handshake targets the onion host itself. - let ws_url = format!("ws://{onion}/"); - let (ws, _response) = tokio::time::timeout( - timeout, - tokio_tungstenite::client_async(ws_url.as_str(), stream), - ) - .await - .map_err(|_| terr("websocket handshake timeout (onion)"))? - .map_err(|e| terr(format!("websocket handshake failed (onion): {e}")))?; - log::info!( - "[timing] tor: relay {host} CONNECTED via onion — stream+ws {}ms", - t.elapsed().as_millis() - ); - return Ok(split_ws(ws)); - } - - // EXIT PATH: no pinned onion → reach the relay's clearnet host over a - // Tor exit, with the usual TLS + websocket for wss (SNI = the relay - // host). This is what lets a send fan out to a recipient's arbitrary - // public DM relays over Tor. + // Reach the relay's clearnet host over a Tor exit, with the usual + // hostname-validated TLS + websocket for wss (SNI = the relay host). + // This is the single dial path: the wallet's IP never leaves Tor, and a + // send fans out to a recipient's arbitrary public DM relays exactly the + // way it reaches the shared floor relay (relay.floonet.dev). let port = url.port().unwrap_or(match url.scheme() { "ws" => 80, _ => 443, @@ -126,9 +95,8 @@ impl WebSocketTransport for TorWebSocketTransport { } } -/// Split a websocket into the pool's boxed sink/stream halves — shared by the -/// onion and exit dial paths, so everything above the byte transport is identical -/// whichever egress carried the connection. +/// Split a websocket into the pool's boxed sink/stream halves, so everything above +/// the byte transport is identical regardless of which Tor circuit carried it. fn split_ws(ws: tokio_tungstenite::WebSocketStream) -> (WebSocketSink, WebSocketStream) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,