mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Harden Nostr envelope migration compatibility
This commit is contained in:
@@ -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,
|
interoperates with BitChat clients. New envelopes use provisional,
|
||||||
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
||||||
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
||||||
For mixed-version delivery, clients also publish a compatibility-only legacy
|
For mixed-version delivery, clients publish both the primary kind-1402
|
||||||
kind-1059 copy through October 15, 2026 at 00:00 UTC; kind 1402 remains the
|
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
|
||||||
primary format and is the only format published after that deadline.
|
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
|
### Channel Types
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -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.
|
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
|
## 6. Store and Forward
|
||||||
|
|
||||||
@@ -107,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
|||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 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
|
### 6.5 Delivery Metrics
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ struct NostrProtocol {
|
|||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
// Bounded compatibility for BitChat releases that incorrectly emitted
|
// Compatibility for BitChat releases that incorrectly emitted the
|
||||||
// the proprietary payload under standard NIP kinds. Only kind 1059 is
|
// proprietary payload under standard NIP kinds. Kind 1059 continues
|
||||||
// temporarily published during migration; all three remain readable.
|
// to be published and read until a coordinated cross-platform release
|
||||||
|
// explicitly removes it; all three legacy layers remain readable.
|
||||||
case legacyNIP59Seal = 13
|
case legacyNIP59Seal = 13
|
||||||
case legacyNIP17DirectMessage = 14
|
case legacyNIP17DirectMessage = 14
|
||||||
case legacyNIP59GiftWrap = 1059
|
case legacyNIP59GiftWrap = 1059
|
||||||
@@ -57,16 +58,10 @@ struct NostrProtocol {
|
|||||||
/// the layer-specific cap below the public ciphertext ceiling.
|
/// the layer-specific cap below the public ciphertext ceiling.
|
||||||
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
|
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
|
/// New clients subscribe to the provisional BitChat-specific kind and the
|
||||||
/// compatibility-only legacy kind so both sides of a rolling rollout can
|
/// compatibility legacy kind so both sides of a rolling rollout can
|
||||||
/// recover stored messages.
|
/// recover stored messages. Do not remove kind 1059 here until all
|
||||||
|
/// supported iOS and Android releases have migrated.
|
||||||
static let acceptedPrivateEnvelopeKinds = [
|
static let acceptedPrivateEnvelopeKinds = [
|
||||||
EventKind.privateEnvelope.rawValue,
|
EventKind.privateEnvelope.rawValue,
|
||||||
EventKind.legacyNIP59GiftWrap.rawValue
|
EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
@@ -145,24 +140,22 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Events to publish for one logical private payload. The primary
|
/// Events to publish for one logical private payload. The primary
|
||||||
/// BitChat-specific format is always first. Until the explicit migration
|
/// BitChat-specific format is always first and a legacy copy follows for
|
||||||
/// deadline, a legacy copy follows for clients that still subscribe only
|
/// clients that still subscribe only to kind 1059. There is deliberately
|
||||||
/// to kind 1059. Both encrypt the exact same embedded BitChat payload, so
|
/// no date-based cutoff: removal requires a coordinated iOS/Android
|
||||||
/// receive-side logical-payload dedup collapses the pair.
|
/// 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(
|
static func createPrivateEnvelopePublicationBatch(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity
|
||||||
now: Date = Date()
|
|
||||||
) throws -> [NostrEvent] {
|
) throws -> [NostrEvent] {
|
||||||
let primary = try createPrivateEnvelope(
|
let primary = try createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderIdentity: senderIdentity
|
senderIdentity: senderIdentity
|
||||||
)
|
)
|
||||||
guard now < legacyPrivateEnvelopePublicationDeadline else {
|
|
||||||
return [primary]
|
|
||||||
}
|
|
||||||
let compatibilityCopy = try createPrivateEnvelope(
|
let compatibilityCopy = try createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
@@ -176,14 +169,15 @@ struct NostrProtocol {
|
|||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
format: PrivateEnvelopeWireFormat
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
messageTags: [[String]] = []
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
// 1. Create the unsigned inner BitChat message.
|
// 1. Create the unsigned inner BitChat message.
|
||||||
let message = NostrEvent(
|
let message = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: format.messageKind,
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: messageTags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,16 +273,37 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createLegacyPrivateEnvelopeForTesting(
|
static func createPrivateEnvelopeWithInnerTagsForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity,
|
||||||
|
innerMessageTags: [[String]]
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
try createPrivateEnvelope(
|
try createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderIdentity: senderIdentity,
|
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
|
// comes from the seal. Bind its claimed sender and custom kind to that
|
||||||
// authenticated layer before exposing content.
|
// authenticated layer before exposing content.
|
||||||
guard message.kind == format.messageKind.rawValue,
|
guard message.kind == format.messageKind.rawValue,
|
||||||
message.tags.isEmpty,
|
validInnerMessageTags(
|
||||||
|
message.tags,
|
||||||
|
format: format,
|
||||||
|
recipientPubkey: recipientIdentity.publicKeyHex
|
||||||
|
),
|
||||||
message.sig == nil,
|
message.sig == nil,
|
||||||
seal.pubkey == message.pubkey else {
|
seal.pubkey == message.pubkey else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
@@ -646,6 +665,23 @@ struct NostrProtocol {
|
|||||||
return (seal, message)
|
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(
|
private static func decodePrivateEnvelopeEventJSON(
|
||||||
_ json: String,
|
_ json: String,
|
||||||
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
private var pendingTorConnectionURLs = Set<String>()
|
private var pendingTorConnectionURLs = Set<String>()
|
||||||
private var awaitingTorForConnections = false
|
private var awaitingTorForConnections = false
|
||||||
|
private var awaitingTorForQueueFlush = false
|
||||||
private var torReadyWaitAttempts = 0
|
private var torReadyWaitAttempts = 0
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -209,11 +210,32 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var eoseTrackerEpoch = 0
|
private var eoseTrackerEpoch = 0
|
||||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability. Pending sends are grouped so a private
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// 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 {
|
private struct PendingSend {
|
||||||
var event: NostrEvent
|
let id: UUID
|
||||||
|
let events: [NostrEvent]
|
||||||
var pendingRelays: Set<String>
|
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 var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
@@ -305,7 +327,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
connectionGeneration &+= 1
|
connectionGeneration &+= 1
|
||||||
for (_, task) in connections {
|
for (relayURL, task) in connections {
|
||||||
|
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
|
||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
@@ -327,6 +350,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
confirmed.forEach { $0(false) }
|
confirmed.forEach { $0(false) }
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
|
awaitingTorForQueueFlush = false
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
@@ -351,6 +375,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingEOSECallbacks.removeAll()
|
pendingEOSECallbacks.removeAll()
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
|
awaitingTorForQueueFlush = false
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
recentInboundEventKeys.removeAll()
|
recentInboundEventKeys.removeAll()
|
||||||
recentInboundEventKeyOrder.removeAll()
|
recentInboundEventKeyOrder.removeAll()
|
||||||
@@ -399,38 +424,127 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connectToRelays(targets)
|
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) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
// Global network policy gate
|
_ = sendEvents(
|
||||||
guard dependencies.activationAllowed() else { return }
|
[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() {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||||
// event locally so it survives a slow bootstrap (queued sends flush
|
// complete item locally so it survives a slow bootstrap, then kick
|
||||||
// when relays connect), then kick off connection setup, which itself
|
// off connection setup, which itself waits for Tor readiness.
|
||||||
// waits for Tor readiness.
|
guard enqueuePendingSendBatch(
|
||||||
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
events,
|
||||||
guard !targetRelays.isEmpty else { return }
|
pendingRelays: Set(targetRelays),
|
||||||
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
priority: priority,
|
||||||
|
terminalFailure: nil
|
||||||
|
) != nil else { return false }
|
||||||
ensureConnections(to: targetRelays)
|
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>()
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connectedConnection(for: relayUrl) {
|
if let connection = connectedConnection(for: relayUrl) {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
connectedTargets.append((relayUrl, connection))
|
||||||
} else {
|
} else {
|
||||||
stillPending.insert(relayUrl)
|
stillPending.insert(relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !stillPending.isEmpty {
|
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
|
/// Attempts an event only on currently connected target relays and
|
||||||
@@ -524,66 +638,244 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
state.completion(false)
|
state.completion(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
|
||||||
messageQueueLock.lock()
|
switch event.kind {
|
||||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
case NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
|
NostrProtocol.EventKind.geohashPresence.rawValue:
|
||||||
if overflow > 0 {
|
return .ephemeral
|
||||||
messageQueue.removeFirst(overflow)
|
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()
|
||||||
|
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()
|
messageQueueLock.unlock()
|
||||||
guard overflow > 0 else { return }
|
guard evictedEventCount > 0 else { return itemID }
|
||||||
// Dropped events are ephemeral (presence/geo), so no status surfacing
|
let isFirstEviction = pendingSendDropCount == 0
|
||||||
// is needed — but the drops should be visible. Sampled so a sustained
|
pendingSendDropCount += evictedEventCount
|
||||||
// relay stall can't flood the log.
|
if isFirstEviction ||
|
||||||
pendingSendDropCount += overflow
|
|
||||||
if pendingSendDropCount == 1 ||
|
|
||||||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
|
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return itemID
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to flush any queued messages for relays that are now connected.
|
/// Try to flush any queued messages for relays that are now connected.
|
||||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||||
|
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
||||||
|
flushMessageQueueWhenTorIsReady()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var deliveries: [PendingDelivery] = []
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
defer { messageQueueLock.unlock() }
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
guard !messageQueue.isEmpty else { return }
|
var item = messageQueue[index]
|
||||||
if let target = relayUrl {
|
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
|
||||||
// Flush only for a specific relay
|
for target in targets {
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
guard item.pendingRelays.contains(target),
|
||||||
var item = messageQueue[i]
|
item.inFlightConnections[target] == nil,
|
||||||
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
let connection = connectedConnection(for: target) else { continue }
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
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)
|
item.pendingRelays.remove(target)
|
||||||
if item.pendingRelays.isEmpty {
|
|
||||||
messageQueue.remove(at: i)
|
|
||||||
} else {
|
|
||||||
messageQueue[i] = item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if item.pendingRelays.isEmpty {
|
||||||
// Flush for any relays that now have connections
|
messageQueue.remove(at: index)
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
} else {
|
||||||
var item = messageQueue[i]
|
messageQueue[index] = item
|
||||||
for url in item.pendingRelays {
|
}
|
||||||
if let conn = connectedConnection(for: url) {
|
}
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
messageQueueLock.unlock()
|
||||||
item.pendingRelays.remove(url)
|
|
||||||
}
|
// 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if item.pendingRelays.isEmpty {
|
} else {
|
||||||
messageQueue.remove(at: i)
|
for event in delivery.events {
|
||||||
} else {
|
sendToRelay(
|
||||||
messageQueue[i] = item
|
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? {
|
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||||
guard let connection = connections[relayUrl],
|
guard let connection = connections[relayUrl],
|
||||||
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||||
@@ -599,9 +891,28 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relayUrls: [String]? = nil,
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void,
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
onEOSE: (() -> Void)? = nil
|
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
|
// 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.
|
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||||
let now = dependencies.now()
|
let now = dependencies.now()
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||||
@@ -610,7 +921,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: filters)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let message = try encoder.encode(req)
|
let message = try encoder.encode(req)
|
||||||
@@ -677,6 +988,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
ensureConnections(to: Self.defaultRelays)
|
ensureConnections(to: Self.defaultRelays)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
var terminalFailures: [() -> Void] = []
|
||||||
for url in Self.defaultRelays {
|
for url in Self.defaultRelays {
|
||||||
if let connection = connections[url] {
|
if let connection = connections[url] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
@@ -684,18 +996,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connections.removeValue(forKey: url)
|
connections.removeValue(forKey: url)
|
||||||
subscriptions.removeValue(forKey: url)
|
subscriptions.removeValue(forKey: url)
|
||||||
pendingSubscriptions.removeValue(forKey: url)
|
pendingSubscriptions.removeValue(forKey: url)
|
||||||
|
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
|
||||||
}
|
}
|
||||||
messageQueueLock.lock()
|
terminalFailures.forEach { $0() }
|
||||||
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()
|
|
||||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1588,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connection: NostrRelayConnectionProtocol? = nil
|
connection: NostrRelayConnectionProtocol? = nil
|
||||||
) {
|
) {
|
||||||
if let connection, connections[relayUrl] !== connection { return }
|
if let connection, connections[relayUrl] !== connection { return }
|
||||||
|
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||||
@@ -1305,7 +1609,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
if errorDescription.contains("hostname could not be found") ||
|
if errorDescription.contains("hostname could not be found") ||
|
||||||
errorDescription.contains("dns") ||
|
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 {
|
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||||
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
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
|
relays[index].nextReconnectTime = nil
|
||||||
}
|
}
|
||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
|
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||||
|
terminalFailures.forEach { $0() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,6 +1635,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Stop attempting after max attempts
|
// Stop attempting after max attempts
|
||||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||||
|
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||||
|
terminalFailures.forEach { $0() }
|
||||||
return
|
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
|
// MARK: - Public Utility Methods
|
||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
/// Manually retry connection to a specific relay
|
||||||
@@ -1378,6 +1737,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[normalizedRelayUrl] {
|
if let connection = connections[normalizedRelayUrl] {
|
||||||
|
clearPendingSendInFlight(
|
||||||
|
relayUrl: normalizedRelayUrl,
|
||||||
|
connection: connection
|
||||||
|
)
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: normalizedRelayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
@@ -1399,7 +1762,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var debugPendingMessageQueueCount: Int {
|
var debugPendingMessageQueueCount: Int {
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
defer { messageQueueLock.unlock() }
|
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 {
|
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||||
@@ -1604,19 +1973,17 @@ struct NostrFilter: Encodable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
||||||
// during the bounded migration and stored by older releases as kind 1059.
|
// throughout the coordinated migration and stored by older releases as
|
||||||
static func privateEnvelopesFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
// kind 1059.
|
||||||
var filter = NostrFilter()
|
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
|
||||||
filter.kinds = NostrProtocol.acceptedPrivateEnvelopeKinds
|
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
var filter = NostrFilter()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.kinds = [kind]
|
||||||
// Before the migration deadline each logical payload is stored once
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
// per accepted wire kind. Scale the combined filter so compatibility
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
// copies do not halve the number of logical messages/acks recovered
|
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||||
// after a reconnect.
|
return filter
|
||||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit
|
}
|
||||||
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
|
|
||||||
return filter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||||
|
|||||||
@@ -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
|
// MARK: - Transport Selection
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||||
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendPrivateEnvelopeBatch: @MainActor (
|
||||||
let now: @MainActor () -> Date
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool
|
||||||
|
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||||
/// Emits whether a relay that carries private messages is up
|
/// Emits whether a relay that carries private messages is up
|
||||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||||
/// doesn't count: DM sends target the default relay set and would
|
/// 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.
|
/// serialize behind each other; `live` passes the process-wide one.
|
||||||
let ackPacer: AckPacer
|
let ackPacer: AckPacer
|
||||||
|
|
||||||
|
@MainActor
|
||||||
init(
|
init(
|
||||||
notificationCenter: NotificationCenter,
|
notificationCenter: NotificationCenter,
|
||||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||||
@@ -30,11 +34,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
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,
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||||
ackPacer: AckPacer? = nil,
|
ackPacer: AckPacer? = nil,
|
||||||
now: @escaping @MainActor () -> Date = Date.init
|
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||||
) {
|
) {
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
self.loadFavorites = loadFavorites
|
self.loadFavorites = loadFavorites
|
||||||
@@ -42,8 +49,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||||
self.currentIdentity = currentIdentity
|
self.currentIdentity = currentIdentity
|
||||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||||
self.sendEvent = sendEvent
|
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||||
self.now = now
|
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
||||||
|
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||||
|
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||||
|
scheduleAfter: scheduleAfter
|
||||||
|
)
|
||||||
self.relayConnectivity = relayConnectivity
|
self.relayConnectivity = relayConnectivity
|
||||||
// Default pacer drives its throttle through the same injected
|
// Default pacer drives its throttle through the same injected
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
// scheduler, so tests that step scheduleAfter manually keep
|
||||||
@@ -60,12 +71,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||||
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||||
|
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||||
|
events,
|
||||||
|
terminalFailure: terminalFailure
|
||||||
|
)
|
||||||
|
},
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
},
|
},
|
||||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
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()
|
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 dependencies: Dependencies
|
||||||
|
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||||
private var favoriteStatusObserver: NSObjectProtocol?
|
private var favoriteStatusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
// Reachability Cache (thread-safe)
|
||||||
@@ -148,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
dependencies: Dependencies? = nil
|
dependencies: Dependencies? = nil
|
||||||
) {
|
) {
|
||||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
||||||
|
self.dependencies = resolvedDependencies
|
||||||
|
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
||||||
|
|
||||||
setupObservers()
|
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() {
|
private func setupObservers() {
|
||||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
@@ -264,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
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)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
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)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
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.
|
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||||
@MainActor
|
@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(
|
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipientHex,
|
recipientPubkey: recipientHex,
|
||||||
senderIdentity: senderIdentity,
|
senderIdentity: senderIdentity
|
||||||
now: dependencies.now()
|
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
||||||
return
|
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 {
|
for event in events {
|
||||||
if registerPending {
|
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||||
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)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
return
|
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):
|
case .deliveredDirect(let messageID, let peerID):
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
@@ -388,17 +542,40 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
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):
|
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
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):
|
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
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
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,13 +171,22 @@ enum TransportConfig {
|
|||||||
static let nostrGeoRelayCount: Int = 5
|
static let nostrGeoRelayCount: Int = 5
|
||||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||||
static let nostrGeohashSampleLimit: Int = 100
|
static let nostrGeohashSampleLimit: Int = 100
|
||||||
/// Public envelope timestamps are deliberately shifted into the past for
|
/// New iOS public-envelope timestamps are deliberately shifted into the
|
||||||
/// privacy and relay compatibility. Mailbox queries must add the complete
|
/// past for privacy and relay compatibility.
|
||||||
/// shift to the 24-hour delivery window or boundary messages disappear
|
|
||||||
/// from `since` filters early.
|
|
||||||
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
||||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = (24 * 60 * 60)
|
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
|
||||||
+ nostrPrivateEnvelopeTimestampFuzzSeconds
|
/// 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
|
// A sampled chat message this recent means "a conversation is happening
|
||||||
// there" for the empty-timeline nearby-activity hint.
|
// there" for the empty-timeline nearby-activity hint.
|
||||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||||
@@ -205,11 +214,20 @@ enum TransportConfig {
|
|||||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||||
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
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
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||||
static let nostrPendingSendQueueCap: Int = 200
|
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
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
static let nostrPendingSendDropLogInterval: Int = 10
|
static let nostrPendingSendDropLogInterval: Int = 10
|
||||||
|
|||||||
@@ -1241,6 +1241,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||||
// disconnect is not enough here.
|
// disconnect is not enough here.
|
||||||
|
NostrTransport.resetControlRetriesForPanicWipe()
|
||||||
NostrRelayManager.shared.resetForPanicWipe()
|
NostrRelayManager.shared.resetForPanicWipe()
|
||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
|
|||||||
self.privateChatManager = privateChatManager
|
self.privateChatManager = privateChatManager
|
||||||
self.unifiedPeerService = unifiedPeerService
|
self.unifiedPeerService = unifiedPeerService
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
// Persist processed private-envelope event IDs: BitChat randomizes their
|
// Persist processed private-envelope event IDs: legacy Android can
|
||||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
// randomize timestamps across the full 72h15m mailbox lookback, so DM
|
||||||
// events on every launch and only a cross-launch record stops the
|
// subscriptions redeliver the same events on every launch and only a
|
||||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
// cross-launch record stops the reprocessing (re-sent DELIVERED
|
||||||
|
// bursts, phantom-ack noise).
|
||||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
||||||
self.publicMessagePipeline = PublicMessagePipeline()
|
self.publicMessagePipeline = PublicMessagePipeline()
|
||||||
}
|
}
|
||||||
@@ -165,7 +166,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
|
|
||||||
func configureTransport() {
|
func configureTransport() {
|
||||||
viewModel.meshService.delegate = viewModel
|
viewModel.meshService.delegate = viewModel
|
||||||
viewModel.meshService.eventDelegate = viewModel
|
viewModel.messageRouter.setEventDelegate(viewModel)
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||||
guard let viewModel else { return }
|
guard let viewModel else { return }
|
||||||
|
|||||||
@@ -162,11 +162,11 @@ final class GeohashSubscriptionManager {
|
|||||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.setGeoDmSubscriptionID(dmSub)
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
let dmFilter = NostrFilter.privateEnvelopesFor(
|
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
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
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
@@ -260,11 +260,11 @@ final class GeohashSubscriptionManager {
|
|||||||
if TorManager.shared.isReady {
|
if TorManager.shared.isReady {
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
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,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
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
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
@@ -388,12 +388,12 @@ final class GeohashSubscriptionManager {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
let filter = NostrFilter.privateEnvelopesFor(
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
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
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleAccountPrivateEnvelope(event)
|
self?.inbound.handleAccountPrivateEnvelope(event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ final class NostrInboundPipeline {
|
|||||||
private weak var context: (any NostrInboundPipelineContext)?
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
private var geoEventLogCount = 0
|
private var geoEventLogCount = 0
|
||||||
// During the bounded wire-format migration, one logical private payload
|
// During the coordinated wire-format migration, one logical private
|
||||||
// is published under both the primary and compatibility formats. Outer
|
// payload is published under both primary and compatibility formats. Outer
|
||||||
// event IDs differ, so collapse the authenticated embedded payload before
|
// event IDs differ, so collapse the authenticated embedded payload before
|
||||||
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
||||||
// caches; the recipient and authenticated sender are part of the key.
|
// caches; the recipient and authenticated sender are part of the key.
|
||||||
|
|||||||
@@ -349,8 +349,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender,
|
senderIdentity: sender
|
||||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(-1)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for envelope in envelopes {
|
for envelope in envelopes {
|
||||||
@@ -385,9 +384,6 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
|
||||||
.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
content: "migration message",
|
content: "migration message",
|
||||||
messageID: "migration-message-id",
|
messageID: "migration-message-id",
|
||||||
@@ -408,8 +404,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender,
|
senderIdentity: sender
|
||||||
now: beforeDeadline
|
|
||||||
)
|
)
|
||||||
#expect(envelopes.count == 2)
|
#expect(envelopes.count == 2)
|
||||||
for envelope in envelopes {
|
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"
|
||||||
|
}
|
||||||
@@ -140,6 +140,124 @@ struct NostrProtocolTests {
|
|||||||
)
|
)
|
||||||
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||||
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.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 {
|
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
||||||
@@ -163,17 +281,14 @@ struct NostrProtocolTests {
|
|||||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func publicationBatchDualPublishesOnlyBeforeExplicitDeadline() throws {
|
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
|
||||||
.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: "mixed-version",
|
content: "mixed-version",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender,
|
senderIdentity: sender
|
||||||
now: beforeDeadline
|
|
||||||
)
|
)
|
||||||
#expect(migrationBatch.map(\.kind) == [
|
#expect(migrationBatch.map(\.kind) == [
|
||||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
@@ -187,29 +302,38 @@ struct NostrProtocolTests {
|
|||||||
#expect(result.content == "mixed-version")
|
#expect(result.content == "mixed-version")
|
||||||
}
|
}
|
||||||
|
|
||||||
let postMigrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
// The compatibility copy retains the exact released-iOS legacy shape,
|
||||||
content: "new-only",
|
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
// no inner tags. There is no wall-clock branch that can silently stop
|
||||||
senderIdentity: sender,
|
// old-client delivery.
|
||||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: migrationBatch[1],
|
||||||
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
#expect(postMigrationBatch.map(\.kind) == [
|
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
||||||
NostrProtocol.EventKind.privateEnvelope.rawValue
|
#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 sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
let earliestPublicTimestamp = sentAt.addingTimeInterval(
|
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
||||||
-TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds
|
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
||||||
)
|
)
|
||||||
let reconnectAtRetentionBoundary = sentAt.addingTimeInterval(24 * 60 * 60)
|
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
||||||
let filterSince = reconnectAtRetentionBoundary.addingTimeInterval(
|
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||||
|
)
|
||||||
|
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
||||||
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == (24 * 60 * 60) + (15 * 60))
|
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
||||||
#expect(filterSince <= earliestPublicTimestamp)
|
#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 {
|
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||||
@@ -497,22 +621,31 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func privateEnvelopeFilterIncludesPrimaryAndCompatibilityKinds() throws {
|
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
|
||||||
let since = Date(timeIntervalSince1970: 1_234_567)
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||||
let filter = NostrFilter.privateEnvelopesFor(pubkey: "recipient", since: since)
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
let data = try JSONEncoder().encode(filter)
|
pubkey: "recipient",
|
||||||
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
since: since
|
||||||
|
|
||||||
#expect(object["kinds"] as? [Int] == [
|
|
||||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
|
||||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
|
||||||
])
|
|
||||||
#expect(object["#p"] as? [String] == ["recipient"])
|
|
||||||
#expect(object["since"] as? Int == 1_234_567)
|
|
||||||
#expect(object["limit"] as? Int ==
|
|
||||||
TransportConfig.nostrRelayDefaultFetchLimit
|
|
||||||
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#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.nostrPrivateEnvelopeFetchLimitPerKind
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -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
|
#if SWIFT_PACKAGE
|
||||||
let bundle = Bundle.module
|
let bundle = Bundle.module
|
||||||
#else
|
#else
|
||||||
let bundle = Bundle(for: MockKeychain.self)
|
let bundle = Bundle(for: MockKeychain.self)
|
||||||
#endif
|
#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? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
|||||||
@@ -267,6 +267,312 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
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 reused—not re-admitted—and 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 {
|
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||||
let relayURL = "wss://tor-ready.example"
|
let relayURL = "wss://tor-ready.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
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)
|
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 {
|
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -1646,12 +1998,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
return filter
|
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 identity = try NostrIdentity.generate()
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: kind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
@@ -1791,6 +2146,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
private let pingError: Error?
|
private let pingError: Error?
|
||||||
var sendError: Error?
|
var sendError: Error?
|
||||||
|
var sendErrorSequence: [Error?] = []
|
||||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
private(set) var resumeCallCount = 0
|
private(set) var resumeCallCount = 0
|
||||||
private(set) var cancelCallCount = 0
|
private(set) var cancelCallCount = 0
|
||||||
@@ -1820,14 +2176,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var deferSendCompletions = false
|
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) {
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
sentMessages.append(message)
|
sentMessages.append(message)
|
||||||
|
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
||||||
if deferSendCompletions {
|
if deferSendCompletions {
|
||||||
deferredSendCompletions.append(completionHandler)
|
deferredSendCompletions.append((error, completionHandler))
|
||||||
} else {
|
} else {
|
||||||
completionHandler(sendError)
|
completionHandler(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1835,7 +2192,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
let pending = deferredSendCompletions
|
let pending = deferredSendCompletions
|
||||||
deferredSendCompletions = []
|
deferredSendCompletions = []
|
||||||
pending.forEach {
|
pending.forEach {
|
||||||
$0(sendError)
|
$0.completion($0.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
|||||||
#expect(undeliverable)
|
#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
|
@MainActor
|
||||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
@@ -154,7 +154,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,12 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
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(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 result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
|
|
||||||
@@ -176,9 +180,9 @@ struct NostrTransportTests {
|
|||||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migration window publishes primary and compatibility envelopes, then stops")
|
@Test("Coordinated migration always publishes primary and compatibility envelopes")
|
||||||
@MainActor
|
@MainActor
|
||||||
func migrationWindowDualPublishesUntilDeadline() async throws {
|
func migrationAlwaysDualPublishes() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -198,11 +202,7 @@ struct NostrTransportTests {
|
|||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
sendEvent: migrationProbe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
|
||||||
now: {
|
|
||||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
|
||||||
.addingTimeInterval(-1)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
@@ -228,32 +228,281 @@ struct NostrTransportTests {
|
|||||||
#expect(message.messageID == "migration-pm")
|
#expect(message.messageID == "migration-pm")
|
||||||
#expect(message.content == "migration payload")
|
#expect(message.content == "migration payload")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let postMigrationProbe = NostrTransportProbe()
|
@Test("Rejected migration batch does not register half-delivery state")
|
||||||
let postMigrationTransport = NostrTransport(
|
@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 },
|
||||||
|
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")
|
||||||
|
releasedTransport = transport
|
||||||
|
|
||||||
|
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||||
|
let retryScheduled = await TestHelpers.waitUntil(
|
||||||
|
{ attempts.count == 1 && probe.scheduledActionCount == 1 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#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,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
sendEvent: postMigrationProbe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
now: { NostrProtocol.legacyPrivateEnvelopePublicationDeadline }
|
attempts.append(events.map(\.id))
|
||||||
|
return attempts.count > 1
|
||||||
|
},
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
postMigrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
postMigrationTransport.sendPrivateMessage(
|
|
||||||
"post migration",
|
|
||||||
to: peerID,
|
|
||||||
recipientNickname: "Migration peer",
|
|
||||||
messageID: "post-migration-pm"
|
|
||||||
)
|
|
||||||
|
|
||||||
let sentPrimaryOnly = await TestHelpers.waitUntil(
|
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
|
||||||
{ postMigrationProbe.sentEvents.count == 1 },
|
let firstAttempt = await TestHelpers.waitUntil(
|
||||||
|
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
|
||||||
timeout: 5.0
|
timeout: 5.0
|
||||||
)
|
)
|
||||||
#expect(sentPrimaryOnly)
|
#expect(firstAttempt)
|
||||||
#expect(postMigrationProbe.sentEvents.first?.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
|
||||||
|
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")
|
@Test("Favorite notification embeds current npub")
|
||||||
@@ -279,7 +528,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -289,7 +538,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
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)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
@@ -320,7 +569,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -330,7 +579,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
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)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
|
|
||||||
@@ -353,7 +602,7 @@ struct NostrTransportTests {
|
|||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -368,7 +617,7 @@ struct NostrTransportTests {
|
|||||||
messageID: "geo-1"
|
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)
|
#expect(didSend)
|
||||||
let event = probe.sentEvents[0]
|
let event = probe.sentEvents[0]
|
||||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||||
@@ -377,7 +626,7 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "geo-1")
|
#expect(privateMessage.messageID == "geo-1")
|
||||||
#expect(privateMessage.content == "geo hello")
|
#expect(privateMessage.content == "geo hello")
|
||||||
#expect(result.packet.recipientID == nil)
|
#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")
|
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||||
@@ -403,7 +652,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -418,20 +667,20 @@ struct NostrTransportTests {
|
|||||||
transport.sendReadReceipt(second, to: fullPeerID)
|
transport.sendReadReceipt(second, to: fullPeerID)
|
||||||
|
|
||||||
let readReceiptTimeout: TimeInterval = 5.0
|
let readReceiptTimeout: TimeInterval = 5.0
|
||||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentFirst, "Expected first queued read receipt event")
|
try #require(sentFirst, "Expected first queued read receipt pair")
|
||||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
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
|
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||||
#expect(firstPayload.type == .readReceipt)
|
#expect(firstPayload.type == .readReceipt)
|
||||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||||
|
|
||||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||||
|
|
||||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
||||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
let secondEvent = probe.sentEvents[2]
|
||||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||||
#expect(secondPayload.type == .readReceipt)
|
#expect(secondPayload.type == .readReceipt)
|
||||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||||
@@ -500,12 +749,13 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
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 },
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||||
now: @escaping @MainActor () -> Date = {
|
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(1)
|
|
||||||
}
|
|
||||||
) -> NostrTransport.Dependencies {
|
) -> NostrTransport.Dependencies {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -514,10 +764,10 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||||
sendEvent: sendEvent,
|
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||||
scheduleAfter: scheduleAfter,
|
scheduleAfter: scheduleAfter,
|
||||||
relayConnectivity: relayConnectivity,
|
relayConnectivity: relayConnectivity,
|
||||||
now: now
|
envelopeRetryQueue: envelopeRetryQueue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +822,17 @@ private enum NostrTransportTestError: Error {
|
|||||||
case invalidPrivateMessage
|
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? {
|
private func base64URLDecode(_ string: String) -> Data? {
|
||||||
var candidate = string
|
var candidate = string
|
||||||
let padding = (4 - (candidate.count % 4)) % 4
|
let padding = (4 - (candidate.count % 4)) % 4
|
||||||
@@ -608,10 +869,11 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return scheduledActionsStorage.count
|
return scheduledActionsStorage.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func record(event: NostrEvent) {
|
func record(batch: [NostrEvent]) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
sentEventsStorage.append(event)
|
sentEventsStorage.append(contentsOf: batch)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordPendingPrivateEnvelope(id: String) {
|
func recordPendingPrivateEnvelope(id: String) {
|
||||||
|
|||||||
Reference in New Issue
Block a user