1
0
forked from GRIN/grim

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:
2ro
2026-07-09 23:31:36 -04:00
parent 905b3ab94d
commit 6d7a0d6574
5 changed files with 160 additions and 30 deletions
+11 -6
View File
@@ -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;
+4 -3
View File
@@ -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(),
)
}
}
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()]);
}
}