diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 91cfaec4..ed291838 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -298,6 +298,41 @@ final class NostrRelayManager: ObservableObject { torReadyWaitAttempts = 0 updateConnectionStatus() } + + /// Panic wipe reset: close sockets and drop every user/session-specific + /// relay intent without invoking old callbacks. Unlike `disconnect()`, this + /// must not preserve subscription replay state because geohash DM handlers + /// can capture pre-wipe Nostr private keys. + func resetForPanicWipe() { + connectionGeneration &+= 1 + for (_, task) in connections { + task.cancel(with: .goingAway, reason: nil) + } + connections.removeAll() + subscriptions.removeAll() + pendingSubscriptions.removeAll() + messageHandlers.removeAll() + subscriptionRequestState.removeAll() + subscribeCoalesce.removeAll() + eoseTrackers.removeAll() + pendingEOSECallbacks.removeAll() + pendingTorConnectionURLs.removeAll() + awaitingTorForConnections = false + torReadyWaitAttempts = 0 + recentInboundEventKeys.removeAll() + recentInboundEventKeyOrder.removeAll() + duplicateInboundEventDropCount = 0 + duplicateInboundEventDropCountBySubscription.removeAll() + inboundEventLogCount = 0 + Self.pendingGiftWrapIDs.removeAll() + + messageQueueLock.lock() + messageQueue.removeAll() + pendingSendDropCount = 0 + messageQueueLock.unlock() + + updateConnectionStatus() + } /// Ensure connections exist to the given relay URLs (idempotent). func ensureConnections(to relayUrls: [String]) { @@ -1170,6 +1205,18 @@ final class NostrRelayManager: ObservableObject { return Set(map.keys) } + var debugMessageHandlerCount: Int { + messageHandlers.count + } + + var debugSubscriptionRequestCount: Int { + subscriptionRequestState.count + } + + var debugPendingEOSECallbackCount: Int { + pendingEOSECallbacks.count + } + var debugDuplicateInboundEventDropCount: Int { duplicateInboundEventDropCount } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4d26ed31..a38dc529 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1158,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear selected private chat selectedPrivateChatPeer = nil + // Clear live location/geohash session state. Persisted location state + // was wiped above, but the running view model can still be scoped to a + // geohash channel and hold subscriptions tied to the old Nostr identity. + activeChannel = .mesh + setGeoChatSubscriptionID(nil) + setGeoDmSubscriptionID(nil) + _ = clearGeoSamplingSubs() + cachedGeohashIdentity = nil + nostrKeyMapping.removeAll() + // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() // IMPORTANT: Clear Nostr-related state - // Disconnect from Nostr relays and clear subscriptions - nostrRelayManager?.disconnect() + // Drop relay subscriptions, handlers, pending sends, and replay state. + // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain + // disconnect is not enough here. + NostrRelayManager.shared.resetForPanicWipe() nostrRelayManager = nil // Clear Nostr identity associations diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 8045feae..3e9c2daa 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests { #expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.selectedPrivateChatPeer == nil) } + + @Test @MainActor + func panicClearAllData_resetsLiveGeohashAndNostrState() async throws { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruy" + let channel = GeohashChannel(level: .city, geohash: geohash) + let identity = try NostrIdentity.generate() + let pubkey = String(repeating: "ab", count: 32) + let peerID = PeerID(nostr: pubkey) + + viewModel.activeChannel = .location(channel) + viewModel.setGeoChatSubscriptionID("geo-\(geohash)") + viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)") + viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash) + viewModel.cachedGeohashIdentity = (geohash, identity) + viewModel.registerNostrKeyMapping(pubkey, for: peerID) + viewModel.currentGeohash = geohash + viewModel.geoNicknames = [pubkey: "alice"] + viewModel.teleportedGeo = [pubkey] + + viewModel.panicClearAllData() + + #expect(viewModel.activeChannel == .mesh) + #expect(viewModel.geoSubscriptionID == nil) + #expect(viewModel.geoDmSubscriptionID == nil) + #expect(viewModel.geoSamplingSubs.isEmpty) + #expect(viewModel.cachedGeohashIdentity == nil) + #expect(viewModel.nostrKeyMapping.isEmpty) + #expect(viewModel.currentGeohash == nil) + #expect(viewModel.geoNicknames.isEmpty) + #expect(viewModel.teleportedGeo.isEmpty) + } } // MARK: - Service Lifecycle Tests diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 8184564a..ce07f303 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1328,6 +1328,49 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) } + func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws { + let relayURL = "wss://panic-reset.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + let event = try makeSignedEvent(content: "queued before panic") + var handledEvents = 0 + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "panic-sub", + relayUrls: [relayURL], + handler: { _ in handledEvents += 1 }, + onEOSE: { eoseCount += 1 } + ) + context.manager.sendEvent(event, to: [relayURL]) + + XCTAssertEqual(context.manager.debugMessageHandlerCount, 1) + XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) + XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.manager.resetForPanicWipe() + + XCTAssertEqual(context.manager.debugMessageHandlerCount, 0) + XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) + XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0) + XCTAssertEqual(handledEvents, 0) + XCTAssertEqual(eoseCount, 0) + + // Stale Tor wait and fallback callbacks from the pre-wipe generation + // must not resurrect connections or settle callbacks after reset. + context.torWaiter.resolve(true) + context.scheduler.runNext() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertEqual(eoseCount, 0) + } + func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async { let relayURL = "wss://jitter-bounds.example" // Pin the jitter source to the extremes and the midpoint of [0, 1).