Fix stale relay list: replace UserDefaults hex lookup with ndb query

Replace the fragile `latestRelayListEventIdHex` UserDefaults lookup in
`getLatestNIP65RelayListEvent()` with a direct nostrdb query using
`ndb.query(filters:maxResults:)` on the AUTHOR_KINDS index. This
eliminates the stale-hex failure mode where relay add/remove operations
would silently fall back to bootstrap or year-old relay lists.

Add an in-memory `lastSetRelayList` cache to bridge the nostrdb async
write gap — `set()` populates it, `getUserCurrentRelayList()` checks it
first, and `load()` clears it once ndb has committed.

Remove `latestRelayListEventIdHex` from: Delegate protocol,
DamusState, SaveKeysView, UserSettingsStore, and all test mocks.

Includes 5 regression tests that fail before the fix and pass after:
- testRemoveRelayDoesNotFallBackToBootstrapList
- testCacheBridgesAsyncWriteGap
- testRapidSequentialRemovesDoNotReintroduceRelays
- testLoadClearsCacheAndReadsFromNdb
- testNdbQueryFindsRelayListWithoutStoredHex

Changelog-Fixed: Fix stale relay list causing inability to disconnect relays
Closes: #3537
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
This commit is contained in:
alltheseas
2026-03-16 18:32:22 -07:00
committed by Daniel D’Aquino
co-authored by Claude Opus 4.6
parent 537734397f
commit 2e10c0b4c5
8 changed files with 219 additions and 27 deletions
@@ -304,10 +304,7 @@ extension NostrNetworkManager {
/// The keypair to use for relay authentication and updating relay lists
var keypair: Keypair { get }
/// The latest relay list event id hex
var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support
/// The latest contact list `NostrEvent`
///
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
@@ -18,9 +18,14 @@ extension NostrNetworkManager {
private var delegate: Delegate
private let pool: RelayPool
private let reader: SubscriptionManager
private var relayListObserverTask: Task<Void, Never>? = nil
private var walletUpdatesObserverTask: AnyCancellable? = nil
/// In-memory cache of the most recently set relay list.
/// Bridges the gap between sending an event to nostrdb (async write) and it being queryable.
@MainActor
private var lastSetRelayList: NIP65.RelayList?
init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) {
self.delegate = delegate
@@ -60,9 +65,11 @@ extension NostrNetworkManager {
/// Gets the user's current relay list.
///
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
/// It attempts to get the in-memory cache first (to bridge the nostrdb async write gap),
/// then a NIP-65 relay list from the local database, or falls back to a legacy list.
@MainActor
func getUserCurrentRelayList() -> NIP65.RelayList? {
if let lastSetRelayList { return lastSetRelayList }
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent }
@@ -80,17 +87,18 @@ extension NostrNetworkManager {
return list
}
/// Gets the latest NIP-65 relay list event from NostrDB.
///
/// Gets the latest NIP-65 relay list event from NostrDB via query.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
///
/// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead.
///
/// - Returns: The latest NIP-65 relay list NdbNote
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
return try? delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
let filter = NostrFilter(kinds: [.relay_list], limit: 1, authors: [delegate.keypair.pubkey])
guard let ndbFilter = try? NdbFilter(from: filter) else { return nil }
guard let noteKey = try? delegate.ndb.query(filters: [ndbFilter], maxResults: 1).first else { return nil }
return try? delegate.ndb.lookup_note_by_key_and_copy(noteKey)
}
/// Gets the latest `kind:3` relay list from NostrDB.
@@ -176,17 +184,19 @@ extension NostrNetworkManager {
func set(userRelayList: NIP65.RelayList) async throws(UpdateError) {
guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList }
guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent }
await MainActor.run { self.lastSetRelayList = userRelayList }
await self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
await self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB
}
// MARK: - Syncing our saved user relay list with the active `RelayPool`
/// Loads the current user relay list
func load() async {
await MainActor.run { self.lastSetRelayList = nil } // Clear cache; ndb has had time to commit by now
await self.apply(newRelayList: self.relaysToConnectTo())
}
-5
View File
@@ -230,11 +230,6 @@ fileprivate extension DamusState {
var ndb: Ndb
var keypair: Keypair
var latestRelayListEventIdHex: String? {
get { self.settings.latestRelayListEventIdHex }
set { self.settings.latestRelayListEventIdHex = newValue }
}
@MainActor
var latestContactListEvent: NostrEvent? { self.contacts.event }
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
@@ -166,7 +166,6 @@ struct SaveKeysView: View {
// Save the ID to user settings so that we can easily find it later.
let settings = UserSettingsStore.globally_load_for(pubkey: account.pubkey)
settings.latest_contact_event_id_hex = first_contact_event.id.hex()
settings.latestRelayListEventIdHex = first_relay_list_event.id.hex()
}
func handle_event(relay: RelayURL, ev: NostrConnectionEvent) async {
@@ -411,10 +411,6 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "draft_event_ids", default_value: nil)
var draft_event_ids: [String]?
// TODO: Get rid of this once we have NostrDB query capabilities integrated
@Setting(key: "latest_relay_list_event_id", default_value: nil)
var latestRelayListEventIdHex: String?
// MARK: Helper types
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {