Goblin Build 134 - Tor delivery fixes (drop the flapping onion)

build133 shipped the Tor transport but two delivery bugs remained; this
fixes both so payments resolve through confirmations again.

- Drop the pinned relay .onion. It was a single fragile hidden-service hop
  shared by every wallet; under load it flapped (WebSocket 1006), and when
  it dropped mid-handshake a payment stalled after the first gift-wrap --
  you'd get the incoming alert but the money never confirmed. Every relay is
  now reached over the Tor exit to its clearnet host: your IP still stays
  hidden from the relay, with no fragile onion hop to wedge. The has-onion
  gate that rejected an onion-less relay list is removed, and the pinned
  candidate pool carries no onion/exit fields.

- Tor-friendly relay set. Defaults/fallbacks had included relays that refuse
  Tor-exit connections (damus, nos.lol); wallets that fell back to them
  couldn't send at all. Defaults and the pinned pool are now
  relay.floonet.dev, relay.0xchat.com, offchain.pub.

- confirm-before-sent no longer false-fails across a relay reconnect (a
  confirmed-received wrap is treated as sent-pending instead of re-dispatched,
  which had duplicated wraps and hung the spinner); a v3 gift-wrap unwrap
  failure no longer silently drops the message.

Validated over Tor with two funded wallets on two independent mainnet nodes:
connect in seconds, send to finalize in 8s.
This commit is contained in:
2ro
2026-07-04 05:33:10 -04:00
parent 03c1770892
commit 1e55ef5dfb
5 changed files with 139 additions and 215 deletions
+86 -37
View File
@@ -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<String>, 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<String> = 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<NostrService>, 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;
}
};
+21 -83
View File
@@ -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<String>,
/// This relay's pinned `.onion` address (`<host>.onion`, optional `:port`),
/// when its operator fronts the relay with a Tor onion service. The wallet
/// dials this over embedded Tor and speaks PLAIN websocket to it (the onion
/// connection is already encrypted+authenticated end to end). Absent → the
/// relay is reached over a Tor exit to its clearnet host instead. Added beside
/// `exit` the same tolerant way (no `deny_unknown_fields`, `version` stays 1),
/// so OLDER builds simply ignore it — no schema break, no flag day. Carried in
/// the pinned pool so the money-path relay's onion bootstraps OFFLINE.
#[serde(default)]
pub onion: Option<String>,
}
impl PoolRelay {
@@ -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<String> {
let want = url.trim_end_matches('/');
self.relays
.iter()
.find(|r| r.url.trim_end_matches('/') == want)
.and_then(|r| r.onion.clone())
.filter(|o| !o.trim().is_empty())
}
/// Whether ANY relay in the pool pins an `.onion`. Used to prefer a pool that
/// carries the money-path onion (see [`load`]).
pub fn has_onion(&self) -> bool {
self.relays
.iter()
.any(|r| r.onion.as_deref().is_some_and(|o| !o.trim().is_empty()))
}
}
/// Disk path of the cached pool file.
@@ -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);
+12 -3
View File
@@ -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.
+5 -45
View File
@@ -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
+15 -47
View File
@@ -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<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,