fix: keep NDR acknowledgements on ratchet sessions

This commit is contained in:
Dev 2026-07-27 23:55:39 +03:00
parent 1e766b8d4f
commit 9abb8fce0f
8 changed files with 427 additions and 25 deletions

View File

@ -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.

View File

@ -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) {

View File

@ -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
)
}

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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<String>] = [:]
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()

View File

@ -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) {}

View File

@ -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 {