Harden Nostr envelope migration compatibility

This commit is contained in:
jack
2026-07-12 10:01:02 -04:00
parent cc32f0b596
commit 84add32552
18 changed files with 1833 additions and 259 deletions
+35 -3
View File
@@ -52,9 +52,41 @@ NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients. New envelopes use provisional,
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
For mixed-version delivery, clients also publish a compatibility-only legacy
kind-1059 copy through October 15, 2026 at 00:00 UTC; kind 1402 remains the
primary format and is the only format published after that deadline.
For mixed-version delivery, clients publish both the primary kind-1402
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
that supported older clients have migrated. Receivers subscribe to both kinds
and deduplicate the authenticated embedded BitChat payload.
Private-envelope migration compatibility:
| Sender | Receiver | Delivery path |
| --- | --- | --- |
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
| New iOS | Released iOS | Compatibility kind 1059 |
| New iOS | Current Android | Compatibility kind 1059 |
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
New kind-1402 envelopes require an empty inner tag list. The Android recipient
tag exception is intentionally confined to legacy kind 1059 and accepts only
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
delivery window plus Android's full 48-hour timestamp randomization and 15
minutes of clock skew. Recovery uses
one independent 500-event relay filter per wire kind so either format cannot
consume the other's result budget.
The two outbound migration copies are admitted to the relay queue as one
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
of a private pair; if protected capacity is exhausted, the entire new pair is
rejected as a whole. User-message rejection becomes a visible failed delivery;
acknowledgements and favorite notifications retain the exact pair in a
process-wide 256-entry bounded retry queue. A sustained outage beyond that
bound evicts the oldest whole control pair with an explicit warning, never half
a pair.
If either socket write fails, the same queued pair remains pending and both
copies are replayed on the replacement connection. A terminal relay target is
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
### Channel Types
+6 -2
View File
@@ -80,7 +80,7 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. Seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
## 6. Store and Forward
@@ -107,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour retention window plus the full 15-minute timestamp fuzz. During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Through October 15, 2026 at 00:00 UTC, each logical payload is published first in the primary kind-1402 format and then as a compatibility-only legacy copy for older BitChat clients; after that deadline clients publish only kind 1402. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour delivery window plus the full 48-hour timestamp randomization used by deployed Android clients and 15 minutes of clock skew (72 hours 15 minutes total). During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Recovery places the kinds in separate filters within one REQ, each with an independent 500-event limit, so traffic in one format cannot starve the other. Each logical payload is published first in the primary kind-1402 format and then as a compatibility legacy copy for older iOS and current Android clients. There is no calendar cutoff: legacy publication and reception remain until a coordinated cross-platform release confirms supported clients have migrated. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
### 6.5 Delivery Metrics
+63 -27
View File
@@ -17,9 +17,10 @@ struct NostrProtocol {
enum EventKind: Int {
case metadata = 0
case textNote = 1
// Bounded compatibility for BitChat releases that incorrectly emitted
// the proprietary payload under standard NIP kinds. Only kind 1059 is
// temporarily published during migration; all three remain readable.
// Compatibility for BitChat releases that incorrectly emitted the
// proprietary payload under standard NIP kinds. Kind 1059 continues
// to be published and read until a coordinated cross-platform release
// explicitly removes it; all three legacy layers remain readable.
case legacyNIP59Seal = 13
case legacyNIP17DirectMessage = 14
case legacyNIP59GiftWrap = 1059
@@ -57,16 +58,10 @@ struct NostrProtocol {
/// the layer-specific cap below the public ciphertext ceiling.
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
/// Compatibility-only publication stops at this instant. New-format kind
/// 1402 remains first/primary throughout the window; the legacy kind-1059
/// copy exists solely so pre-migration BitChat clients can receive it.
static let legacyPrivateEnvelopePublicationDeadline = Date(
timeIntervalSince1970: 1_792_022_400 // 2026-10-15T00:00:00Z
)
/// New clients subscribe to the provisional BitChat-specific kind and the
/// compatibility-only legacy kind so both sides of a rolling rollout can
/// recover stored messages.
/// compatibility legacy kind so both sides of a rolling rollout can
/// recover stored messages. Do not remove kind 1059 here until all
/// supported iOS and Android releases have migrated.
static let acceptedPrivateEnvelopeKinds = [
EventKind.privateEnvelope.rawValue,
EventKind.legacyNIP59GiftWrap.rawValue
@@ -145,24 +140,22 @@ struct NostrProtocol {
}
/// Events to publish for one logical private payload. The primary
/// BitChat-specific format is always first. Until the explicit migration
/// deadline, a legacy copy follows for clients that still subscribe only
/// to kind 1059. Both encrypt the exact same embedded BitChat payload, so
/// receive-side logical-payload dedup collapses the pair.
/// BitChat-specific format is always first and a legacy copy follows for
/// clients that still subscribe only to kind 1059. There is deliberately
/// no date-based cutoff: removal requires a coordinated iOS/Android
/// release after supported old clients have migrated. Both encrypt the
/// exact same embedded BitChat payload, so receive-side logical-payload
/// dedup collapses the pair.
static func createPrivateEnvelopePublicationBatch(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
now: Date = Date()
senderIdentity: NostrIdentity
) throws -> [NostrEvent] {
let primary = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity
)
guard now < legacyPrivateEnvelopePublicationDeadline else {
return [primary]
}
let compatibilityCopy = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
@@ -176,14 +169,15 @@ struct NostrProtocol {
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
format: PrivateEnvelopeWireFormat
format: PrivateEnvelopeWireFormat,
messageTags: [[String]] = []
) throws -> NostrEvent {
// 1. Create the unsigned inner BitChat message.
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: format.messageKind,
tags: [],
tags: messageTags,
content: content
)
@@ -279,16 +273,37 @@ struct NostrProtocol {
)
}
static func createLegacyPrivateEnvelopeForTesting(
static func createPrivateEnvelopeWithInnerTagsForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
senderIdentity: NostrIdentity,
innerMessageTags: [[String]]
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2
format: .bitchatV1,
messageTags: innerMessageTags
)
}
static func createLegacyPrivateEnvelopeForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]] = []
) throws -> NostrEvent {
// Current Android legacy envelopes use exactly one recipient `p` tag
// on the unsigned inner kind-14 event; released iOS envelopes use no
// inner tags. Tests pass the Android shape explicitly so this helper
// cannot silently make the production encoder depend on that quirk.
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2,
messageTags: innerMessageTags
)
}
@@ -637,7 +652,11 @@ struct NostrProtocol {
// comes from the seal. Bind its claimed sender and custom kind to that
// authenticated layer before exposing content.
guard message.kind == format.messageKind.rawValue,
message.tags.isEmpty,
validInnerMessageTags(
message.tags,
format: format,
recipientPubkey: recipientIdentity.publicKeyHex
),
message.sig == nil,
seal.pubkey == message.pubkey else {
throw NostrError.invalidEvent
@@ -646,6 +665,23 @@ struct NostrProtocol {
return (seal, message)
}
/// Released iOS legacy envelopes used no inner tags, while current
/// Android legacy envelopes use exactly the authenticated recipient tag.
/// Accept only those two historical shapes for kind 1059. The new kind
/// 1402 format remains strict and rejects every inner tag.
private static func validInnerMessageTags(
_ tags: [[String]],
format: PrivateEnvelopeWireFormat,
recipientPubkey: String
) -> Bool {
switch format {
case .bitchatV1:
return tags.isEmpty
case .legacyMislabelledV2:
return tags.isEmpty || tags == [["p", recipientPubkey]]
}
}
private static func decodePrivateEnvelopeEventJSON(
_ json: String,
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
+450 -83
View File
@@ -175,6 +175,7 @@ final class NostrRelayManager: ObservableObject {
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var awaitingTorForQueueFlush = false
private var torReadyWaitAttempts = 0
private var cancellables = Set<AnyCancellable>()
@@ -209,11 +210,32 @@ final class NostrRelayManager: ObservableObject {
private var eoseTrackerEpoch = 0
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
// Message queue for reliability. Pending sends are grouped so a private
// envelope's primary and legacy migration copies can never be split by
// queue eviction. Private batches are protected; lower-priority traffic
// is evicted first and a full protected queue rejects the entire new pair.
private enum PendingSendPriority: Int {
case ephemeral
case regular
case privateEnvelope
}
private struct PendingSend {
var event: NostrEvent
let id: UUID
let events: [NostrEvent]
var pendingRelays: Set<String>
var inFlightConnections: [String: ObjectIdentifier]
var hasSuccessfulDelivery: Bool
let priority: PendingSendPriority
let terminalFailure: (() -> Void)?
}
private struct PendingDelivery {
let itemID: UUID
let events: [NostrEvent]
let priority: PendingSendPriority
let relayUrl: String
let connection: NostrRelayConnectionProtocol
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
@@ -305,7 +327,8 @@ final class NostrRelayManager: ObservableObject {
/// Disconnect from all relays
func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections {
for (relayURL, task) in connections {
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
@@ -327,6 +350,7 @@ final class NostrRelayManager: ObservableObject {
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
awaitingTorForQueueFlush = false
torReadyWaitAttempts = 0
updateConnectionStatus()
}
@@ -351,6 +375,7 @@ final class NostrRelayManager: ObservableObject {
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
awaitingTorForQueueFlush = false
torReadyWaitAttempts = 0
recentInboundEventKeys.removeAll()
recentInboundEventKeyOrder.removeAll()
@@ -399,38 +424,127 @@ final class NostrRelayManager: ObservableObject {
connectToRelays(targets)
}
/// Send an event to specified relays (or all if none specified)
/// Send an event to specified relays (or all if none specified).
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
_ = sendEvents(
[event],
to: relayUrls,
priority: Self.pendingSendPriority(for: event),
terminalFailure: nil
)
}
/// Atomically admits a complete private-envelope migration pair. The
/// queue stores the pair as one protected item, so capacity pressure can
/// neither retain only kind 1402 nor only kind 1059. A `false` result means
/// the entire pair was rejected before any connected-relay sends began.
@discardableResult
func sendPrivateEnvelopeBatch(
_ events: [NostrEvent],
to relayUrls: [String]? = nil,
terminalFailure: (() -> Void)? = nil
) -> Bool {
guard events.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
] else {
SecureLogger.error(
"Refusing malformed private-envelope migration batch kinds=\(events.map(\.kind))",
category: .session
)
return false
}
return sendEvents(
events,
to: relayUrls,
priority: .privateEnvelope,
terminalFailure: terminalFailure
)
}
@discardableResult
private func sendEvents(
_ events: [NostrEvent],
to relayUrls: [String]?,
priority: PendingSendPriority,
terminalFailure: (() -> Void)?
) -> Bool {
guard dependencies.activationAllowed(), !events.isEmpty else { return false }
let requestedRelays = relayUrls ?? Self.defaultRelays
var targetRelays = allowedRelayList(from: requestedRelays)
if priority == .privateEnvelope {
// Do not admit a protected batch against a relay already in its
// terminal cooldown. Such a target has no connection attempt that
// could ever retire the queue record; retry callers can try it
// again after the cooldown decays.
targetRelays.removeAll(where: isPermanentlyFailed)
}
guard !targetRelays.isEmpty else { return false }
if priority == .privateEnvelope {
// Protected pairs are admitted for every target before any socket
// write, including targets already connected. The queue entry
// remains authoritative until both writes succeed per relay.
guard enqueuePendingSendBatch(
events,
pendingRelays: Set(targetRelays),
priority: priority,
terminalFailure: terminalFailure
) != nil else { return false }
ensureConnections(to: targetRelays)
// Preserve the same fail-closed Tor gate as ordinary sends. The
// queue is already durable; a stale socket that still appears
// connected must not be written while enforced Tor is unready.
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
flushMessageQueueWhenTorIsReady()
} else {
for relayUrl in targetRelays where connectedConnection(for: relayUrl) != nil {
flushMessageQueue(for: relayUrl)
}
}
return true
}
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Fail-closed: nothing touches the network until Tor is up. Queue the
// event locally so it survives a slow bootstrap (queued sends flush
// when relays connect), then kick off connection setup, which itself
// waits for Tor readiness.
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
guard !targetRelays.isEmpty else { return }
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
// complete item locally so it survives a slow bootstrap, then kick
// off connection setup, which itself waits for Tor readiness.
guard enqueuePendingSendBatch(
events,
pendingRelays: Set(targetRelays),
priority: priority,
terminalFailure: nil
) != nil else { return false }
ensureConnections(to: targetRelays)
return
return true
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest
// Ordinary single events retain their existing immediate-send path.
var connectedTargets: [(String, NostrRelayConnectionProtocol)] = []
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connectedConnection(for: relayUrl) {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
connectedTargets.append((relayUrl, connection))
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
enqueuePendingSend(event, pendingRelays: stillPending)
guard enqueuePendingSendBatch(
events,
pendingRelays: stillPending,
priority: priority,
terminalFailure: nil
) != nil else { return false }
}
ensureConnections(to: targetRelays)
for (relayUrl, connection) in connectedTargets {
for event in events {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
}
}
return true
}
/// Attempts an event only on currently connected target relays and
@@ -524,66 +638,244 @@ final class NostrRelayManager: ObservableObject {
state.completion(false)
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
if overflow > 0 {
messageQueue.removeFirst(overflow)
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
switch event.kind {
case NostrProtocol.EventKind.ephemeralEvent.rawValue,
NostrProtocol.EventKind.geohashPresence.rawValue:
return .ephemeral
default:
return .regular
}
}
/// Atomically append a whole send item while preserving protected private
/// batches. Capacity is measured in events, not queue records.
private func enqueuePendingSendBatch(
_ events: [NostrEvent],
pendingRelays: Set<String>,
priority: PendingSendPriority,
terminalFailure: (() -> Void)?
) -> UUID? {
guard !events.isEmpty, !pendingRelays.isEmpty,
events.count <= TransportConfig.nostrPendingSendQueueCap else {
SecureLogger.error("Refusing invalid or oversized relay send batch", category: .session)
return nil
}
messageQueueLock.lock()
let queuedEventCount = messageQueue.reduce(0) { $0 + $1.events.count }
let overflow = queuedEventCount + events.count - TransportConfig.nostrPendingSendQueueCap
var removalIndexes: [Int] = []
var evictedEventCount = 0
if overflow > 0 {
var eventsToFree = overflow
let candidates = messageQueue.indices
.filter {
messageQueue[$0].priority != .privateEnvelope &&
messageQueue[$0].priority.rawValue <= priority.rawValue
}
.sorted {
let left = messageQueue[$0].priority.rawValue
let right = messageQueue[$1].priority.rawValue
return left == right ? $0 < $1 : left < right
}
for index in candidates where eventsToFree > 0 {
removalIndexes.append(index)
let count = messageQueue[index].events.count
evictedEventCount += count
eventsToFree -= count
}
if eventsToFree > 0 {
messageQueueLock.unlock()
guard overflow > 0 else { return }
// Dropped events are ephemeral (presence/geo), so no status surfacing
// is needed but the drops should be visible. Sampled so a sustained
// relay stall can't flood the log.
pendingSendDropCount += overflow
if pendingSendDropCount == 1 ||
SecureLogger.error(
"Relay send queue protected-capacity exhausted; rejected entire \(events.count)-event batch",
category: .session
)
return nil
}
for index in removalIndexes.sorted(by: >) {
messageQueue.remove(at: index)
}
}
let itemID = UUID()
messageQueue.append(PendingSend(
id: itemID,
events: events,
pendingRelays: pendingRelays,
inFlightConnections: [:],
hasSuccessfulDelivery: false,
priority: priority,
terminalFailure: terminalFailure
))
messageQueueLock.unlock()
guard evictedEventCount > 0 else { return itemID }
let isFirstEviction = pendingSendDropCount == 0
pendingSendDropCount += evictedEventCount
if isFirstEviction ||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
SecureLogger.warning(
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
category: .session
)
}
return itemID
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
flushMessageQueueWhenTorIsReady()
return
}
var deliveries: [PendingDelivery] = []
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
for target in targets {
guard item.pendingRelays.contains(target),
item.inFlightConnections[target] == nil,
let connection = connectedConnection(for: target) else { continue }
deliveries.append(PendingDelivery(
itemID: item.id,
events: item.events,
priority: item.priority,
relayUrl: target,
connection: connection
))
if item.priority == .privateEnvelope {
// Keep the relay pending until both asynchronous writes
// report success. `inFlightConnections` prevents duplicate
// flushes while those callbacks are outstanding.
item.inFlightConnections[target] = ObjectIdentifier(connection)
} else {
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connectedConnection(for: url) {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
messageQueue.remove(at: index)
} else {
messageQueue[i] = item
messageQueue[index] = item
}
}
messageQueueLock.unlock()
// Never invoke WebSocket callbacks while holding messageQueueLock.
for delivery in deliveries {
if delivery.priority == .privateEnvelope {
sendPrivateEnvelopeBatchToRelay(
delivery.events,
connection: delivery.connection,
relayUrl: delivery.relayUrl
) { [weak self] succeeded in
self?.completePrivateEnvelopeBatchDelivery(
itemID: delivery.itemID,
relayUrl: delivery.relayUrl,
connection: delivery.connection,
succeeded: succeeded
)
}
} else {
for event in delivery.events {
sendToRelay(
event: event,
connection: delivery.connection,
relayUrl: delivery.relayUrl
)
}
}
}
}
private func flushMessageQueueWhenTorIsReady() {
guard !awaitingTorForQueueFlush else { return }
awaitingTorForQueueFlush = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
self.awaitingTorForQueueFlush = false
guard ready else { return }
self.flushMessageQueue(for: nil)
}
}
/// Send both copies in order and report one result for the complete pair.
/// Stop on the first failure; the still-pending queue item retries both
/// after reconnection, which is safe because receivers deduplicate twins.
private func sendPrivateEnvelopeBatchToRelay(
_ events: [NostrEvent],
connection: NostrRelayConnectionProtocol,
relayUrl: String,
index: Int = 0,
completion: @escaping (Bool) -> Void
) {
guard index < events.count else {
completion(true)
return
}
sendToRelay(
event: events[index],
connection: connection,
relayUrl: relayUrl
) { [weak self] succeeded in
guard succeeded,
let self,
self.connections[relayUrl] === connection else {
completion(false)
return
}
self.sendPrivateEnvelopeBatchToRelay(
events,
connection: connection,
relayUrl: relayUrl,
index: index + 1,
completion: completion
)
}
}
private func completePrivateEnvelopeBatchDelivery(
itemID: UUID,
relayUrl: String,
connection: NostrRelayConnectionProtocol,
succeeded: Bool
) {
messageQueueLock.lock()
if let index = messageQueue.firstIndex(where: { $0.id == itemID }) {
var item = messageQueue[index]
guard item.inFlightConnections[relayUrl] == ObjectIdentifier(connection) else {
messageQueueLock.unlock()
return
}
item.inFlightConnections.removeValue(forKey: relayUrl)
if succeeded {
item.hasSuccessfulDelivery = true
item.pendingRelays.remove(relayUrl)
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
guard !succeeded else { return }
// A failed WebSocket write means this connection cannot advance the
// durable queue. Keep the original pair pending and reconnect with the
// manager's bounded backoff; the next successful ping flushes it.
handleDisconnection(
relayUrl: relayUrl,
error: NSError(
domain: "NostrRelayPrivateEnvelopeBatch",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "private-envelope batch write failed"]
),
connection: connection
)
}
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
@@ -599,9 +891,28 @@ final class NostrRelayManager: ObservableObject {
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
subscribe(
filters: [filter],
id: id,
relayUrls: relayUrls,
handler: handler,
onEOSE: onEOSE
)
}
/// Subscribe with independent Nostr filters in one REQ. Each filter keeps
/// its own relay-side limit; this is required for mixed-version mailbox
/// recovery so kind 1402 cannot starve kind 1059 (or the reverse).
func subscribe(
filters: [NostrFilter],
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
guard dependencies.activationAllowed(), !filters.isEmpty else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
@@ -610,7 +921,7 @@ final class NostrRelayManager: ObservableObject {
subscribeCoalesce[id] = now
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
let req = NostrRequest.subscribe(id: id, filters: filters)
do {
let message = try encoder.encode(req)
@@ -677,6 +988,7 @@ final class NostrRelayManager: ObservableObject {
ensureConnections(to: Self.defaultRelays)
}
} else {
var terminalFailures: [() -> Void] = []
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
@@ -684,18 +996,9 @@ final class NostrRelayManager: ObservableObject {
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
terminalFailures.forEach { $0() }
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
@@ -1285,6 +1588,7 @@ final class NostrRelayManager: ObservableObject {
connection: NostrRelayConnectionProtocol? = nil
) {
if let connection, connections[relayUrl] !== connection { return }
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
@@ -1305,7 +1609,10 @@ final class NostrRelayManager: ObservableObject {
let ns = error as NSError
if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
(ns.domain == NSURLErrorDomain && (
ns.code == NSURLErrorBadServerResponse ||
ns.code == NSURLErrorCannotFindHost
)) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
}
@@ -1315,6 +1622,8 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil
}
pendingSubscriptions[relayUrl] = nil
let terminalFailures = retirePendingSendRelay(relayUrl)
terminalFailures.forEach { $0() }
return
}
@@ -1326,6 +1635,8 @@ final class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
let terminalFailures = retirePendingSendRelay(relayUrl)
terminalFailures.forEach { $0() }
return
}
@@ -1364,6 +1675,54 @@ final class NostrRelayManager: ObservableObject {
}
}
/// A relay in terminal cooldown cannot make progress on queued sends. Drop
/// it from every target set so one unavailable default relay cannot pin
/// otherwise-delivered protected batches forever. If this was the last
/// target and no relay completed the pair, report whole-batch failure to
/// the originating transport for visible failure or retry.
private func retirePendingSendRelay(_ relayUrl: String) -> [() -> Void] {
var terminalFailures: [() -> Void] = []
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
guard item.pendingRelays.remove(relayUrl) != nil else { continue }
item.inFlightConnections.removeValue(forKey: relayUrl)
if item.pendingRelays.isEmpty {
if item.priority == .privateEnvelope,
!item.hasSuccessfulDelivery,
let terminalFailure = item.terminalFailure {
terminalFailures.append(terminalFailure)
}
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
return terminalFailures
}
/// Release the in-flight marker owned by a dead socket without removing
/// the relay from the durable pending set. A stale completion from that
/// socket is ignored by its ObjectIdentifier check.
private func clearPendingSendInFlight(
relayUrl: String,
connection: NostrRelayConnectionProtocol?
) {
let expectedIdentifier = connection.map(ObjectIdentifier.init)
messageQueueLock.lock()
for index in messageQueue.indices {
var item = messageQueue[index]
guard let inFlightIdentifier = item.inFlightConnections[relayUrl],
expectedIdentifier == nil || expectedIdentifier == inFlightIdentifier else {
continue
}
item.inFlightConnections.removeValue(forKey: relayUrl)
messageQueue[index] = item
}
messageQueueLock.unlock()
}
// MARK: - Public Utility Methods
/// Manually retry connection to a specific relay
@@ -1378,6 +1737,10 @@ final class NostrRelayManager: ObservableObject {
// Disconnect if connected
if let connection = connections[normalizedRelayUrl] {
clearPendingSendInFlight(
relayUrl: normalizedRelayUrl,
connection: connection
)
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
}
@@ -1399,7 +1762,13 @@ final class NostrRelayManager: ObservableObject {
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
return messageQueue.reduce(0) { $0 + $1.events.count }
}
var debugPendingMessageQueueEventIDsByBatch: [[String]] {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.map { $0.events.map(\.id) }
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
@@ -1604,20 +1973,18 @@ struct NostrFilter: Encodable {
}
// BitChat private envelopes, plus compatibility legacy envelopes emitted
// during the bounded migration and stored by older releases as kind 1059.
static func privateEnvelopesFor(pubkey: String, since: Date? = nil) -> NostrFilter {
// throughout the coordinated migration and stored by older releases as
// kind 1059.
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
var filter = NostrFilter()
filter.kinds = NostrProtocol.acceptedPrivateEnvelopeKinds
filter.kinds = [kind]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]]
// Before the migration deadline each logical payload is stored once
// per accepted wire kind. Scale the combined filter so compatibility
// copies do not halve the number of logical messages/acks recovered
// after a reconnect.
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
return filter
}
}
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
+10
View File
@@ -158,6 +158,16 @@ final class MessageRouter {
}
}
/// Wire one typed event sink to every route, including the queue-backed
/// Nostr transport. Keeping this at the router boundary prevents a relay
/// admission failure from disappearing merely because only the primary
/// mesh transport was assigned a UI delegate.
func setEventDelegate(_ delegate: TransportEventDelegate?) {
for transport in transports {
transport.eventDelegate = delegate
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
+317 -21
View File
@@ -12,8 +12,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let now: @MainActor () -> Date
let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
@@ -23,6 +26,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
/// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer
@MainActor
init(
notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
@@ -30,11 +34,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil,
now: @escaping @MainActor () -> Date = Date.init
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) {
self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites
@@ -42,8 +49,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.currentIdentity = currentIdentity
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.sendEvent = sendEvent
self.now = now
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
scheduleAfter: scheduleAfter
)
self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep
@@ -60,12 +71,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
ackPacer: NostrTransport.sharedAckPacer
ackPacer: NostrTransport.sharedAckPacer,
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
)
}
}
@@ -131,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
static let sharedAckPacer = AckPacer()
// Geohash acknowledgements use short-lived NostrTransport instances, so
// the retry owner must be process-wide. A per-transport cap would still be
// globally unbounded under outage as throwaway instances accumulated.
@MainActor
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
registerPendingPrivateEnvelope: {
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
@MainActor
static func resetControlRetriesForPanicWipe() {
sharedEnvelopeRetryQueue.removeAll()
}
private enum PrivateEnvelopeFailurePolicy {
case userMessage(messageID: String)
case retry(retryKey: String)
}
private let dependencies: Dependencies
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
@@ -148,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.dependencies = dependencies ?? .live(idBridge: idBridge)
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
self.dependencies = resolvedDependencies
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
setupObservers()
@@ -175,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
#if DEBUG
@MainActor
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
}
@MainActor
var debugControlRetryCount: Int {
envelopeRetryQueue.debugPendingCount
}
#endif
private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged,
@@ -264,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .userMessage(messageID: messageID)
)
}
}
@@ -290,7 +356,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
@@ -322,7 +395,13 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .userMessage(messageID: messageID)
)
}
}
}
@@ -345,21 +424,89 @@ extension NostrTransport {
/// Creates and sends a BitChat private-envelope event over Nostr.
@MainActor
private func sendPrivateEnvelope(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
private func sendPrivateEnvelope(
content: String,
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
failurePolicy: PrivateEnvelopeFailurePolicy
) {
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipientHex,
senderIdentity: senderIdentity,
now: dependencies.now()
senderIdentity: senderIdentity
) else {
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
return
}
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
}
guard accepted else {
SecureLogger.error(
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
category: .session
)
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
return
}
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
}
@MainActor
private func handlePrivateEnvelopeFailure(
events: [NostrEvent],
registerPending: Bool,
policy: PrivateEnvelopeFailurePolicy
) {
switch policy {
case .userMessage(let messageID):
deliverTransportEvent(.messageDeliveryStatusUpdated(
messageID: messageID,
status: .failed(reason: String(
localized: "content.delivery.reason.not_delivered",
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
))
))
case .retry(let retryKey):
envelopeRetryQueue.enqueue(
key: retryKey,
events: events,
registerPending: registerPending
)
}
}
@MainActor
private func registerPendingPrivateEnvelopesIfNeeded(
_ events: [NostrEvent],
registerPending: Bool
) {
guard registerPending else { return }
for event in events {
if registerPending {
dependencies.registerPendingPrivateEnvelope(event.id)
}
dependencies.sendEvent(event)
}
@MainActor
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
} else {
delegate?.receiveTransportEvent(event)
}
}
@@ -377,7 +524,14 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return
}
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID),
@@ -388,17 +542,40 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
}
@@ -418,3 +595,122 @@ extension NostrTransport {
return nil
}
}
/// Bounded retry owner for non-user private control payloads. It is separate
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
/// transport remains valid after that transport deinitializes. Scheduler
/// callbacks retain this queue, never the transport; an evicted key simply
/// becomes a harmless no-op when its already-scheduled callback fires.
@MainActor
final class NostrPrivateEnvelopeRetryQueue {
private struct PendingRetry {
let events: [NostrEvent]
let registerPending: Bool
var attempt: Int
var isScheduled: Bool
}
private let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
private let scheduleAfter: @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
private var pending: [String: PendingRetry] = [:]
private var insertionOrder: [String] = []
init(
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
scheduleAfter: @escaping @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
) {
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.scheduleAfter = scheduleAfter
}
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
guard pending[key] == nil else { return }
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
let evictedKey = insertionOrder.first {
insertionOrder.removeFirst()
pending.removeValue(forKey: evictedKey)
// These are control payloads, never user-authored messages. Keep
// the bounded-loss decision explicit rather than silently growing
// memory during a prolonged outage.
SecureLogger.warning(
"📮 Private control retry queue full — evicted oldest whole migration pair",
category: .session
)
}
pending[key] = PendingRetry(
events: events,
registerPending: registerPending,
attempt: 0,
isScheduled: false
)
insertionOrder.append(key)
schedule(key: key)
}
private func schedule(key: String) {
guard var item = pending[key], !item.isScheduled else { return }
item.isScheduled = true
pending[key] = item
let exponent = min(item.attempt, 5)
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
scheduleAfter(delay) { [self] in
Task { @MainActor [self] in
self.retry(key: key)
}
}
}
private func retry(key: String) {
guard var item = pending[key] else { return }
item.isScheduled = false
pending[key] = item
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
self.enqueue(
key: key,
events: item.events,
registerPending: item.registerPending
)
}
if accepted {
remove(key: key)
if item.registerPending {
for event in item.events {
registerPendingPrivateEnvelope(event.id)
}
}
} else {
item.attempt += 1
pending[key] = item
schedule(key: key)
}
}
private func remove(key: String) {
pending.removeValue(forKey: key)
insertionOrder.removeAll { $0 == key }
}
func removeAll() {
pending.removeAll()
insertionOrder.removeAll()
}
var debugPendingCount: Int { pending.count }
func debugContains(key: String) -> Bool { pending[key] != nil }
}
+25 -7
View File
@@ -171,13 +171,22 @@ enum TransportConfig {
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
/// Public envelope timestamps are deliberately shifted into the past for
/// privacy and relay compatibility. Mailbox queries must add the complete
/// shift to the 24-hour delivery window or boundary messages disappear
/// from `since` filters early.
/// New iOS public-envelope timestamps are deliberately shifted into the
/// past for privacy and relay compatibility.
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
static let nostrDMSubscribeLookbackSeconds: TimeInterval = (24 * 60 * 60)
+ nostrPrivateEnvelopeTimestampFuzzSeconds
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
/// full preceding 48 hours.
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
/// Private mail remains eligible for delivery for the same 24-hour window
/// as the persistent sender outbox. The relay query must add timestamp
/// randomization to that window rather than replacing it: an Android event
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
nostrPrivateEnvelopeDeliveryWindowSeconds
+ nostrLegacyAndroidTimestampFuzzSeconds
+ nostrDMSubscribeClockSkewSeconds
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
@@ -205,11 +214,20 @@ enum TransportConfig {
// Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2
static let nostrRelayDefaultFetchLimit: Int = 100
/// Migration recovery uses one independent relay filter per wire kind so
/// primary traffic cannot consume the legacy result budget (or vice
/// versa). Five hundred per kind bounds startup work while preserving a
/// materially deeper offline mailbox than the generic feed default.
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
/// Control-payload pairs (delivery/read acknowledgements and favorite
/// notifications) that could not enter the relay queue. User messages do
/// not use this queue: they fail visibly, while direct messages also
/// remain in the router outbox.
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
+1
View File
@@ -1241,6 +1241,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Drop relay subscriptions, handlers, pending sends, and replay state.
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrTransport.resetControlRetriesForPanicWipe()
NostrRelayManager.shared.resetForPanicWipe()
nostrRelayManager = nil
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService()
// Persist processed private-envelope event IDs: BitChat randomizes their
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
// events on every launch and only a cross-launch record stops the
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
// Persist processed private-envelope event IDs: legacy Android can
// randomize timestamps across the full 72h15m mailbox lookback, so DM
// subscriptions redeliver the same events on every launch and only a
// cross-launch record stops the reprocessing (re-sent DELIVERED
// bursts, phantom-ack noise).
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
self.publicMessagePipeline = PublicMessagePipeline()
}
@@ -165,7 +166,7 @@ private extension ChatViewModelBootstrapper {
func configureTransport() {
viewModel.meshService.delegate = viewModel
viewModel.meshService.eventDelegate = viewModel
viewModel.messageRouter.setEventDelegate(viewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
@@ -162,11 +162,11 @@ final class GeohashSubscriptionManager {
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.privateEnvelopesFor(
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] envelope in
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
Task { @MainActor [weak self] in
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
}
@@ -260,11 +260,11 @@ final class GeohashSubscriptionManager {
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.privateEnvelopesFor(
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] envelope in
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
Task { @MainActor [weak self] in
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
}
@@ -388,12 +388,12 @@ final class GeohashSubscriptionManager {
category: .session
)
let filter = NostrFilter.privateEnvelopesFor(
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
context.nostrRelayManager?.subscribe(filters: filters, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleAccountPrivateEnvelope(event)
}
@@ -91,8 +91,8 @@ final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
// During the bounded wire-format migration, one logical private payload
// is published under both the primary and compatibility formats. Outer
// During the coordinated wire-format migration, one logical private
// payload is published under both primary and compatibility formats. Outer
// event IDs differ, so collapse the authenticated embedded payload before
// invoking message/ack side effects. Keep this bounded like the outer-ID
// caches; the recipient and authenticated sender are part of the key.
@@ -349,8 +349,7 @@ struct ChatNostrCoordinatorContextTests {
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(-1)
senderIdentity: sender
)
for envelope in envelopes {
@@ -385,9 +384,6 @@ struct ChatNostrCoordinatorContextTests {
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
.addingTimeInterval(-1)
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "migration message",
messageID: "migration-message-id",
@@ -408,8 +404,7 @@ struct ChatNostrCoordinatorContextTests {
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
now: beforeDeadline
senderIdentity: sender
)
#expect(envelopes.count == 2)
for envelope in envelopes {
@@ -0,0 +1 @@
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
@@ -0,0 +1,29 @@
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
index a5bd956..544a29b 100644
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
@@ -10,0 +11,21 @@ class NostrProtocolTest {
+ @Test
+ fun emitCrossPlatformFixtures() {
+ val sender = NostrIdentity.fromPrivateKey(
+ "0000000000000000000000000000000000000000000000000000000000000001"
+ )
+ val recipient = NostrIdentity.fromPrivateKey(
+ "0000000000000000000000000000000000000000000000000000000000000002"
+ )
+ val giftWrap = NostrProtocol.createPrivateMessage(
+ content = "legacy fixture from Android b7f0b33d",
+ recipientPubkey = recipient.publicKeyHex,
+ senderIdentity = sender
+ ).single()
+
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
+ }
+
@Test
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
val sender = NostrIdentity.generate()
@@ -0,0 +1,10 @@
{
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
"generator": "NostrProtocol.createPrivateMessage",
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
"generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a",
"gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info",
"fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e",
"recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002",
"sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
}
+186 -31
View File
@@ -140,6 +140,124 @@ struct NostrProtocolTests {
)
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
#expect(layers.message.tags.isEmpty)
}
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Current Android's `createPrivateMessage` emits an unsigned kind-14
// inner event with exactly [["p", recipient]], while its outer/seal
// layers use the deployed BitChat legacy v2 crypto. This isolated
// generator reproduces that cross-platform wire shape without making
// the production encoder depend on Android's historical tag choice.
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
content: "legacy message from Android",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: [["p", recipient.publicKeyHex]]
)
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy message from Android")
#expect(result.senderPubkey == sender.publicKeyHex)
}
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
let eventData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
))
let metadataData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
))
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
let recipient = try NostrIdentity(privateKeyData: recipientKey)
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
let generatorPatch = try String(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
extension: "patch"
))
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
#expect(envelope.isValidSignature())
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy fixture from Android b7f0b33d")
#expect(result.senderPubkey == metadata.senderPublicKey)
}
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let otherRecipient = try NostrIdentity.generate()
let invalidTagShapes = [
[["p", otherRecipient.publicKeyHex]],
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
[["p", recipient.publicKeyHex, "unexpected"]],
[["x", recipient.publicKeyHex]]
]
for tags in invalidTagShapes {
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
content: "invalid Android-shaped tags",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: tags
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
}
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
content: "new format stays strict",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: [["p", recipient.publicKeyHex]]
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
@@ -163,17 +281,14 @@ struct NostrProtocolTests {
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
}
@Test func publicationBatchDualPublishesOnlyBeforeExplicitDeadline() throws {
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
.addingTimeInterval(-1)
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "mixed-version",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
now: beforeDeadline
senderIdentity: sender
)
#expect(migrationBatch.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
@@ -187,29 +302,38 @@ struct NostrProtocolTests {
#expect(result.content == "mixed-version")
}
let postMigrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "new-only",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline
// The compatibility copy retains the exact released-iOS legacy shape,
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
// no inner tags. There is no wall-clock branch that can silently stop
// old-client delivery.
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: migrationBatch[1],
recipientIdentity: recipient
)
#expect(postMigrationBatch.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue
])
#expect(migrationBatch[1].content.hasPrefix("v2:"))
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
#expect(compatibilityLayers.message.tags.isEmpty)
}
@Test func mailboxLookbackCoversFullRetentionWindowAndTimestampFuzz() {
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
let earliestPublicTimestamp = sentAt.addingTimeInterval(
-TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
)
let reconnectAtRetentionBoundary = sentAt.addingTimeInterval(24 * 60 * 60)
let filterSince = reconnectAtRetentionBoundary.addingTimeInterval(
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
)
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
-TransportConfig.nostrDMSubscribeLookbackSeconds
)
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == (24 * 60 * 60) + (15 * 60))
#expect(filterSince <= earliestPublicTimestamp)
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
}
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
@@ -497,23 +621,32 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
@Test func privateEnvelopeFilterIncludesPrimaryAndCompatibilityKinds() throws {
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
let since = Date(timeIntervalSince1970: 1_234_567)
let filter = NostrFilter.privateEnvelopesFor(pubkey: "recipient", since: since)
let data = try JSONEncoder().encode(filter)
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: "recipient",
since: since
)
#expect(object["kinds"] as? [Int] == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
#expect(filters.count == 2)
let objects = try filters.map { filter in
let data = try JSONEncoder().encode(filter)
return try #require(
try JSONSerialization.jsonObject(with: data) as? [String: Any]
)
}
#expect(objects.compactMap { $0["kinds"] as? [Int] } == [
[NostrProtocol.EventKind.privateEnvelope.rawValue],
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
])
for object in objects {
#expect(object["#p"] as? [String] == ["recipient"])
#expect(object["since"] as? Int == 1_234_567)
#expect(object["limit"] as? Int ==
TransportConfig.nostrRelayDefaultFetchLimit
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
)
}
}
// MARK: - Helpers
private struct LegacyRecipientKeyFixture: Decodable {
@@ -524,13 +657,35 @@ struct NostrProtocolTests {
}
}
private func fixtureURL(name: String) throws -> URL {
private struct AndroidLegacyEnvelopeFixture: Decodable {
let androidCommit: String
let generator: String
let generatorPatch: String
let generatorPatchSHA256: String
let gradleTest: String
let fixtureSHA256: String
let recipientPrivateKey: String
let senderPublicKey: String
enum CodingKeys: String, CodingKey {
case androidCommit = "android_commit"
case generator
case generatorPatch = "generator_patch"
case generatorPatchSHA256 = "generator_patch_sha256"
case gradleTest = "gradle_test"
case fixtureSHA256 = "fixture_sha256"
case recipientPrivateKey = "recipient_private_key"
case senderPublicKey = "sender_public_key"
}
}
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
#if SWIFT_PACKAGE
let bundle = Bundle.module
#else
let bundle = Bundle(for: MockKeychain.self)
#endif
return try #require(bundle.url(forResource: name, withExtension: "json"))
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
}
private static func base64URLDecode(_ s: String) -> Data? {
@@ -267,6 +267,312 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
}
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
let relayURL = "wss://private-batch-priority.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: false
)
var ephemeralIDs: [String] = []
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
let event = try makeSignedEvent(
content: "ephemeral-\(i)",
kind: .ephemeralEvent
)
ephemeralIDs.append(event.id)
context.manager.sendEvent(event, to: [relayURL])
}
let regular = try makeSignedEvent(content: "regular survives")
context.manager.sendEvent(regular, to: [relayURL])
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
let allIDs = Set(batches.flatMap { $0 })
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
XCTAssertTrue(allIDs.contains(regular.id))
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
// Later low-priority traffic may evict another ephemeral event, but
// never one half (or both halves) of the protected private batch.
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
context.manager.sendEvent(lateEphemeral, to: [relayURL])
XCTAssertTrue(
context.manager.debugPendingMessageQueueEventIDsByBatch
.contains([primary.id, legacy.id])
)
}
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
let relayURL = "wss://private-batch-tor-gate.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true
)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
context.torWaiter.isReady = false
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(connection.sentStrings.isEmpty)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
context.torWaiter.resolve(true)
let flushed = await waitUntil {
connection.sentStrings.count == 2 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(flushed)
}
func test_privateEnvelopeBatchPrunesTerminalRelayAfterHealthyDelivery() async throws {
let healthyURL = "wss://private-batch-healthy.example"
let deadURL = "wss://private-batch-dead.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [healthyURL, deadURL],
terminalFailure: { terminalFailureCount += 1 }
))
let drained = await waitUntil {
context.sessionFactory.latestConnection(for: healthyURL)?.sentStrings.count == 2 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(drained)
XCTAssertEqual(terminalFailureCount, 0)
// The terminal target is excluded during cooldown. More than one full
// protected-capacity worth of subsequent logical sends must continue
// to drain through the healthy relay instead of wedging globally.
for _ in 0...100 {
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [healthyURL, deadURL]
))
let sent = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(sent)
}
}
func test_privateEnvelopeBatchAllTerminalTargetsReportsWholeBatchFailure() async throws {
let deadURL = "wss://private-batch-all-dead.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [deadURL],
terminalFailure: { terminalFailureCount += 1 }
))
let failed = await waitUntil {
terminalFailureCount == 1 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(failed)
}
func test_privateEnvelopeBatchRejectsBeforeWritingWhenProtectedCapacityIsFull() async throws {
let stalledRelayURL = "wss://private-batch-full.example"
let connectedRelayURL = "wss://private-batch-connected.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: true
)
context.manager.ensureConnections(to: [connectedRelayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == connectedRelayURL })?.isConnected == true
}
XCTAssertTrue(connected)
context.torForeground.value = false
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
let pair = [primary, legacy]
for _ in 0..<(TransportConfig.nostrPendingSendQueueCap / pair.count) {
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(pair, to: [stalledRelayURL]))
}
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
let rejectedPrimary = try makeSignedEvent(content: "rejected-primary", kind: .privateEnvelope)
let rejectedLegacy = try makeSignedEvent(content: "rejected-legacy", kind: .legacyNIP59GiftWrap)
XCTAssertFalse(
context.manager.sendPrivateEnvelopeBatch(
[rejectedPrimary, rejectedLegacy],
to: [connectedRelayURL]
)
)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(
context.sessionFactory.latestConnection(for: connectedRelayURL)?.sentStrings.isEmpty == true
)
let queuedIDs = Set(
context.manager.debugPendingMessageQueueEventIDsByBatch.flatMap { $0 }
)
XCTAssertFalse(queuedIDs.contains(rejectedPrimary.id))
XCTAssertFalse(queuedIDs.contains(rejectedLegacy.id))
}
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterFirstWriteFails() async throws {
try await assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [NSError(domain: "send", code: 1)],
expectedFirstConnectionWrites: 1
)
}
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterSecondWriteFails() async throws {
try await assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [nil, NSError(domain: "send", code: 2)],
expectedFirstConnectionWrites: 2
)
}
func test_privateEnvelopeBatchRemainsPendingUntilBothWritesSucceed() async throws {
let relayURL = "wss://private-batch-two-write-commit.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
connection.deferSendCompletions = true
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
XCTAssertTrue(firstWriteStarted)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
connection.flushDeferredSendCompletions()
let secondWriteStarted = await waitUntil { connection.sentStrings.count == 2 }
XCTAssertTrue(secondWriteStarted)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
connection.flushDeferredSendCompletions()
let committed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(committed)
}
func test_privateEnvelopeBatchDisconnectDuringWriteReplaysOnReplacementConnection() async throws {
let relayURL = "wss://private-batch-background-reconnect.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let firstConnection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
firstConnection.deferSendCompletions = true
let firstWriteStarted = await waitUntil { firstConnection.sentStrings.count == 1 }
XCTAssertTrue(firstWriteStarted)
// Backgrounding cancels the socket while its first write callback is
// still outstanding. The pair must remain replayable in the queue.
context.manager.disconnect()
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
context.manager.connect()
let replayed = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
guard connections.count == 2, let replacement = connections.last else {
return false
}
return replacement.sentStrings.count == 2 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(replayed)
// A late success callback from the canceled socket must neither send
// the legacy half on that stale socket nor disturb the completed pair.
firstConnection.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(firstConnection.sentStrings.count, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
}
private func assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [Error?],
expectedFirstConnectionWrites: Int
) async throws {
let relayURL = "wss://private-batch-write-failure.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
firstConnection.sendErrorSequence = failureSequence
let stayedQueued = await waitUntil {
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
}
XCTAssertTrue(stayedQueued)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
// The failed socket is disconnected with bounded backoff. Its queue
// item is reusednot re-admittedand the replacement retries both
// copies before the pair is finally removed.
XCTAssertFalse(context.scheduler.scheduled.isEmpty)
context.scheduler.runNext()
let retried = await waitUntil {
guard let replacement = context.sessionFactory.latestConnection(for: relayURL),
replacement !== firstConnection else { return false }
return replacement.sentStrings.count == 2 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(retried)
}
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
let relayURL = "wss://tor-ready.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -548,6 +854,52 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
}
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
let relayURL = "wss://private-recovery-filters.example"
let context = makeContext(permission: .denied)
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: "recipient",
since: Date(timeIntervalSince1970: 1_234_567)
)
context.manager.subscribe(
filters: filters,
id: "private-recovery",
relayUrls: [relayURL],
handler: { _ in }
)
let sent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(sent)
let request = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
)
let data = try XCTUnwrap(request.data(using: .utf8))
let array = try XCTUnwrap(
try JSONSerialization.jsonObject(with: data) as? [Any]
)
XCTAssertEqual(array.count, 4)
XCTAssertEqual(array[0] as? String, "REQ")
XCTAssertEqual(array[1] as? String, "private-recovery")
let encodedFilters = try [array[2], array[3]].map {
try XCTUnwrap($0 as? [String: Any])
}
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
[NostrProtocol.EventKind.privateEnvelope.rawValue],
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
])
for filter in encodedFilters {
XCTAssertEqual(
filter["limit"] as? Int,
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
)
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
}
}
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
let relayURL = "wss://tor-subscribe-coalesce.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -1646,12 +1998,15 @@ final class NostrRelayManagerTests: XCTestCase {
return filter
}
private func makeSignedEvent(content: String) throws -> NostrEvent {
private func makeSignedEvent(
content: String,
kind: NostrProtocol.EventKind = .textNote
) throws -> NostrEvent {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
kind: kind,
tags: [],
content: content
)
@@ -1791,6 +2146,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private final class MockRelayConnection: NostrRelayConnectionProtocol {
private let pingError: Error?
var sendError: Error?
var sendErrorSequence: [Error?] = []
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private(set) var resumeCallCount = 0
private(set) var cancelCallCount = 0
@@ -1820,14 +2176,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
var deferSendCompletions = false
private var deferredSendCompletions: [(Error?) -> Void] = []
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
sentMessages.append(message)
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
if deferSendCompletions {
deferredSendCompletions.append(completionHandler)
deferredSendCompletions.append((error, completionHandler))
} else {
completionHandler(sendError)
completionHandler(error)
}
}
@@ -1835,7 +2192,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
let pending = deferredSendCompletions
deferredSendCompletions = []
pending.forEach {
$0(sendError)
$0.completion($0.error)
}
}
+309 -47
View File
@@ -131,7 +131,7 @@ struct NostrTransportTests {
#expect(undeliverable)
}
@Test("Private message resolves short peer ID and emits decryptable packet")
@Test("Private message resolves short peer ID and emits both migration formats")
@MainActor
func sendPrivateMessageResolvesShortPeerID() async throws {
let keychain = MockKeychain()
@@ -154,7 +154,7 @@ struct NostrTransportTests {
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -164,8 +164,12 @@ struct NostrTransportTests {
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
#expect(probe.sentEvents.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -176,9 +180,9 @@ struct NostrTransportTests {
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
}
@Test("Migration window publishes primary and compatibility envelopes, then stops")
@Test("Coordinated migration always publishes primary and compatibility envelopes")
@MainActor
func migrationWindowDualPublishesUntilDeadline() async throws {
func migrationAlwaysDualPublishes() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
@@ -198,11 +202,7 @@ struct NostrTransportTests {
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendEvent: migrationProbe.record(event:),
now: {
NostrProtocol.legacyPrivateEnvelopePublicationDeadline
.addingTimeInterval(-1)
}
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
)
)
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
@@ -228,32 +228,281 @@ struct NostrTransportTests {
#expect(message.messageID == "migration-pm")
#expect(message.content == "migration payload")
}
}
let postMigrationProbe = NostrTransportProbe()
let postMigrationTransport = NostrTransport(
@Test("Rejected migration batch does not register half-delivery state")
@MainActor
func rejectedMigrationBatchRegistersNothing() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let probe = NostrTransportProbe()
var rejectedKinds: [Int] = []
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in
rejectedKinds = events.map(\.kind)
return false
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendPrivateMessageGeohash(
content: "must stay atomic",
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "atomic-reject"
)
let attempted = await TestHelpers.waitUntil({ rejectedKinds.count == 2 }, timeout: 5.0)
#expect(attempted)
#expect(rejectedKinds == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
}
@Test("Rejected user message emits a visible failed-delivery event")
@MainActor
func rejectedUserMessageEmitsFailureEvent() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { _, _ in false }
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessageGeohash(
content: "must fail visibly",
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "visible-reject"
)
let reported = await TestHelpers.waitUntil(
{ eventProbe.failedMessageIDs == ["visible-reject"] },
timeout: 5.0
)
#expect(reported)
}
@Test("Rejected favorite notification retains and retries the exact pair")
@MainActor
func rejectedFavoriteNotificationRetriesExactPair() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((96..<128).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Retry favorite"
)
let probe = NostrTransportProbe()
var attempts: [[String]] = []
weak var releasedTransport: NostrTransport?
do {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendEvent: postMigrationProbe.record(event:),
now: { NostrProtocol.legacyPrivateEnvelopePublicationDeadline }
sendPrivateEnvelopeBatch: { events, _ in
attempts.append(events.map(\.id))
return attempts.count > 1
},
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
postMigrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
postMigrationTransport.sendPrivateMessage(
"post migration",
to: peerID,
recipientNickname: "Migration peer",
messageID: "post-migration-pm"
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
releasedTransport = transport
let sentPrimaryOnly = await TestHelpers.waitUntil(
{ postMigrationProbe.sentEvents.count == 1 },
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
let retryScheduled = await TestHelpers.waitUntil(
{ attempts.count == 1 && probe.scheduledActionCount == 1 },
timeout: 5.0
)
#expect(sentPrimaryOnly)
#expect(postMigrationProbe.sentEvents.first?.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
#expect(retryScheduled)
}
#expect(releasedTransport == nil)
#expect(probe.runNextScheduledAction())
let retried = await TestHelpers.waitUntil({ attempts.count == 2 }, timeout: 5.0)
#expect(retried)
#expect(attempts[0] == attempts[1])
}
@Test("Rejected delivery acknowledgement remains queued for retry")
@MainActor
func rejectedDeliveryAckRetries() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((128..<160).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Retry ack"
)
let probe = NostrTransportProbe()
var attempts: [[String]] = []
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
attempts.append(events.map(\.id))
return attempts.count > 1
},
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
let firstAttempt = await TestHelpers.waitUntil(
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
timeout: 5.0
)
#expect(firstAttempt)
for _ in 0..<3 where attempts.count < 2 {
_ = probe.runNextScheduledAction()
_ = await TestHelpers.waitUntil(
{ attempts.count == 2 || probe.scheduledActionCount > 0 },
timeout: 1.0
)
}
#expect(attempts.count == 2)
#expect(attempts[0] == attempts[1])
}
@Test("Control retry queue is bounded and evicted callbacks are harmless")
@MainActor
func controlRetryQueueIsBounded() async throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "bounded retry fixture",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let probe = NostrTransportProbe()
var retryAttempts = 0
let queue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { _, _ in
retryAttempts += 1
return false
},
registerPendingPrivateEnvelope: { _ in },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
queue.enqueue(
key: "control-\(index)",
events: events,
registerPending: false
)
}
#expect(queue.debugPendingCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
#expect(!queue.debugContains(key: "control-0"))
#expect(queue.debugContains(key: "control-1"))
// The oldest callback was scheduled before eviction. Running it must
// observe the missing key and return without touching dependencies.
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
queue.removeAll()
#expect(queue.debugPendingCount == 0)
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
}
@Test("Multiple transports share one globally bounded control retry owner")
@MainActor
func multipleTransportsShareControlRetryQueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "shared retry fixture",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let probe = NostrTransportProbe()
var retryAttempts = 0
let sharedQueue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { _, _ in
retryAttempts += 1
return false
},
registerPendingPrivateEnvelope: { _ in },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
let first = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
)
let second = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
)
first.debugEnqueueControlRetry(key: "shared", events: events)
second.debugEnqueueControlRetry(key: "shared", events: events)
#expect(first.debugControlRetryCount == 1)
#expect(second.debugControlRetryCount == 1)
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
let transport = index.isMultiple(of: 2) ? first : second
transport.debugEnqueueControlRetry(key: "global-\(index)", events: events)
}
#expect(first.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
#expect(second.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
// The first scheduled callback belongs to the now-evicted shared key.
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
}
@Test("Favorite notification embeds current npub")
@@ -279,7 +528,7 @@ struct NostrTransportTests {
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -289,7 +538,7 @@ struct NostrTransportTests {
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -320,7 +569,7 @@ struct NostrTransportTests {
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -330,7 +579,7 @@ struct NostrTransportTests {
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
@@ -353,7 +602,7 @@ struct NostrTransportTests {
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -368,7 +617,7 @@ struct NostrTransportTests {
messageID: "geo-1"
)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let event = probe.sentEvents[0]
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
@@ -377,7 +626,7 @@ struct NostrTransportTests {
#expect(privateMessage.messageID == "geo-1")
#expect(privateMessage.content == "geo hello")
#expect(result.packet.recipientID == nil)
#expect(probe.pendingPrivateEnvelopeIDs == [event.id])
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
}
@Test("Read receipt queue sends in order and waits for scheduler")
@@ -403,7 +652,7 @@ struct NostrTransportTests {
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -418,20 +667,20 @@ struct NostrTransportTests {
transport.sendReadReceipt(second, to: fullPeerID)
let readReceiptTimeout: TimeInterval = 5.0
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt event")
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt pair")
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
#expect(firstPayload.type == .readReceipt)
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
try #require(sentSecond, "Expected second read receipt after running throttle action")
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
let secondEvent = probe.sentEvents[2]
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
#expect(secondPayload.type == .readReceipt)
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
@@ -500,12 +749,13 @@ struct NostrTransportTests {
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool = { _, _ in true },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
now: @escaping @MainActor () -> Date = {
NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(1)
}
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
@@ -514,10 +764,10 @@ struct NostrTransportTests {
favoriteStatusForPeerID: favoriteStatusForPeerID,
currentIdentity: currentIdentity,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
sendEvent: sendEvent,
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity,
now: now
envelopeRetryQueue: envelopeRetryQueue
)
}
@@ -572,6 +822,17 @@ private enum NostrTransportTestError: Error {
case invalidPrivateMessage
}
@MainActor
private final class NostrTransportEventProbe: TransportEventDelegate {
private(set) var failedMessageIDs: [String] = []
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
case .failed = status else { return }
failedMessageIDs.append(messageID)
}
}
private func base64URLDecode(_ string: String) -> Data? {
var candidate = string
let padding = (4 - (candidate.count % 4)) % 4
@@ -608,10 +869,11 @@ private final class NostrTransportProbe: @unchecked Sendable {
return scheduledActionsStorage.count
}
func record(event: NostrEvent) {
func record(batch: [NostrEvent]) -> Bool {
lock.lock()
sentEventsStorage.append(event)
sentEventsStorage.append(contentsOf: batch)
lock.unlock()
return true
}
func recordPendingPrivateEnvelope(id: String) {