From 6d7a0d65744bc6aaf92950799af230b2e02bba5a Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:31:36 -0400 Subject: [PATCH] goblin: remember relays per wallet, keyed by transport The "Nostr Relays" editor saved a single transport-agnostic override that won on both Tor and clearnet, so a list edited on one network silently replaced the other. Split the persisted override into relays_tor and relays_clearnet in nostr.toml so each network keeps its own remembered list across app updates; the legacy `relays` field stays honored as a fallback for whichever transport has no explicit set (no migration). If the user has never edited, behavior is unchanged: the pinned TOR_RELAYS set on Tor, the per-identity random healthy subset on clearnet. relay.floonet.dev is now pinned first on every resolved override via pin_floonet() and can no longer be removed in the editor, so any two wallets always share a rendezvous regardless of per-wallet edits. Adds round-trip / precedence unit tests for the config and the pin helper. --- src/gui/views/goblin/settings_node.rs | 17 +++-- src/nostr/client/mod.rs | 7 +- src/nostr/client/service.rs | 4 +- src/nostr/config.rs | 102 +++++++++++++++++++++++--- src/nostr/relays.rs | 60 ++++++++++++--- 5 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/gui/views/goblin/settings_node.rs b/src/gui/views/goblin/settings_node.rs index 9e950ef2..de7358e9 100644 --- a/src/gui/views/goblin/settings_node.rs +++ b/src/gui/views/goblin/settings_node.rs @@ -242,6 +242,9 @@ impl GoblinWalletView { let mut remove: Option = None; let many = self.relay_edit.len() > 1; for (i, relay) in self.relay_edit.iter().enumerate() { + // The shared Floonet rendezvous is pinned and cannot be + // removed, so it never shows an X. + let removable = many && relay != crate::nostr::relays::FLOONET_RELAY; ui.horizontal(|ui| { ui.label( RichText::new(relay) @@ -249,7 +252,7 @@ impl GoblinWalletView { .color(t.surface_text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if many { + if removable { let x = ui.label( RichText::new(crate::gui::icons::X) .font(FontId::new(15.0, fonts::regular())) @@ -289,11 +292,13 @@ impl GoblinWalletView { ui.add_space(10.0); if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() { if let Some(s) = wallet.nostr_service() { - { - let mut c = s.config.write(); - c.set_relays(self.relay_edit.clone()); - c.save(); - } + // Remember the edited list for the ACTIVE transport only + // (Tor vs clearnet), keyed so each network keeps its own + // set. Floonet is pinned on resolve regardless. + let over_tor = s.tor_routing(); + s.config + .write() + .set_relays_for(over_tor, self.relay_edit.clone()); s.restart(wallet.clone()); } self.settings_page = SettingsPage::Main; diff --git a/src/nostr/client/mod.rs b/src/nostr/client/mod.rs index 6f8b4bd2..4092b3d9 100644 --- a/src/nostr/client/mod.rs +++ b/src/nostr/client/mod.rs @@ -444,11 +444,12 @@ impl NostrService { /// set for the new transport at the next `run_service` without touching the /// persisted clearnet subset. pub fn relays(&self) -> Vec { + let over_tor = self.tor_routing(); crate::nostr::relays::effective_relays( - self.tor_routing(), - self.config.read().relays_override(), + over_tor, + self.config.read().relays_override(over_tor), self.identity.read().dm_relays.clone(), - self.config.read().relays(), + self.config.read().default_relays(), ) } } diff --git a/src/nostr/client/service.rs b/src/nostr/client/service.rs index c59ac47d..496b11b9 100644 --- a/src/nostr/client/service.rs +++ b/src/nostr/client/service.rs @@ -571,7 +571,9 @@ async fn ensure_advertised_set(svc: &Arc) { if svc.tor_routing() { return; } - if svc.config.read().relays_override().is_some() || !svc.identity.read().dm_relays.is_empty() { + if svc.config.read().relays_override(false).is_some() + || !svc.identity.read().dm_relays.is_empty() + { return; } let goblin = DEFAULT_RELAYS[0]; diff --git a/src/nostr/config.rs b/src/nostr/config.rs index 5717079e..2a6aba99 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -36,8 +36,18 @@ pub enum AcceptPolicy { pub struct NostrConfig { /// Whether the nostr subsystem runs for this wallet. enabled: Option, - /// Relay list override. + /// Legacy transport-agnostic relay list override (pre per-transport memory). + /// Still honored as a fallback for whichever transport has no explicit set, + /// so an old `nostr.toml` keeps working with no migration. relays: Option>, + /// User relay list remembered for the Tor transport. `Some` only once the + /// user has edited relays while on Tor; otherwise the pinned `TOR_RELAYS` + /// set is used. `relay.floonet.dev` is always pinned first on resolve. + relays_tor: Option>, + /// User relay list remembered for the clearnet transport. `Some` only once + /// the user has edited relays while on clearnet; otherwise the per-identity + /// random healthy subset is used. `relay.floonet.dev` is always pinned first. + relays_clearnet: Option>, /// Accept policy for incoming payments. accept_from: Option, /// NIP-05 identity server base URL. @@ -96,19 +106,37 @@ impl NostrConfig { self.save(); } - pub fn relays(&self) -> Vec { - self.relays_override() - .unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()) + /// The built-in clearnet default relay set (floonet pinned first), used + /// until the per-identity advertised set has been selected. + pub fn default_relays(&self) -> Vec { + DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect() } - /// The relay list explicitly set by the user in nostr.toml, if any. An - /// override disables the per-identity advertised-set selection entirely. - pub fn relays_override(&self) -> Option> { - self.relays.clone().filter(|r| !r.is_empty()) + /// The relay list the user explicitly saved for the given transport, if any. + /// A `Some` override disables the per-identity advertised-set selection for + /// that transport entirely. Precedence: the transport-specific list wins; + /// failing that, the legacy transport-agnostic `relays` list is honored so an + /// old `nostr.toml` keeps working. Empty lists are treated as "not set". + pub fn relays_override(&self, over_tor: bool) -> Option> { + let specific = if over_tor { + &self.relays_tor + } else { + &self.relays_clearnet + }; + specific + .clone() + .or_else(|| self.relays.clone()) + .filter(|r| !r.is_empty()) } - pub fn set_relays(&mut self, relays: Vec) { - self.relays = Some(relays); + /// Remember the user's relay list for the given transport (Tor vs clearnet), + /// keeping the two independent so a switch of network keeps each side's set. + pub fn set_relays_for(&mut self, over_tor: bool, relays: Vec) { + if over_tor { + self.relays_tor = Some(relays); + } else { + self.relays_clearnet = Some(relays); + } self.save(); } @@ -228,4 +256,58 @@ mod tests { assert!(off.tor_enabled_is_set()); assert!(!off.tor_enabled()); } + + #[test] + fn per_transport_relay_overrides_are_independent_and_round_trip() { + // A fresh config has no override for either transport. + let mut cfg = NostrConfig::default(); + assert!(cfg.relays_override(true).is_none()); + assert!(cfg.relays_override(false).is_none()); + + // Editing on clearnet stores a clearnet-only override; Tor is untouched. + let clearnet = vec!["wss://a.example".to_string(), "wss://b.example".to_string()]; + cfg.set_relays_for(false, clearnet.clone()); + assert_eq!(cfg.relays_override(false), Some(clearnet.clone())); + assert!(cfg.relays_override(true).is_none()); + + // Editing on Tor stores a separate Tor-only override; clearnet unchanged. + let tor = vec!["wss://tor-only.example".to_string()]; + cfg.set_relays_for(true, tor.clone()); + assert_eq!(cfg.relays_override(true), Some(tor)); + assert_eq!(cfg.relays_override(false), Some(clearnet)); + + // The two lists survive a serialize -> deserialize round-trip (an app + // update reads the same nostr.toml back). + let toml_str = toml::to_string(&cfg).expect("serialize"); + let back: NostrConfig = toml::from_str(&toml_str).expect("deserialize"); + assert_eq!(back.relays_override(true), cfg.relays_override(true)); + assert_eq!(back.relays_override(false), cfg.relays_override(false)); + } + + #[test] + fn legacy_relays_field_is_honored_as_a_fallback_for_both_transports() { + // An old nostr.toml written before per-transport memory existed only has + // the transport-agnostic `relays` list; it must still apply to both. + let legacy: NostrConfig = toml::from_str("relays = [\"wss://legacy.example\"]\n") + .expect("legacy relays nostr.toml parses"); + let expected = Some(vec!["wss://legacy.example".to_string()]); + assert_eq!(legacy.relays_override(true), expected); + assert_eq!(legacy.relays_override(false), expected); + + // A per-transport override takes precedence over the legacy fallback for + // that transport, while the other transport still falls back to legacy. + let mut cfg = legacy; + let clearnet = vec!["wss://new-clearnet.example".to_string()]; + cfg.set_relays_for(false, clearnet.clone()); + assert_eq!(cfg.relays_override(false), Some(clearnet)); + assert_eq!( + cfg.relays_override(true), + Some(vec!["wss://legacy.example".to_string()]) + ); + + // An empty stored list is treated as "not set". + let mut empty = NostrConfig::default(); + empty.set_relays_for(false, vec![]); + assert!(empty.relays_override(false).is_none()); + } } diff --git a/src/nostr/relays.rs b/src/nostr/relays.rs index c74beb47..3a0d571c 100644 --- a/src/nostr/relays.rs +++ b/src/nostr/relays.rs @@ -24,8 +24,13 @@ /// 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. +/// The shared Floonet rendezvous relay. Pinned FIRST in every resolved relay +/// list and never removable by the user, so any two Goblin wallets always share +/// a guaranteed rendezvous regardless of transport or per-wallet edits. +pub const FLOONET_RELAY: &str = "wss://relay.floonet.dev"; + pub const DEFAULT_RELAYS: &[&str] = &[ - "wss://relay.floonet.dev", + FLOONET_RELAY, "wss://relay.0xchat.com", "wss://offchain.pub", ]; @@ -39,11 +44,7 @@ pub const DEFAULT_RELAYS: &[&str] = &[ /// always share a guaranteed rendezvous. All three are reached over a Tor exit to /// their clearnet host (no onion). The clearnet regime instead draws a per-identity /// random healthy subset from the pool (see [`effective_relays`]). -pub const TOR_RELAYS: &[&str] = &[ - "wss://relay.floonet.dev", - "wss://relay.nostr.net", - "wss://offchain.pub", -]; +pub const TOR_RELAYS: &[&str] = &[FLOONET_RELAY, "wss://relay.nostr.net", "wss://offchain.pub"]; /// Default NIP-05 identity server. pub const DEFAULT_NIP05_SERVER: &str = "https://goblin.st"; @@ -75,7 +76,7 @@ pub fn effective_relays( clearnet_defaults: Vec, ) -> Vec { if let Some(over) = override_set { - return over; + return pin_floonet(over); } if over_tor { return TOR_RELAYS.iter().map(|s| s.to_string()).collect(); @@ -86,6 +87,19 @@ pub fn effective_relays( clearnet_defaults } +/// Ensure [`FLOONET_RELAY`] is present and pinned FIRST, preserving the order of +/// the remaining entries and dropping any duplicate floonet occurrences. Applied +/// to every user relay override so the shared rendezvous can never be edited out. +pub fn pin_floonet(relays: Vec) -> Vec { + let mut out = vec![FLOONET_RELAY.to_string()]; + for r in relays { + if r != FLOONET_RELAY && !out.contains(&r) { + out.push(r); + } + } + out +} + /// Normalize a user-entered relay url (adds wss:// when missing). pub fn normalize_relay_url(input: &str) -> Option { let trimmed = input.trim().trim_end_matches('/'); @@ -171,12 +185,38 @@ mod tests { } #[test] - fn a_user_override_wins_in_both_regimes() { + fn a_user_override_wins_in_both_regimes_with_floonet_pinned() { let ov = vec!["wss://only.example".to_string()]; - assert_eq!(effective_relays(true, Some(ov.clone()), vec![], vec![]), ov); + let expected = vec![FLOONET.to_string(), "wss://only.example".to_string()]; + // The override wins over both the Tor set and the clearnet subset, and + // floonet is pinned first in each regime even when the user omitted it. + assert_eq!( + effective_relays(true, Some(ov.clone()), vec![], vec![]), + expected + ); assert_eq!( effective_relays(false, Some(ov.clone()), vec![], vec![]), - ov + expected ); } + + #[test] + fn pin_floonet_prepends_and_dedups() { + // Missing floonet gets prepended. + assert_eq!( + pin_floonet(vec!["wss://a.example".to_string()]), + vec![FLOONET.to_string(), "wss://a.example".to_string()] + ); + // A floonet already in the middle is moved to the front (deduped). + assert_eq!( + pin_floonet(vec![ + "wss://a.example".to_string(), + FLOONET.to_string(), + "wss://a.example".to_string(), + ]), + vec![FLOONET.to_string(), "wss://a.example".to_string()] + ); + // Floonet can never be edited out entirely. + assert_eq!(pin_floonet(vec![]), vec![FLOONET.to_string()]); + } }