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.
This commit is contained in:
@@ -242,6 +242,9 @@ impl GoblinWalletView {
|
||||
let mut remove: Option<usize> = 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;
|
||||
|
||||
@@ -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<String> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +571,9 @@ async fn ensure_advertised_set(svc: &Arc<NostrService>) {
|
||||
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];
|
||||
|
||||
+92
-10
@@ -36,8 +36,18 @@ pub enum AcceptPolicy {
|
||||
pub struct NostrConfig {
|
||||
/// Whether the nostr subsystem runs for this wallet.
|
||||
enabled: Option<bool>,
|
||||
/// 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<Vec<String>>,
|
||||
/// 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<Vec<String>>,
|
||||
/// 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<Vec<String>>,
|
||||
/// Accept policy for incoming payments.
|
||||
accept_from: Option<AcceptPolicy>,
|
||||
/// NIP-05 identity server base URL.
|
||||
@@ -96,19 +106,37 @@ impl NostrConfig {
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn relays(&self) -> Vec<String> {
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+50
-10
@@ -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<String>,
|
||||
) -> Vec<String> {
|
||||
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<String>) -> Vec<String> {
|
||||
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<String> {
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user