From 638f3f500515b3ae11f7c691abc68df1c10bd378 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 22:28:08 +0200 Subject: [PATCH] Split ChatNostrCoordinator into owned components along domain boundaries The 1,109-line coordinator becomes a 187-line facade wiring three components, each with its own narrow context protocol: GeohashSubscriptionManager (384 lines - subscription IDs + relay lifecycle, the only NostrRelayManager toucher), NostrInboundPipeline (490 lines - the hot event path, dedup-before-verify ordering preserved verbatim), and GeoPresenceTracker (192 lines - teleport detection, sampling LRU, notification cooldowns, now directly tested). Perf baselines confirm the hot path is unchanged: fresh events 2,131 -> 2,138/sec, duplicates ~1.41M/sec. Co-Authored-By: Claude Fable 5 --- bitchat/ViewModels/ChatNostrCoordinator.swift | 978 +----------------- .../Extensions/ChatViewModel+Nostr.swift | 34 +- bitchat/ViewModels/GeoPresenceTracker.swift | 192 ++++ .../GeohashSubscriptionManager.swift | 384 +++++++ bitchat/ViewModels/NostrInboundPipeline.swift | 490 +++++++++ .../ChatNostrCoordinatorContextTests.swift | 122 ++- .../PerformanceBaselineTests.swift | 13 +- 7 files changed, 1227 insertions(+), 986 deletions(-) create mode 100644 bitchat/ViewModels/GeoPresenceTracker.swift create mode 100644 bitchat/ViewModels/GeohashSubscriptionManager.swift create mode 100644 bitchat/ViewModels/NostrInboundPipeline.swift diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 96b35783..e56ce1db 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -1,102 +1,21 @@ import BitFoundation import BitLogger import Foundation -import SwiftUI -import Tor -/// The narrow surface `ChatNostrCoordinator` needs from its owner. +/// The surface `ChatNostrCoordinator` needs from its owner. /// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding a back-reference to the -/// whole `ChatViewModel`. This keeps the coordinator independently testable -/// (see `ChatNostrCoordinatorContextTests`) and makes its true dependencies -/// explicit. The surface is intentionally large — it documents the -/// coordinator's real coupling to channel/subscription state, the inbound -/// Nostr event pipeline, geohash presence, and the ack transports. +/// Inherits the component contexts (`GeohashSubscriptionContext`, +/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object — +/// `ChatViewModel` in production, one mock in tests — can back the whole +/// Nostr stack. The members declared here are only the residual +/// favorites/ack glue the slimmed coordinator still owns. @MainActor -protocol ChatNostrContext: AnyObject { - // MARK: Channel & subscription state - var activeChannel: ChannelID { get set } - var currentGeohash: String? { get set } - var geoSubscriptionID: String? { get } - var geoDmSubscriptionID: String? { get } - func setGeoChatSubscriptionID(_ id: String?) - func setGeoDmSubscriptionID(_ id: String?) - /// Geohash sampling subscriptions: subscription ID -> geohash. - var geoSamplingSubs: [String: String] { get } - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) - func removeGeoSamplingSub(_ subID: String) - /// Clears all sampling subscriptions and returns the removed subscription IDs - /// so the caller can unsubscribe them from the relay manager. - func clearGeoSamplingSubs() -> [String] - /// Per-geohash notification cooldown: geohash -> last notify time. - var lastGeoNotificationAt: [String: Date] { get set } - var nostrRelayManager: NostrRelayManager? { get } - - // MARK: Public timeline & pipeline - var messages: [BitchatMessage] { get } - func resetPublicMessagePipeline() - func updatePublicMessagePipelineChannel(_ channel: ChannelID) - func refreshVisibleMessages(from channel: ChannelID?) - func addPublicSystemMessage(_ content: String) - func drainPendingGeohashSystemMessages() -> [String] - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool - func synchronizePublicConversationStore(forGeohash geohash: String) - - // MARK: Inbound public messages - func handlePublicMessage(_ message: BitchatMessage) - func checkForMentions(_ message: BitchatMessage) - func sendHapticFeedback(for message: BitchatMessage) - func parseMentions(from content: String) -> [String] - - // MARK: Inbound private (geohash DM) payloads +protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext { var selectedPrivateChatPeer: PeerID? { get } var nostrKeyMapping: [PeerID: String] { get } - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) - func handlePrivateMessage( - _ payload: NoisePayload, - senderPubkey: String, - convKey: PeerID, - id: NostrIdentity, - messageTimestamp: Date - ) - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) func startPrivateChat(with peerID: PeerID) - - // MARK: Nostr identity & blocking (shared with `ChatPrivateConversationContext`) - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func currentNostrIdentity() -> NostrIdentity? - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String - - // MARK: Event dedup - func hasProcessedNostrEvent(_ eventID: String) -> Bool - func recordProcessedNostrEvent(_ eventID: String) - func clearProcessedNostrEvents() - - // MARK: Geo participants & presence - var geoNicknames: [String: String] { get } - var teleportedGeoCount: Int { get } - func startGeoParticipantRefreshTimer() - func stopGeoParticipantRefreshTimer() - func setActiveParticipantGeohash(_ geohash: String?) - func recordGeoParticipant(pubkeyHex: String) - func recordGeoParticipant(pubkeyHex: String, geohash: String) - func geoParticipantCount(for geohash: String) -> Int - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) - func markGeoTeleported(_ pubkeyHexLowercased: String) - func clearGeoTeleported(_ pubkeyHexLowercased: String) - func clearTeleportedGeo() - func clearGeoNicknames() func visibleGeohashPeople() -> [GeoPerson] - // MARK: Location channels - var isTeleported: Bool { get } - /// True when regional channels are known and the geohash is not one of them. - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool - // MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`) func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -104,857 +23,33 @@ protocol ChatNostrContext: AnyObject { } extension ChatViewModel: ChatNostrContext { - // `activeChannel`, `selectedPrivateChatPeer`, `nostrKeyMapping`, - // `messages`, `geoNicknames`, the Nostr identity/blocking members, and the - // routing/ack members are shared requirements with `ChatDeliveryContext` / - // `ChatPrivateConversationContext`; their witnesses already exist. The - // members below flatten nested service accesses into intent-named calls. - - func resetPublicMessagePipeline() { - publicMessagePipeline.reset() - } - - func updatePublicMessagePipelineChannel(_ channel: ChannelID) { - publicMessagePipeline.updateActiveChannel(channel) - } - - func drainPendingGeohashSystemMessages() -> [String] { - timelineStore.drainPendingGeohashSystemMessages() - } - - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - timelineStore.appendIfAbsent(message, toGeohash: geohash) - } - - func hasProcessedNostrEvent(_ eventID: String) -> Bool { - deduplicationService.hasProcessedNostrEvent(eventID) - } - - func recordProcessedNostrEvent(_ eventID: String) { - deduplicationService.recordNostrEvent(eventID) - } - - func clearProcessedNostrEvents() { - deduplicationService.clearNostrCaches() - } - - var teleportedGeoCount: Int { - locationPresenceStore.teleportedGeo.count - } - - func startGeoParticipantRefreshTimer() { - participantTracker.startRefreshTimer() - } - - func stopGeoParticipantRefreshTimer() { - participantTracker.stopRefreshTimer() - } - - func setActiveParticipantGeohash(_ geohash: String?) { - participantTracker.setActiveGeohash(geohash) - } - - func recordGeoParticipant(pubkeyHex: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex) - } - - func recordGeoParticipant(pubkeyHex: String, geohash: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) - } - - func geoParticipantCount(for geohash: String) -> Int { - participantTracker.participantCount(for: geohash) - } - - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { - locationPresenceStore.setNickname(nickname, for: pubkeyHex) - } - - func markGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.markTeleported(pubkeyHexLowercased) - } - - func clearGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.clearTeleported(pubkeyHexLowercased) - } - - func clearTeleportedGeo() { - locationPresenceStore.clearTeleportedGeo() - } - - func clearGeoNicknames() { - locationPresenceStore.clearGeoNicknames() - } - - var isTeleported: Bool { - locationManager.teleported - } - - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { - let channels = locationManager.availableChannels - return !channels.isEmpty && !channels.contains { $0.geohash == geohash } - } + // All requirements — including the component-context witnesses declared + // in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`, + // and `GeoPresenceTracker.swift` — already exist on `ChatViewModel`. } +/// Thin facade over the Nostr stack: owns and wires the three components and +/// keeps the residual favorites/ack glue that fits none of them. +/// +/// - `subscriptions`: relay lifecycle and subscription IDs +/// (`GeohashSubscriptionManager`) +/// - `inbound`: the hot event -> message/payload pipeline +/// (`NostrInboundPipeline`) +/// - `presence`: teleport marking, sampling dedup, notification cooldown +/// (`GeoPresenceTracker`) final class ChatNostrCoordinator { private weak var context: (any ChatNostrContext)? - private var recentGeoSamplingEventIDs = Set() - private var recentGeoSamplingEventIDOrder: [String] = [] - private var geoEventLogCount = 0 + let presence: GeoPresenceTracker + let inbound: NostrInboundPipeline + let subscriptions: GeohashSubscriptionManager init(context: any ChatNostrContext) { self.context = context - } - - @MainActor - func resubscribeCurrentGeohash() { - guard let context else { return } - guard case .location(let channel) = context.activeChannel else { return } - guard let subID = context.geoSubscriptionID else { - switchLocationChannel(to: context.activeChannel) - return - } - - context.startGeoParticipantRefreshTimer() - NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral( - channel.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.subscribeNostrEvent(event) - } - } - - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.subscribeGiftWrap(giftWrap, id: identity) - } - } - } - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification — - // duplicates dominate real traffic and must not pay for crypto. - // Only verified events are recorded, so a forged-signature copy can - // never poison the dedup set and suppress the genuine event. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !context.hasProcessedNostrEvent(event.id) - else { - return - } - guard event.isValidSignature() else { return } - - context.recordProcessedNostrEvent(event.id) - - if let gh = context.currentGeohash, - let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), - myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmed - context.setGeoNickname(nick, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let hasTeleportTag = event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - - if hasTeleportTag { - let key = event.pubkey.lowercased() - let isSelf: Bool = { - if let gh = context.currentGeohash, - let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { - return myIdentity.publicKeyHex.lowercased() == key - } - return false - }() - if !isSelf { - Task { @MainActor [weak context] in - context?.markGeoTeleported(key) - } - } - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmed - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let timestamp = min(rawTs, Date()) - let mentions = context.parseMentions(from: content) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message) - if !isBlocked { - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - } - - @MainActor - func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. - guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ), - let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let noisePayload = NoisePayload.decode(packet.payload) - else { - return - } - - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch noisePayload.type { - case .privateMessage: - context.handlePrivateMessage( - noisePayload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: - break - } - } - - @MainActor - func switchLocationChannel(to channel: ChannelID) { - guard let context else { return } - context.resetPublicMessagePipeline() - context.activeChannel = channel - context.updatePublicMessagePipelineChannel(channel) - - context.clearProcessedNostrEvents() - switch channel { - case .mesh: - context.refreshVisibleMessages(from: .mesh) - let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count - if emptyMesh > 0 { - SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) - } - context.stopGeoParticipantRefreshTimer() - context.setActiveParticipantGeohash(nil) - context.clearTeleportedGeo() - - case .location: - context.refreshVisibleMessages(from: channel) - } - - if case .location = channel { - for content in context.drainPendingGeohashSystemMessages() { - context.addPublicSystemMessage(content) - } - } - - if let sub = context.geoSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) - context.setGeoChatSubscriptionID(nil) - } - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - context.currentGeohash = nil - context.setActiveParticipantGeohash(nil) - context.clearGeoNicknames() - - guard case .location(let channel) = channel else { return } - context.currentGeohash = channel.geohash - context.setActiveParticipantGeohash(channel.geohash) - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - let key = identity.publicKeyHex.lowercased() - if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } else { - context.clearGeoTeleported(key) - } - } - - let subID = "geo-\(channel.geohash)" - context.setGeoChatSubscriptionID(subID) - context.startGeoParticipantRefreshTimer() - let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) - let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.handleNostrEvent(event) - } - } - - subscribeToGeoChat(channel) - } - - @MainActor - func handleNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification — - // duplicates dominate real traffic and must not pay for crypto. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - if context.hasProcessedNostrEvent(event.id) { return } - guard event.isValidSignature() else { return } - context.recordProcessedNostrEvent(event.id) - - // Sampled: fires for every geo event and floods dev logs in busy geohashes. - geoEventLogCount += 1 - if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) - } - - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { - return - } - - let hasTeleportTag = event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - - let isSelf: Bool = { - if let gh = context.currentGeohash, - let my = try? context.deriveNostrIdentity(forGeohash: gh) { - return my.publicKeyHex.lowercased() == event.pubkey.lowercased() - } - return false - }() - - if hasTeleportTag, !isSelf { - let key = event.pubkey.lowercased() - Task { @MainActor [weak context] in - guard let context else { return } - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } - } - - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if isSelf { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content - - if let teleTag = event.tags.first(where: { $0.first == "t" }), - teleTag.count >= 2, - teleTag[1] == "teleport", - content.trimmed.isEmpty { - return - } - - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let mentions = context.parseMentions(from: content) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: min(rawTs, Date()), - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - context.handlePublicMessage(message) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - - @MainActor - func subscribeToGeoChat(_ channel: GeohashChannel) { - guard let context else { return } - guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } - - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - if TorManager.shared.isReady { - SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) - } - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.handleGiftWrap(giftWrap, id: identity) - } - } - } - - @MainActor - func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. - if context.hasProcessedNostrEvent(giftWrap.id) { - return - } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ) else { - SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) - return - } - - SecureLogger.debug( - "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", - category: .session - ) - - guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) - else { - return - } - - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch payload.type { - case .privateMessage: - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: - break - } - } - - @MainActor - func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { - guard let context else { return } - let channel = geoContext.channel - let event = geoContext.event - let identity = geoContext.identity - - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) - SecureLogger.debug( - "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)", - category: .session - ) - - if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - let key = identity.publicKeyHex.lowercased() - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", - category: .session - ) - } - - context.recordProcessedNostrEvent(event.id) - } - - @MainActor - func beginGeohashSampling(for geohashes: [String]) { - guard let context else { return } - if !TorManager.shared.isForeground() { - endGeohashSampling() - return - } - - let desired = Set(geohashes) - let current = Set(context.geoSamplingSubs.values) - let toAdd = desired.subtracting(current) - let toRemove = current.subtracting(desired) - - for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { - NostrRelayManager.shared.unsubscribe(id: subID) - context.removeGeoSamplingSub(subID) - } - - for gh in toAdd { - subscribe(gh) - } - } - - @MainActor - func subscribe(_ gh: String) { - guard let context else { return } - let subID = "geo-sample-\(gh)" - context.addGeoSamplingSub(subID, forGeohash: gh) - let filter = NostrFilter.geohashEphemeral( - gh, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), - limit: TransportConfig.nostrGeohashSampleLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.subscribeNostrEvent(event, gh: gh) - } - } - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard let context else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - guard event.isValidSignature() else { return } - guard shouldProcessGeoSamplingEvent(event.id) else { return } - - let existingCount = context.geoParticipantCount(for: gh) - context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - - guard let content = event.content.trimmedOrNilIfEmpty else { return } - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - if let my = try? context.deriveNostrIdentity(forGeohash: gh), - my.publicKeyHex.lowercased() == event.pubkey.lowercased() { - return - } - guard existingCount == 0 else { return } - - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) > 30 { return } - - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #elseif os(macOS) - guard NSApplication.shared.isActive else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #endif - - cooldownPerGeohash(gh, content: content, event: event) - } - - @MainActor - func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - guard let context else { return } - let now = Date() - let last = context.lastGeoNotificationAt[gh] ?? .distantPast - if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } - - let preview: String = { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - if content.count <= maxLen { return content } - let idx = content.index(content.startIndex, offsetBy: maxLen) - return String(content[.. Bool { - guard !eventID.isEmpty else { return true } - guard recentGeoSamplingEventIDs.insert(eventID).inserted else { - return false - } - recentGeoSamplingEventIDOrder.append(eventID) - - let cap = TransportConfig.geoSamplingEventLRUCap - if recentGeoSamplingEventIDOrder.count > cap { - let removeCount = recentGeoSamplingEventIDOrder.count - cap - for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { - recentGeoSamplingEventIDs.remove(staleID) - } - recentGeoSamplingEventIDOrder.removeFirst(removeCount) - } - return true - } - - private func clearGeoSamplingEventDedup() { - recentGeoSamplingEventIDs.removeAll() - recentGeoSamplingEventIDOrder.removeAll() - } - - @MainActor - func setupNostrMessageHandling() { - guard let context else { return } - guard let currentIdentity = context.currentNostrIdentity() else { - SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session) - return - } - - SecureLogger.debug( - "🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", - category: .session - ) - - let filter = NostrFilter.giftWrapsFor( - pubkey: currentIdentity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - - context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in - Task { @MainActor [weak self] in - self?.handleNostrMessage(event) - } - } - } - - @MainActor - func handleNostrMessage(_ giftWrap: NostrEvent) { - guard let context else { return } - // Cheap dedup pre-check only; Schnorr verification runs off-main in - // processNostrMessage, which then does the authoritative - // check-and-record. Recording stays after verification so a - // forged-signature copy can never poison the dedup set and suppress - // the genuine event. - if context.hasProcessedNostrEvent(giftWrap.id) { return } - - Task.detached(priority: .userInitiated) { [weak self] in - await self?.processNostrMessage(giftWrap) - } - } - - func processNostrMessage(_ giftWrap: NostrEvent) async { - guard giftWrap.isValidSignature() else { return } - guard let context else { return } - // Authoritative check-and-record, atomic on the main actor so two - // concurrent detached tasks can't both process the same event. - let alreadyProcessed: Bool = await MainActor.run { - if context.hasProcessedNostrEvent(giftWrap.id) { return true } - context.recordProcessedNostrEvent(giftWrap.id) - return false - } - if alreadyProcessed { return } - let currentIdentity: NostrIdentity? = await MainActor.run { - context.currentNostrIdentity() - } - guard let currentIdentity else { return } - - do { - let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: currentIdentity - ) - - if content.hasPrefix("verify:") { - return - } - - if content.hasPrefix("bitchat1:") { - let packet: BitchatPacket? = await MainActor.run { - Self.decodeEmbeddedBitChatPacket(from: content) - } - guard let packet else { - SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) - return - } - - let actualSenderNoiseKey: Data? = await MainActor.run { - self.findNoiseKey(for: senderPubkey) - } - let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) - - if packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) { - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - await MainActor.run { - context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) - - switch payload.type { - case .privateMessage: - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID, - id: currentIdentity, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: - break - } - } - } - } else { - SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) - } - } catch { - SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) - } - } - - @MainActor - func findNoiseKey(for nostrPubkey: String) -> Data? { - let favorites = FavoritesPersistenceService.shared.favorites.values - var npubToMatch = nostrPubkey - - if !nostrPubkey.hasPrefix("npub") { - if let pubkeyData = Data(hexString: nostrPubkey), - let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { - npubToMatch = encoded - } else { - SecureLogger.warning( - "⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", - category: .session - ) - } - } - - for relationship in favorites { - if let storedNostrKey = relationship.peerNostrPublicKey { - if storedNostrKey == npubToMatch { - return relationship.peerNoisePublicKey - } - if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { - SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) - return relationship.peerNoisePublicKey - } - } - } - - SecureLogger.debug( - "⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", - category: .session - ) - return nil + let presence = GeoPresenceTracker(context: context) + let inbound = NostrInboundPipeline(context: context, presence: presence) + self.presence = presence + self.inbound = inbound + self.subscriptions = GeohashSubscriptionManager(context: context, inbound: inbound, presence: presence) } @MainActor @@ -994,7 +89,7 @@ final class ChatNostrCoordinator { @MainActor func handleFavoriteNotification(content: String, from nostrPubkey: String) { - guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } let isFavorite = content.contains("FAVORITE:TRUE") let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" @@ -1090,20 +185,3 @@ final class ChatNostrCoordinator { return context.displayNameForNostrPubkey(full) } } - -private extension ChatNostrCoordinator { - @MainActor - static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { - guard content.hasPrefix("bitchat1:") else { return nil } - let encoded = String(content.dropFirst("bitchat1:".count)) - let maxBytes = FileTransferLimits.maxFramedFileBytes - let maxEncoded = ((maxBytes + 2) / 3) * 4 - guard encoded.count <= maxEncoded else { return nil } - guard let packetData = Base64URLCoding.decode(encoded), - packetData.count <= maxBytes - else { - return nil - } - return BitchatPacket.from(packetData) - } -} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index fff0897e..bf2051bc 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -12,86 +12,86 @@ extension ChatViewModel { @MainActor func resubscribeCurrentGeohash() { - nostrCoordinator.resubscribeCurrentGeohash() + nostrCoordinator.subscriptions.resubscribeCurrentGeohash() } @MainActor func subscribeNostrEvent(_ event: NostrEvent) { - nostrCoordinator.subscribeNostrEvent(event) + nostrCoordinator.inbound.subscribeNostrEvent(event) } @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.subscribeGiftWrap(giftWrap, id: id) + nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id) } @MainActor func switchLocationChannel(to channel: ChannelID) { - nostrCoordinator.switchLocationChannel(to: channel) + nostrCoordinator.subscriptions.switchLocationChannel(to: channel) } @MainActor func handleNostrEvent(_ event: NostrEvent) { - nostrCoordinator.handleNostrEvent(event) + nostrCoordinator.inbound.handleNostrEvent(event) } @MainActor func subscribeToGeoChat(_ ch: GeohashChannel) { - nostrCoordinator.subscribeToGeoChat(ch) + nostrCoordinator.subscriptions.subscribeToGeoChat(ch) } @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.handleGiftWrap(giftWrap, id: id) + nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id) } @MainActor func sendGeohash(context: GeoOutgoingContext) { - nostrCoordinator.sendGeohash(context: context) + nostrCoordinator.subscriptions.sendGeohash(context: context) } @MainActor func beginGeohashSampling(for geohashes: [String]) { - nostrCoordinator.beginGeohashSampling(for: geohashes) + nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes) } @MainActor func subscribe(_ gh: String) { - nostrCoordinator.subscribe(gh) + nostrCoordinator.subscriptions.subscribe(gh) } @MainActor func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - nostrCoordinator.subscribeNostrEvent(event, gh: gh) + nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh) } @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event) + nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event) } @MainActor func endGeohashSampling() { - nostrCoordinator.endGeohashSampling() + nostrCoordinator.subscriptions.endGeohashSampling() } @MainActor func setupNostrMessageHandling() { - nostrCoordinator.setupNostrMessageHandling() + nostrCoordinator.subscriptions.setupNostrMessageHandling() } @MainActor func handleNostrMessage(_ giftWrap: NostrEvent) { - nostrCoordinator.handleNostrMessage(giftWrap) + nostrCoordinator.inbound.handleNostrMessage(giftWrap) } func processNostrMessage(_ giftWrap: NostrEvent) async { - await nostrCoordinator.processNostrMessage(giftWrap) + await nostrCoordinator.inbound.processNostrMessage(giftWrap) } @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { - nostrCoordinator.findNoiseKey(for: nostrPubkey) + nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey) } @MainActor diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift new file mode 100644 index 00000000..587982c3 --- /dev/null +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -0,0 +1,192 @@ +import BitFoundation +import BitLogger +import Foundation +import SwiftUI + +/// The narrow surface `GeoPresenceTracker` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol GeoPresenceContext: AnyObject { + var activeChannel: ChannelID { get } + /// Per-geohash notification cooldown: geohash -> last notify time. + var lastGeoNotificationAt: [String: Date] { get set } + var geoNicknames: [String: String] { get } + var teleportedGeoCount: Int { get } + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func parseMentions(from content: String) -> [String] + + func recordGeoParticipant(pubkeyHex: String, geohash: String) + func geoParticipantCount(for geohash: String) -> Int + func markGeoTeleported(_ pubkeyHexLowercased: String) + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool + func synchronizePublicConversationStore(forGeohash geohash: String) +} + +extension ChatViewModel: GeoPresenceContext { + // `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr + // identity/blocking members, and `synchronizePublicConversationStore` + // already have witnesses on `ChatViewModel`. The members below flatten + // nested service accesses into intent-named calls. + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + timelineStore.appendIfAbsent(message, toGeohash: geohash) + } + + var teleportedGeoCount: Int { + locationPresenceStore.teleportedGeo.count + } + + func recordGeoParticipant(pubkeyHex: String, geohash: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) + } + + func geoParticipantCount(for geohash: String) -> Int { + participantTracker.participantCount(for: geohash) + } + + func markGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.markTeleported(pubkeyHexLowercased) + } +} + +/// Geohash presence bookkeeping that is independent of relay subscriptions: +/// teleport-tag detection and marking, the sampling-event LRU dedup, and the +/// per-geohash notification cooldown for sampled activity. +final class GeoPresenceTracker { + private weak var context: (any GeoPresenceContext)? + private var recentGeoSamplingEventIDs = Set() + private var recentGeoSamplingEventIDOrder: [String] = [] + + init(context: any GeoPresenceContext) { + self.context = context + } + + /// True when the event carries a `["t", "teleport"]` tag. + static func hasTeleportTag(_ event: NostrEvent) -> Bool { + event.tags.contains { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + } + } + + /// Marks a peer teleported on a follow-up main-actor hop (keeps the + /// inbound hot path free of presence-store writes). + @MainActor + func scheduleMarkPeerTeleported(_ key: String, logged: Bool) { + Task { @MainActor [weak context] in + guard let context else { return } + context.markGeoTeleported(key) + if logged { + SecureLogger.info( + "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", + category: .session + ) + } + } + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent, gh: String) { + guard let context else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + guard event.isValidSignature() else { return } + guard shouldProcessGeoSamplingEvent(event.id) else { return } + + let existingCount = context.geoParticipantCount(for: gh) + context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + + guard let content = event.content.trimmedOrNilIfEmpty else { return } + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + if let my = try? context.deriveNostrIdentity(forGeohash: gh), + my.publicKeyHex.lowercased() == event.pubkey.lowercased() { + return + } + guard existingCount == 0 else { return } + + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } + + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } + #elseif os(macOS) + guard NSApplication.shared.isActive else { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } + #endif + + cooldownPerGeohash(gh, content: content, event: event) + } + + @MainActor + func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + guard let context else { return } + let now = Date() + let last = context.lastGeoNotificationAt[gh] ?? .distantPast + if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } + + let preview: String = { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + if content.count <= maxLen { return content } + let idx = content.index(content.startIndex, offsetBy: maxLen) + return String(content[.. Bool { + guard !eventID.isEmpty else { return true } + guard recentGeoSamplingEventIDs.insert(eventID).inserted else { + return false + } + recentGeoSamplingEventIDOrder.append(eventID) + + let cap = TransportConfig.geoSamplingEventLRUCap + if recentGeoSamplingEventIDOrder.count > cap { + let removeCount = recentGeoSamplingEventIDOrder.count - cap + for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { + recentGeoSamplingEventIDs.remove(staleID) + } + recentGeoSamplingEventIDOrder.removeFirst(removeCount) + } + return true + } + + func clearGeoSamplingEventDedup() { + recentGeoSamplingEventIDs.removeAll() + recentGeoSamplingEventIDOrder.removeAll() + } +} diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift new file mode 100644 index 00000000..1553410a --- /dev/null +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -0,0 +1,384 @@ +import BitFoundation +import BitLogger +import Foundation +import Tor + +/// The narrow surface `GeohashSubscriptionManager` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol GeohashSubscriptionContext: AnyObject { + // MARK: Channel & subscription state + var activeChannel: ChannelID { get set } + var currentGeohash: String? { get set } + var geoSubscriptionID: String? { get } + var geoDmSubscriptionID: String? { get } + func setGeoChatSubscriptionID(_ id: String?) + func setGeoDmSubscriptionID(_ id: String?) + /// Geohash sampling subscriptions: subscription ID -> geohash. + var geoSamplingSubs: [String: String] { get } + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) + func removeGeoSamplingSub(_ subID: String) + /// Clears all sampling subscriptions and returns the removed subscription IDs + /// so the caller can unsubscribe them from the relay manager. + func clearGeoSamplingSubs() -> [String] + var nostrRelayManager: NostrRelayManager? { get } + + // MARK: Public timeline & pipeline + var messages: [BitchatMessage] { get } + func resetPublicMessagePipeline() + func updatePublicMessagePipelineChannel(_ channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func addPublicSystemMessage(_ content: String) + func drainPendingGeohashSystemMessages() -> [String] + + // MARK: Nostr identity & dedup + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + func recordProcessedNostrEvent(_ eventID: String) + func clearProcessedNostrEvents() + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + + // MARK: Geo participants & presence + var teleportedGeoCount: Int { get } + func startGeoParticipantRefreshTimer() + func stopGeoParticipantRefreshTimer() + func setActiveParticipantGeohash(_ geohash: String?) + func recordGeoParticipant(pubkeyHex: String) + func markGeoTeleported(_ pubkeyHexLowercased: String) + func clearGeoTeleported(_ pubkeyHexLowercased: String) + func clearTeleportedGeo() + func clearGeoNicknames() + + // MARK: Location channels + var isTeleported: Bool { get } + /// True when regional channels are known and the geohash is not one of them. + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool +} + +extension ChatViewModel: GeohashSubscriptionContext { + // `activeChannel`, `currentGeohash`, the subscription-ID accessors, the + // identity members, and the timeline members already have witnesses on + // `ChatViewModel`. The members below flatten nested service accesses into + // intent-named calls. + + func resetPublicMessagePipeline() { + publicMessagePipeline.reset() + } + + func updatePublicMessagePipelineChannel(_ channel: ChannelID) { + publicMessagePipeline.updateActiveChannel(channel) + } + + func drainPendingGeohashSystemMessages() -> [String] { + timelineStore.drainPendingGeohashSystemMessages() + } + + func clearProcessedNostrEvents() { + deduplicationService.clearNostrCaches() + } + + func startGeoParticipantRefreshTimer() { + participantTracker.startRefreshTimer() + } + + func stopGeoParticipantRefreshTimer() { + participantTracker.stopRefreshTimer() + } + + func setActiveParticipantGeohash(_ geohash: String?) { + participantTracker.setActiveGeohash(geohash) + } + + func clearGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.clearTeleported(pubkeyHexLowercased) + } + + func clearTeleportedGeo() { + locationPresenceStore.clearTeleportedGeo() + } + + func clearGeoNicknames() { + locationPresenceStore.clearGeoNicknames() + } + + var isTeleported: Bool { + locationManager.teleported + } + + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { + let channels = locationManager.availableChannels + return !channels.isEmpty && !channels.contains { $0.geohash == geohash } + } +} + +/// Owns subscription IDs and relay lifecycle for geohash channels, geohash +/// DMs, the account gift-wrap mailbox, and background geohash sampling. The +/// only component that talks to `NostrRelayManager`; inbound events are +/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`. +final class GeohashSubscriptionManager { + private weak var context: (any GeohashSubscriptionContext)? + private let inbound: NostrInboundPipeline + private let presence: GeoPresenceTracker + + init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { + self.context = context + self.inbound = inbound + self.presence = presence + } + + @MainActor + func resubscribeCurrentGeohash() { + guard let context else { return } + guard case .location(let channel) = context.activeChannel else { return } + guard let subID = context.geoSubscriptionID else { + switchLocationChannel(to: context.activeChannel) + return + } + + context.startGeoParticipantRefreshTimer() + NostrRelayManager.shared.unsubscribe(id: subID) + let filter = NostrFilter.geohashEphemeral( + channel.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.subscribeNostrEvent(event) + } + } + + if let dmSub = context.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + context.setGeoDmSubscriptionID(nil) + } + + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let dmSub = "geo-dm-\(channel.geohash)" + context.setGeoDmSubscriptionID(dmSub) + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.inbound.subscribeGiftWrap(giftWrap, id: identity) + } + } + } + } + + @MainActor + func switchLocationChannel(to channel: ChannelID) { + guard let context else { return } + context.resetPublicMessagePipeline() + context.activeChannel = channel + context.updatePublicMessagePipelineChannel(channel) + + context.clearProcessedNostrEvents() + switch channel { + case .mesh: + context.refreshVisibleMessages(from: .mesh) + let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count + if emptyMesh > 0 { + SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) + } + context.stopGeoParticipantRefreshTimer() + context.setActiveParticipantGeohash(nil) + context.clearTeleportedGeo() + + case .location: + context.refreshVisibleMessages(from: channel) + } + + if case .location = channel { + for content in context.drainPendingGeohashSystemMessages() { + context.addPublicSystemMessage(content) + } + } + + if let sub = context.geoSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + context.setGeoChatSubscriptionID(nil) + } + if let dmSub = context.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + context.setGeoDmSubscriptionID(nil) + } + context.currentGeohash = nil + context.setActiveParticipantGeohash(nil) + context.clearGeoNicknames() + + guard case .location(let channel) = channel else { return } + context.currentGeohash = channel.geohash + context.setActiveParticipantGeohash(channel.geohash) + + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + let key = identity.publicKeyHex.lowercased() + if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { + context.markGeoTeleported(key) + SecureLogger.info( + "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", + category: .session + ) + } else { + context.clearGeoTeleported(key) + } + } + + let subID = "geo-\(channel.geohash)" + context.setGeoChatSubscriptionID(subID) + context.startGeoParticipantRefreshTimer() + let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) + let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.handleNostrEvent(event) + } + } + + subscribeToGeoChat(channel) + } + + @MainActor + func subscribeToGeoChat(_ channel: GeohashChannel) { + guard let context else { return } + guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } + + let dmSub = "geo-dm-\(channel.geohash)" + context.setGeoDmSubscriptionID(dmSub) + if TorManager.shared.isReady { + SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) + } + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.inbound.handleGiftWrap(giftWrap, id: identity) + } + } + } + + @MainActor + func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { + guard let context else { return } + let channel = geoContext.channel + let event = geoContext.event + let identity = geoContext.identity + + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) + SecureLogger.debug( + "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)", + category: .session + ) + + if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { + let key = identity.publicKeyHex.lowercased() + context.markGeoTeleported(key) + SecureLogger.info( + "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", + category: .session + ) + } + + context.recordProcessedNostrEvent(event.id) + } + + @MainActor + func beginGeohashSampling(for geohashes: [String]) { + guard let context else { return } + if !TorManager.shared.isForeground() { + endGeohashSampling() + return + } + + let desired = Set(geohashes) + let current = Set(context.geoSamplingSubs.values) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + + for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { + NostrRelayManager.shared.unsubscribe(id: subID) + context.removeGeoSamplingSub(subID) + } + + for gh in toAdd { + subscribe(gh) + } + } + + @MainActor + func subscribe(_ gh: String) { + guard let context else { return } + let subID = "geo-sample-\(gh)" + context.addGeoSamplingSub(subID, forGeohash: gh) + let filter = NostrFilter.geohashEphemeral( + gh, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), + limit: TransportConfig.nostrGeohashSampleLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.presence.subscribeNostrEvent(event, gh: gh) + } + } + } + + @MainActor + func endGeohashSampling() { + guard let context else { return } + for subID in context.clearGeoSamplingSubs() { + NostrRelayManager.shared.unsubscribe(id: subID) + } + presence.clearGeoSamplingEventDedup() + } + + @MainActor + func setupNostrMessageHandling() { + guard let context else { return } + guard let currentIdentity = context.currentNostrIdentity() else { + SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session) + return + } + + SecureLogger.debug( + "🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", + category: .session + ) + + let filter = NostrFilter.giftWrapsFor( + pubkey: currentIdentity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + + context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.handleNostrMessage(event) + } + } + } +} diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift new file mode 100644 index 00000000..a1d4b4d5 --- /dev/null +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -0,0 +1,490 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `NostrInboundPipeline` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol NostrInboundPipelineContext: AnyObject { + var currentGeohash: String? { get } + + // MARK: Event dedup + func hasProcessedNostrEvent(_ eventID: String) -> Bool + func recordProcessedNostrEvent(_ eventID: String) + + // MARK: Nostr identity & blocking + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + + // MARK: Presence & key mapping + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + func recordGeoParticipant(pubkeyHex: String) + + // MARK: Inbound public messages + func handlePublicMessage(_ message: BitchatMessage) + func checkForMentions(_ message: BitchatMessage) + func sendHapticFeedback(for message: BitchatMessage) + func parseMentions(from content: String) -> [String] + + // MARK: Inbound private (DM) payloads + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date + ) + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) +} + +extension ChatViewModel: NostrInboundPipelineContext { + // `currentGeohash`, the identity/blocking members, key mapping, and the + // inbound message handlers already have witnesses on `ChatViewModel`. + // The members below flatten nested service accesses into intent-named calls. + + func hasProcessedNostrEvent(_ eventID: String) -> Bool { + deduplicationService.hasProcessedNostrEvent(eventID) + } + + func recordProcessedNostrEvent(_ eventID: String) { + deduplicationService.recordNostrEvent(eventID) + } + + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { + locationPresenceStore.setNickname(nickname, for: pubkeyHex) + } + + func recordGeoParticipant(pubkeyHex: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex) + } +} + +/// The inbound Nostr hot path: raw relay events in, chat messages / Noise +/// payloads out. Pure transformation plus dedup — no relay lifecycle. +/// +/// Ordering is deliberate and performance-critical: cheap rejects (kind, +/// dedup lookup) run BEFORE Schnorr signature verification because duplicates +/// dominate real relay traffic; events are recorded only AFTER verification so +/// a forged-signature copy can never poison the dedup set; gift-wrap +/// verification for the account mailbox runs off-main with an atomic +/// main-actor check-and-record. +final class NostrInboundPipeline { + private weak var context: (any NostrInboundPipelineContext)? + private let presence: GeoPresenceTracker + private var geoEventLogCount = 0 + + init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { + self.context = context + self.presence = presence + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent) { + guard let context else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification — + // duplicates dominate real traffic and must not pay for crypto. + // Only verified events are recorded, so a forged-signature copy can + // never poison the dedup set and suppress the genuine event. + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), + !context.hasProcessedNostrEvent(event.id) + else { + return + } + guard event.isValidSignature() else { return } + + context.recordProcessedNostrEvent(event.id) + + if let gh = context.currentGeohash, + let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1].trimmed + context.setGeoNickname(nick, forPubkey: event.pubkey) + } + + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) + context.recordGeoParticipant(pubkeyHex: event.pubkey) + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + if GeoPresenceTracker.hasTeleportTag(event) { + let key = event.pubkey.lowercased() + let isSelf: Bool = { + if let gh = context.currentGeohash, + let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { + return myIdentity.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + presence.scheduleMarkPeerTeleported(key, logged: false) + } + } + + let senderName = context.displayNameForNostrPubkey(event.pubkey) + let content = event.content.trimmed + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let timestamp = min(rawTs, Date()) + let mentions = context.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak context] in + guard let context else { return } + let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) + context.handlePublicMessage(message) + if !isBlocked { + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + } + } + + @MainActor + func handleNostrEvent(_ event: NostrEvent) { + guard let context else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification — + // duplicates dominate real traffic and must not pay for crypto. + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + if context.hasProcessedNostrEvent(event.id) { return } + guard event.isValidSignature() else { return } + context.recordProcessedNostrEvent(event.id) + + // Sampled: fires for every geo event and floods dev logs in busy geohashes. + geoEventLogCount += 1 + if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + } + + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } + + let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event) + + let isSelf: Bool = { + if let gh = context.currentGeohash, + let my = try? context.deriveNostrIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == event.pubkey.lowercased() + } + return false + }() + + if hasTeleportTag, !isSelf { + presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true) + } + + context.recordGeoParticipant(pubkeyHex: event.pubkey) + + if isSelf { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) + } + + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + let senderName = context.displayNameForNostrPubkey(event.pubkey) + let content = event.content + + if let teleTag = event.tags.first(where: { $0.first == "t" }), + teleTag.count >= 2, + teleTag[1] == "teleport", + content.trimmed.isEmpty { + return + } + + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = context.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: min(rawTs, Date()), + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak context] in + guard let context else { return } + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + } + + @MainActor + func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } + // Dedup lookup before Schnorr verification; record only after it passes. + guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } + guard giftWrap.isValidSignature() else { return } + context.recordProcessedNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ), + let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let noisePayload = NoisePayload.decode(packet.payload) + else { + return + } + + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = PeerID(nostr_: senderPubkey) + context.registerNostrKeyMapping(senderPubkey, for: convKey) + + switch noisePayload.type { + case .privateMessage: + context.handlePrivateMessage( + noisePayload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } + // Dedup lookup before Schnorr verification; record only after it passes. + if context.hasProcessedNostrEvent(giftWrap.id) { + return + } + guard giftWrap.isValidSignature() else { return } + context.recordProcessedNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ) else { + SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) + return + } + + SecureLogger.debug( + "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", + category: .session + ) + + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) + else { + return + } + + let convKey = PeerID(nostr_: senderPubkey) + context.registerNostrKeyMapping(senderPubkey, for: convKey) + + switch payload.type { + case .privateMessage: + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func handleNostrMessage(_ giftWrap: NostrEvent) { + guard let context else { return } + // Cheap dedup pre-check only; Schnorr verification runs off-main in + // processNostrMessage, which then does the authoritative + // check-and-record. Recording stays after verification so a + // forged-signature copy can never poison the dedup set and suppress + // the genuine event. + if context.hasProcessedNostrEvent(giftWrap.id) { return } + + Task.detached(priority: .userInitiated) { [weak self] in + await self?.processNostrMessage(giftWrap) + } + } + + func processNostrMessage(_ giftWrap: NostrEvent) async { + guard giftWrap.isValidSignature() else { return } + guard let context else { return } + // Authoritative check-and-record, atomic on the main actor so two + // concurrent detached tasks can't both process the same event. + let alreadyProcessed: Bool = await MainActor.run { + if context.hasProcessedNostrEvent(giftWrap.id) { return true } + context.recordProcessedNostrEvent(giftWrap.id) + return false + } + if alreadyProcessed { return } + let currentIdentity: NostrIdentity? = await MainActor.run { + context.currentNostrIdentity() + } + guard let currentIdentity else { return } + + do { + let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: currentIdentity + ) + + if content.hasPrefix("verify:") { + return + } + + if content.hasPrefix("bitchat1:") { + let packet: BitchatPacket? = await MainActor.run { + Self.decodeEmbeddedBitChatPacket(from: content) + } + guard let packet else { + SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) + return + } + + let actualSenderNoiseKey: Data? = await MainActor.run { + self.findNoiseKey(for: senderPubkey) + } + let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) + + if packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) { + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + await MainActor.run { + context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) + + switch payload.type { + case .privateMessage: + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID, + id: currentIdentity, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .readReceipt: + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .verifyChallenge, .verifyResponse: + break + } + } + } + } else { + SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) + } + } catch { + SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) + } + } + + /// Resolves the Noise static key behind a Nostr pubkey via the favorites + /// store. Lives here because the inbound DM path needs it per message; + /// the favorites glue in `ChatNostrCoordinator` delegates to it. + @MainActor + func findNoiseKey(for nostrPubkey: String) -> Data? { + let favorites = FavoritesPersistenceService.shared.favorites.values + var npubToMatch = nostrPubkey + + if !nostrPubkey.hasPrefix("npub") { + if let pubkeyData = Data(hexString: nostrPubkey), + let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { + npubToMatch = encoded + } else { + SecureLogger.warning( + "⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", + category: .session + ) + } + } + + for relationship in favorites { + if let storedNostrKey = relationship.peerNostrPublicKey { + if storedNostrKey == npubToMatch { + return relationship.peerNoisePublicKey + } + if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { + SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) + return relationship.peerNoisePublicKey + } + } + } + + SecureLogger.debug( + "⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", + category: .session + ) + return nil + } +} + +private extension NostrInboundPipeline { + @MainActor + static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { + guard content.hasPrefix("bitchat1:") else { return nil } + let encoded = String(content.dropFirst("bitchat1:".count)) + let maxBytes = FileTransferLimits.maxFramedFileBytes + let maxEncoded = ((maxBytes + 2) / 3) * 4 + guard encoded.count <= maxEncoded else { return nil } + guard let packetData = Base64URLCoding.decode(encoded), + packetData.count <= maxBytes + else { + return nil + } + return BitchatPacket.from(packetData) + } +} diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 30d208e4..795d6e8e 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -2,10 +2,11 @@ // ChatNostrCoordinatorContextTests.swift // bitchatTests // -// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` — -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests` -// exemplars. +// Exercises the `ChatNostrCoordinator` facade and its components +// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`) +// against a mock `ChatNostrContext` — proving the stack works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. // import Testing @@ -15,8 +16,9 @@ import BitFoundation // MARK: - Mock Context -/// Lightweight stand-in for `ChatNostrContext` proving that -/// `ChatNostrCoordinator` is testable without a `ChatViewModel`. +/// Lightweight stand-in for `ChatNostrContext` (and, via protocol +/// inheritance, the component contexts) proving that the Nostr stack is +/// testable without a `ChatViewModel`. @MainActor private final class MockChatNostrContext: ChatNostrContext { // Channel & subscription state @@ -250,7 +252,7 @@ struct ChatNostrCoordinatorContextTests { ) context.displayNamesByPubkey[event.pubkey] = "alice#1234" - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // Dedup recorded exactly once, presence and key mapping updated. @@ -268,7 +270,7 @@ struct ChatNostrCoordinatorContextTests { #expect(context.hapticMessageIDs == [event.id]) // A replay of the same event is dropped before any processing. - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() #expect(context.recordedNostrEventIDs == [event.id]) #expect(context.handledPublicMessages.count == 1) @@ -288,7 +290,7 @@ struct ChatNostrCoordinatorContextTests { teleported: true ) - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // Teleport detection fires even though the empty message is dropped. @@ -310,7 +312,7 @@ struct ChatNostrCoordinatorContextTests { ) context.blockedNostrPubkeys.insert(event.pubkey.lowercased()) - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // The event is still recorded for dedup but nothing else happens. @@ -337,7 +339,7 @@ struct ChatNostrCoordinatorContextTests { senderIdentity: sender ) - coordinator.handleGiftWrap(giftWrap, id: recipient) + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) let convKey = PeerID(nostr_: sender.publicKeyHex) #expect(context.recordedNostrEventIDs == [giftWrap.id]) @@ -354,7 +356,7 @@ struct ChatNostrCoordinatorContextTests { #expect(pm.content == "psst") // The same gift wrap is dropped on replay. - coordinator.handleGiftWrap(giftWrap, id: recipient) + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.handledPrivateMessages.count == 1) } @@ -367,7 +369,7 @@ struct ChatNostrCoordinatorContextTests { context.currentGeohash = "u4pruyd" context.geoNicknames = ["abcd": "alice"] - coordinator.switchLocationChannel(to: .mesh) + coordinator.subscriptions.switchLocationChannel(to: .mesh) #expect(context.pipelineResetCount == 1) #expect(context.activeChannel == .mesh) @@ -451,3 +453,97 @@ struct ChatNostrCoordinatorContextTests { #expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil) } } + +// MARK: - GeoPresenceTracker Tests + +/// Focused tests for seams the coordinator split made independently +/// testable: the sampling-event LRU dedup and the per-geohash notification +/// cooldown. The cooldown tests stop short of the live notification center by +/// pre-seeding the timeline append as a duplicate. +struct GeoPresenceTrackerTests { + + @Test @MainActor + func samplingEventDedup_evictsOldestBeyondLRUCap() { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let cap = TransportConfig.geoSamplingEventLRUCap + + // Empty IDs are never deduplicated. + #expect(tracker.shouldProcessGeoSamplingEvent("")) + #expect(tracker.shouldProcessGeoSamplingEvent("")) + + // First sight passes; a replay is rejected. + #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) + #expect(!tracker.shouldProcessGeoSamplingEvent("ev-0")) + + // Fill one past the cap: the oldest entry is evicted and accepted + // again, while a still-resident entry stays deduplicated. + for i in 1...cap { + #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)")) + } + #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) + #expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) + + // Clearing resets the dedup entirely. + tracker.clearGeoSamplingEventDedup() + #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) + } + + @Test @MainActor + func notificationCooldown_skipsWithinWindow() async throws { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "sampled activity", + geohash: "9q8yy", + senderIdentity: sender, + nickname: "alice" + ) + + // Within the cooldown window nothing is appended or re-stamped. + let recent = Date() + context.lastGeoNotificationAt["9q8yy"] = recent + tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) + await drainMainQueue() + #expect(context.appendedGeohashMessages.isEmpty) + #expect(context.lastGeoNotificationAt["9q8yy"] == recent) + #expect(context.synchronizedGeohashes.isEmpty) + } + + @Test @MainActor + func notificationCooldown_stampsGeohashOnceWindowElapses() async throws { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "sampled activity", + geohash: "9q8yy", + senderIdentity: sender, + nickname: "alice" + ) + + // Pre-seed the same event ID so the timeline append reports a + // duplicate and the flow never reaches the live notification center. + let placeholder = BitchatMessage( + id: event.id, + sender: "seed", + content: "seed", + timestamp: Date(), + isRelay: false + ) + #expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy")) + + // Cooldown elapsed: the geohash is re-stamped and the append is + // attempted (and rejected as a duplicate, so no store sync either). + let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1) + context.lastGeoNotificationAt["9q8yy"] = stale + tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) + await drainMainQueue() + + let stamped = try #require(context.lastGeoNotificationAt["9q8yy"]) + #expect(stamped > stale) + #expect(context.appendedGeohashMessages.count == 1) + #expect(context.synchronizedGeohashes.isEmpty) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 34ef1918..332489a6 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -51,7 +51,7 @@ final class PerformanceBaselineTests: XCTestCase { // MARK: - 1a. Nostr inbound event handling (fresh events) - /// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events + /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events /// (kind 20000): signature verification, dedup record, presence/nickname /// bookkeeping, and public-message ingest scheduling. func testNostrInboundEventHandling_freshEvents() throws { @@ -68,7 +68,7 @@ final class PerformanceBaselineTests: XCTestCase { keepAlive.append((context, coordinator)) let start = Date() for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) XCTAssertEqual(context.processedEventCount, events.count) @@ -89,7 +89,7 @@ final class PerformanceBaselineTests: XCTestCase { let coordinator = ChatNostrCoordinator(context: context) // Pre-warm: every event is now recorded as processed. for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } XCTAssertEqual(context.processedEventCount, events.count) var samples: [TimeInterval] = [] @@ -97,7 +97,7 @@ final class PerformanceBaselineTests: XCTestCase { measure { let start = Date() for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) } @@ -370,8 +370,9 @@ private struct SeededGenerator: RandomNumberGenerator { // MARK: - Mock ChatNostrContext -/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator` -/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`). +/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator` +/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel` +/// (mirrors `ChatNostrCoordinatorContextTests`). /// Callbacks are cheap dictionary/array operations so the measured cost is /// the coordinator's own pipeline. @MainActor