diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index b2b8614b..7885b280 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -140,7 +140,7 @@ final class NostrTransport: Transport, @unchecked Sendable { /// Ack pacing shared across transport instances. Geohash acks are sent /// through short-lived transports created per ack - /// (`makeGeohashNostrTransport()`), so a per-instance queue would only + /// (`makeNostrTransport()`), so a per-instance queue would only /// ever hold one item and never pace a burst (flagged by Codex on /// #1398). Production wires `sharedAckPacer` via `Dependencies.live`; /// tests get an isolated instance per `Dependencies` by default. diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index e011c763..36fccbec 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -94,6 +94,8 @@ protocol ChatPrivateConversationContext: AnyObject { func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) // MARK: System messages func addMeshOnlySystemMessage(_ content: String) @@ -185,7 +187,7 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { - makeGeohashNostrTransport().sendPrivateMessageGeohash( + makeNostrTransport().sendPrivateMessageGeohash( content: content, toRecipientHex: recipientHex, from: identity, @@ -194,11 +196,24 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) + makeNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) } func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) + makeNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) + } + + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) { + makeNostrTransport().sendDeliveryAck(for: messageID, to: peerID) + } + + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) { + let receipt = ReadReceipt( + originalMessageID: messageID, + readerID: myPeerID, + readerNickname: nickname + ) + makeNostrTransport().sendReadReceipt(receipt, to: peerID) } func addSystemMessage(_ content: String) { @@ -226,7 +241,7 @@ extension ChatViewModel: ChatPrivateConversationContext { NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) } - private func makeGeohashNostrTransport() -> NostrTransport { + private func makeNostrTransport() -> NostrTransport { let transport = NostrTransport(keychain: keychain, idBridge: idBridge) transport.senderPeerID = meshService.myPeerID return transport @@ -486,7 +501,8 @@ final class ChatPrivateConversationCoordinator { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource = .legacy1059 ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID @@ -494,7 +510,13 @@ final class ChatPrivateConversationCoordinator { // Ack before the dedup guard: a re-sent copy means the sender may not // have our DELIVERED yet, and markGeoDeliveryAckSent dedups the // actual sends. - sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + sendDeliveryAckIfNeeded( + to: messageId, + senderPubKey: senderPubkey, + conversationPeerID: convKey, + from: id, + source: source + ) guard markInboundGeoDMSeen(messageId) else { return } @@ -555,7 +577,13 @@ final class ChatPrivateConversationCoordinator { } if isViewing { - sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + sendReadReceiptIfNeeded( + to: messageId, + senderPubKey: senderPubkey, + conversationPeerID: convKey, + from: id, + source: source + ) } if !isViewing && shouldMarkUnread { @@ -633,14 +661,50 @@ final class ChatPrivateConversationCoordinator { } } - func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + func sendDeliveryAckIfNeeded( + to messageId: String, + senderPubKey: String, + conversationPeerID: PeerID, + from id: NostrIdentity, + source: NostrPrivateMessageSource + ) { guard context.markGeoDeliveryAckSent(messageId) else { return } - context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id) + switch source { + case .ndr: + context.sendAccountNostrDeliveryAck( + for: messageId, + to: conversationPeerID + ) + case .legacy1059: + context.sendGeohashDeliveryAck( + for: messageId, + toRecipientHex: senderPubKey, + from: id + ) + } } - func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + func sendReadReceiptIfNeeded( + to messageId: String, + senderPubKey: String, + conversationPeerID: PeerID, + from id: NostrIdentity, + source: NostrPrivateMessageSource + ) { guard context.markReadReceiptSent(messageId) else { return } - context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) + switch source { + case .ndr: + context.sendAccountNostrReadReceipt( + for: messageId, + to: conversationPeerID + ) + case .legacy1059: + context.sendGeohashReadReceipt( + messageId, + toRecipientHex: senderPubKey, + from: id + ) + } } func handlePrivateMessage(_ message: BitchatMessage) { diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index d77a4221..84d4f0a6 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -34,14 +34,16 @@ extension ChatViewModel { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource = .legacy1059 ) { privateConversationCoordinator.handlePrivateMessage( payload, senderPubkey: senderPubkey, convKey: convKey, id: id, - messageTimestamp: messageTimestamp + messageTimestamp: messageTimestamp, + source: source ) } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 192506f6..f4f0d057 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -2,6 +2,11 @@ import BitFoundation import BitLogger import Foundation +enum NostrPrivateMessageSource: Equatable { + case legacy1059 + case ndr +} + /// The narrow surface `NostrInboundPipeline` needs from its owner. /// /// Split out of `ChatNostrContext`: member names are shared with the sibling @@ -51,7 +56,8 @@ protocol NostrInboundPipelineContext: AnyObject { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource ) func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) @@ -427,7 +433,8 @@ final class NostrInboundPipeline { senderPubkey: senderPubkey, convKey: convKey, id: id, - messageTimestamp: messageTimestamp + messageTimestamp: messageTimestamp, + source: .legacy1059 ) case .delivered: context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) @@ -493,7 +500,7 @@ final class NostrInboundPipeline { currentIdentity: currentIdentity, wipeGeneration: wipeGeneration, expiresAtSeconds: message.expiresAtSeconds, - requiresFavoriteBinding: true + source: .ndr ) guard self.wipeGeneration == wipeGeneration else { completion(.retry) @@ -548,7 +555,8 @@ final class NostrInboundPipeline { senderPubkey: senderPubkey, rumorTimestamp: rumorTimestamp, currentIdentity: currentIdentity, - wipeGeneration: wipeGeneration + wipeGeneration: wipeGeneration, + source: .legacy1059 ) } catch { SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) @@ -563,7 +571,7 @@ final class NostrInboundPipeline { currentIdentity: NostrIdentity, wipeGeneration: UInt64, expiresAtSeconds: UInt64? = nil, - requiresFavoriteBinding: Bool = false + source: NostrPrivateMessageSource ) async -> NdrDeliveryDisposition { guard let context else { return .retry } if content.hasPrefix("verify:") { @@ -587,7 +595,7 @@ final class NostrInboundPipeline { let actualSenderNoiseKey: Data? = await MainActor.run { self.findNoiseKey(for: routingPubkey) } - if requiresFavoriteBinding, actualSenderNoiseKey == nil { + if source == .ndr, actualSenderNoiseKey == nil { // Keep the native delivery durable until the favorite binding // journal is recovered. Falling through to a virtual Nostr peer // would bypass the fail-closed pairwise identity binding. @@ -626,7 +634,8 @@ final class NostrInboundPipeline { senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, - messageTimestamp: messageTimestamp + messageTimestamp: messageTimestamp, + source: source ) case .delivered: context.handleDelivered( diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index cff5032d..c43246cd 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -79,7 +79,15 @@ private final class MockChatNostrContext: ChatNostrContext { var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] + private(set) var handledPrivateMessages: [ + ( + payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + timestamp: Date, + source: NostrPrivateMessageSource + ) + ] = [] private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var startedPrivateChats: [PeerID] = [] @@ -89,9 +97,16 @@ private final class MockChatNostrContext: ChatNostrContext { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource ) { - handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp)) + handledPrivateMessages.append(( + payload, + senderPubkey, + convKey, + messageTimestamp, + source + )) } func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { @@ -367,6 +382,7 @@ struct ChatNostrCoordinatorContextTests { #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.convKey == convKey) + #expect(context.handledPrivateMessages.first?.source == .legacy1059) // The embedded Noise payload survives the round trip intact. let payload = try #require(context.handledPrivateMessages.first?.payload) @@ -488,6 +504,7 @@ struct ChatNostrCoordinatorContextTests { await coordinator.inbound.processNostrMessage(acceptedGiftWrap) #expect(context.handledPrivateMessages.count == 1) + #expect(context.handledPrivateMessages.first?.source == .legacy1059) } @Test @MainActor @@ -731,6 +748,17 @@ struct GeoPresenceTrackerTests { let recipient = try NostrIdentity.generate() let sender = try NostrIdentity.generate() context.nostrIdentity = recipient + let senderNoiseKey = Data(repeating: 0xA8, count: 32) + context.favoriteRelationshipsByNoiseKey[senderNoiseKey] = + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: sender.npub, + peerNickname: "expired-peer", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) let senderPeerID = PeerID(str: "0011223344556677") let embedded = try #require( NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( @@ -781,4 +809,64 @@ struct GeoPresenceTrackerTests { #expect(context.recordedNostrEventIDs == [rumor.id]) } + @Test @MainActor + func ndrDelivery_marksPrivateMessageAsPairwiseOnly() async throws { + let context = MockChatNostrContext() + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0xA9, count: 32) + context.nostrIdentity = recipient + context.favoriteRelationshipsByNoiseKey[noiseKey] = + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: sender.npub, + peerNickname: "pairwise-peer", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) + let embedded = try #require( + NostrEmbeddedBitChat.encodePMForNostr( + content: "pairwise inbound", + messageID: "ndr-inbound-1", + recipientPeerID: PeerID(hexData: noiseKey), + senderPeerID: PeerID(str: "0011223344556677") + ) + ) + var rumor = try NostrEvent( + pubkey: sender.publicKeyHex, + createdAt: Date(timeIntervalSince1970: 99), + kind: .dm, + tags: [], + content: embedded + ).sign(with: sender.schnorrSigningKey()) + rumor.sig = nil + let pipeline = NostrInboundPipeline( + context: context, + presence: GeoPresenceTracker(context: context) + ) + var disposition: NdrDeliveryDisposition? + + pipeline.handleNdrDecryptedMessage( + NdrDecryptedMessage( + event: rumor, + senderPubkeyHex: sender.publicKeyHex, + outerEventID: String(repeating: "c", count: 64), + expiresAtSeconds: nil + ), + completion: { disposition = $0 } + ) + let completed = await TestHelpers.waitUntil( + { disposition != nil }, + timeout: TestConstants.settleTimeout + ) + + #expect(completed) + #expect(disposition == .consumed) + #expect(context.handledPrivateMessages.count == 1) + #expect(context.handledPrivateMessages.first?.convKey == PeerID(hexData: noiseKey)) + #expect(context.handledPrivateMessages.first?.source == .ndr) + } + } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 54cb341e..ba3c3d29 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -176,6 +176,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + private(set) var accountNostrDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] + private(set) var accountNostrReadReceipts: [(messageID: String, peerID: PeerID)] = [] var queuedMessageIDsByPeerID: [PeerID: Set] = [:] private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] private(set) var deliveredMessageIDs: [String] = [] @@ -222,6 +224,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC geoReadReceipts.append((messageID, recipientHex)) } + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) { + accountNostrDeliveryAcks.append((messageID, peerID)) + } + + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) { + accountNostrReadReceipts.append((messageID, peerID)) + } + // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] @@ -474,6 +484,8 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"]) #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) #expect(context.sentGeoDeliveryAcks == ["geo-1"]) + #expect(context.accountNostrDeliveryAcks.isEmpty) + #expect(context.accountNostrReadReceipts.isEmpty) #expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"]) #expect(context.privateChats[convKey]?.first?.sender == "bob#5678") #expect(context.unreadPrivateMessages.isEmpty) @@ -491,6 +503,91 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.privateChats[convKey]?.count == 1) } + @Test @MainActor + func ndrPrivateMessage_sendsOnlyPairwiseDeliveryAndReadAcks() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID( + hexData: Data(repeating: 0xA6, count: 32) + ) + let senderPubkey = String(repeating: "b", count: 64) + context.selectedPrivateChatPeer = stablePeerID + context.displayNamesByPubkey[senderPubkey] = "bob" + let payloadData = PrivateMessagePacket( + messageID: "ndr-ack-1", + content: "pairwise" + ).encode()! + let payload = NoisePayload( + type: .privateMessage, + data: payloadData + ) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrDeliveryAcks.first?.messageID == "ndr-ack-1") + #expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID) + #expect(context.accountNostrReadReceipts.count == 1) + #expect(context.accountNostrReadReceipts.first?.messageID == "ndr-ack-1") + #expect(context.accountNostrReadReceipts.first?.peerID == stablePeerID) + #expect(context.geoDeliveryAcks.isEmpty) + #expect(context.geoReadReceipts.isEmpty) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrReadReceipts.count == 1) + #expect(context.privateChats[stablePeerID]?.count == 1) + } + + @Test @MainActor + func ndrPrivateMessage_notViewingSendsOnlyPairwiseDeliveryAck() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID( + hexData: Data(repeating: 0xA5, count: 32) + ) + let senderPubkey = String(repeating: "c", count: 64) + context.displayNamesByPubkey[senderPubkey] = "carol" + let payload = NoisePayload( + type: .privateMessage, + data: PrivateMessagePacket( + messageID: "ndr-background-1", + content: "background" + ).encode()! + ) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID) + #expect(context.accountNostrReadReceipts.isEmpty) + #expect(context.geoDeliveryAcks.isEmpty) + #expect(context.geoReadReceipts.isEmpty) + #expect(context.unreadPrivateMessages == Set([stablePeerID])) + } + @Test @MainActor func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { let context = MockChatPrivateConversationContext() diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 453a4a9a..870b8532 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -684,7 +684,14 @@ private final class PerfNostrContext: ChatNostrContext { var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date, + source: NostrPrivateMessageSource + ) {} func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func startPrivateChat(with peerID: PeerID) {} diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 12fe9a3c..c2acfe8e 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -744,6 +744,126 @@ struct NostrTransportTests { #expect(result.packet.recipientID == fullPeerID.toShort().routingData) } + @Test("Direct delivery and read ACKs stay on an established NDR session") + @MainActor + func directAcksUseNdrWhenSessionExists() async throws { + let keychain = MockKeychain() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let senderRelay = FakeRelayManager() + let recipientRelay = FakeRelayManager() + let senderNdr = NdrNostrService( + relayManager: senderRelay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "direct-ack-ndr-sender") + } + ) + let recipientNdr = NdrNostrService( + relayManager: recipientRelay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "direct-ack-ndr-recipient") + } + ) + senderNdr.configureIfNeeded(identity: sender) + recipientNdr.configureIfNeeded(identity: recipient) + try establishMutualSession( + senderNdr, + recipientNdr, + senderIdentity: sender, + recipientIdentity: recipient, + senderRelay: senderRelay, + recipientRelay: recipientRelay + ) + senderRelay.resetSentEvents() + + let noiseKey = Data((144..<176).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Ack peer" + ) + let legacyProbe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: keychain), + ndrService: senderNdr, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + isNdrFallbackBlockedForPeerID: { + $0.toShort() == peerID.toShort() + }, + currentIdentity: { sender }, + sendEvent: legacyProbe.record(event:), + scheduleAfter: { delay, action in + legacyProbe.enqueueScheduledAction( + delay: delay, + action: action + ) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + var decryptedMessages: [NdrDecryptedMessage] = [] + recipientNdr.onDecryptedMessage = { message, completion in + decryptedMessages.append(message) + completion(.consumed) + } + + transport.sendDeliveryAck(for: "ndr-delivered-1", to: peerID) + let deliveredSent = await TestHelpers.waitUntil({ + senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1 + }) + #expect(deliveredSent) + let deliveredOuter = try #require( + senderRelay.sentEvents.first { $0.kind == 1060 } + ) + recipientNdr.processInboundRelayEvent(deliveredOuter) + + let receipt = ReadReceipt( + originalMessageID: "ndr-read-1", + readerID: transport.myPeerID, + readerNickname: "me" + ) + transport.sendReadReceipt(receipt, to: peerID) + let readQueued = await TestHelpers.waitUntil({ + legacyProbe.scheduledActionCount == 1 + }) + #expect(readQueued) + #expect(legacyProbe.runNextScheduledAction()) + let readSent = await TestHelpers.waitUntil({ + senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 2 + }) + #expect(readSent) + let readOuter = try #require( + senderRelay.sentEvents.filter { $0.kind == 1060 }.last + ) + recipientNdr.processInboundRelayEvent(readOuter) + + #expect(legacyProbe.sentEvents.isEmpty) + #expect(decryptedMessages.count == 2) + let deliveredPayload = try decodeNdrEmbeddedPayload( + from: decryptedMessages[0].event.content + ) + #expect(deliveredPayload.type == .delivered) + #expect( + String(data: deliveredPayload.data, encoding: .utf8) + == "ndr-delivered-1" + ) + let readPayload = try decodeNdrEmbeddedPayload( + from: decryptedMessages[1].event.content + ) + #expect(readPayload.type == .readReceipt) + #expect( + String(data: readPayload.data, encoding: .utf8) + == "ndr-read-1" + ) + } + @Test("Geohash private message registers pending gift wrap") @MainActor func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws { @@ -1066,6 +1186,21 @@ struct NostrTransportTests { return (packet, payload, senderPubkey) } + private func decodeNdrEmbeddedPayload( + from content: String + ) throws -> NoisePayload { + guard content.hasPrefix("bitchat1:") else { + throw NostrTransportTestError.invalidEmbeddedContent + } + let encoded = String(content.dropFirst("bitchat1:".count)) + guard let packetData = base64URLDecode(encoded), + let packet = BitchatPacket.from(packetData), + let payload = NoisePayload.decode(packet.payload) else { + throw NostrTransportTestError.invalidPacket + } + return payload + } + private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket { guard payload.type == .privateMessage, let message = PrivateMessagePacket.decode(from: payload.data) else {