mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Harden Nostr envelope migration compatibility
This commit is contained in:
@@ -349,8 +349,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(-1)
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
for envelope in envelopes {
|
||||
@@ -385,9 +384,6 @@ struct ChatNostrCoordinatorContextTests {
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
|
||||
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: "migration message",
|
||||
messageID: "migration-message-id",
|
||||
@@ -408,8 +404,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: beforeDeadline
|
||||
senderIdentity: sender
|
||||
)
|
||||
#expect(envelopes.count == 2)
|
||||
for envelope in envelopes {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
index a5bd956..544a29b 100644
|
||||
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
@@ -10,0 +11,21 @@ class NostrProtocolTest {
|
||||
+ @Test
|
||||
+ fun emitCrossPlatformFixtures() {
|
||||
+ val sender = NostrIdentity.fromPrivateKey(
|
||||
+ "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
+ )
|
||||
+ val recipient = NostrIdentity.fromPrivateKey(
|
||||
+ "0000000000000000000000000000000000000000000000000000000000000002"
|
||||
+ )
|
||||
+ val giftWrap = NostrProtocol.createPrivateMessage(
|
||||
+ content = "legacy fixture from Android b7f0b33d",
|
||||
+ recipientPubkey = recipient.publicKeyHex,
|
||||
+ senderIdentity = sender
|
||||
+ ).single()
|
||||
+
|
||||
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
|
||||
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
|
||||
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
|
||||
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
|
||||
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
|
||||
+ }
|
||||
+
|
||||
@Test
|
||||
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
|
||||
val sender = NostrIdentity.generate()
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
|
||||
"generator": "NostrProtocol.createPrivateMessage",
|
||||
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
|
||||
"generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a",
|
||||
"gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info",
|
||||
"fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e",
|
||||
"recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
}
|
||||
@@ -140,6 +140,124 @@ struct NostrProtocolTests {
|
||||
)
|
||||
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||
#expect(layers.message.tags.isEmpty)
|
||||
}
|
||||
|
||||
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Current Android's `createPrivateMessage` emits an unsigned kind-14
|
||||
// inner event with exactly [["p", recipient]], while its outer/seal
|
||||
// layers use the deployed BitChat legacy v2 crypto. This isolated
|
||||
// generator reproduces that cross-platform wire shape without making
|
||||
// the production encoder depend on Android's historical tag choice.
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "legacy message from Android",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||
)
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy message from Android")
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
|
||||
let eventData = try Data(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
|
||||
))
|
||||
let metadataData = try Data(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
|
||||
))
|
||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
|
||||
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
|
||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||
|
||||
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
|
||||
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
|
||||
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
|
||||
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
||||
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
||||
let generatorPatch = try String(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
|
||||
extension: "patch"
|
||||
))
|
||||
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
|
||||
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
|
||||
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
|
||||
#expect(envelope.isValidSignature())
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
||||
#expect(result.senderPubkey == metadata.senderPublicKey)
|
||||
}
|
||||
|
||||
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let otherRecipient = try NostrIdentity.generate()
|
||||
let invalidTagShapes = [
|
||||
[["p", otherRecipient.publicKeyHex]],
|
||||
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
|
||||
[["p", recipient.publicKeyHex, "unexpected"]],
|
||||
[["x", recipient.publicKeyHex]]
|
||||
]
|
||||
|
||||
for tags in invalidTagShapes {
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "invalid Android-shaped tags",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: tags
|
||||
)
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
|
||||
content: "new format stays strict",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
||||
@@ -163,17 +281,14 @@ struct NostrProtocolTests {
|
||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||
}
|
||||
|
||||
@Test func publicationBatchDualPublishesOnlyBeforeExplicitDeadline() throws {
|
||||
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
|
||||
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "mixed-version",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: beforeDeadline
|
||||
senderIdentity: sender
|
||||
)
|
||||
#expect(migrationBatch.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
@@ -187,29 +302,38 @@ struct NostrProtocolTests {
|
||||
#expect(result.content == "mixed-version")
|
||||
}
|
||||
|
||||
let postMigrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "new-only",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
// The compatibility copy retains the exact released-iOS legacy shape,
|
||||
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
||||
// no inner tags. There is no wall-clock branch that can silently stop
|
||||
// old-client delivery.
|
||||
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: migrationBatch[1],
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(postMigrationBatch.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue
|
||||
])
|
||||
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
||||
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||
#expect(compatibilityLayers.message.tags.isEmpty)
|
||||
}
|
||||
|
||||
@Test func mailboxLookbackCoversFullRetentionWindowAndTimestampFuzz() {
|
||||
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
|
||||
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let earliestPublicTimestamp = sentAt.addingTimeInterval(
|
||||
-TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds
|
||||
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
||||
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
||||
)
|
||||
let reconnectAtRetentionBoundary = sentAt.addingTimeInterval(24 * 60 * 60)
|
||||
let filterSince = reconnectAtRetentionBoundary.addingTimeInterval(
|
||||
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
||||
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||
)
|
||||
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
||||
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||
)
|
||||
|
||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == (24 * 60 * 60) + (15 * 60))
|
||||
#expect(filterSince <= earliestPublicTimestamp)
|
||||
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
||||
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
|
||||
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
|
||||
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
|
||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
|
||||
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
|
||||
}
|
||||
|
||||
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||
@@ -542,22 +666,31 @@ struct NostrProtocolTests {
|
||||
#expect(event.tags.count == 2)
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeFilterIncludesPrimaryAndCompatibilityKinds() throws {
|
||||
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
|
||||
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||
let filter = NostrFilter.privateEnvelopesFor(pubkey: "recipient", since: since)
|
||||
let data = try JSONEncoder().encode(filter)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
#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
|
||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: "recipient",
|
||||
since: since
|
||||
)
|
||||
|
||||
#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
|
||||
@@ -581,13 +714,35 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func fixtureURL(name: String) throws -> URL {
|
||||
private struct AndroidLegacyEnvelopeFixture: Decodable {
|
||||
let androidCommit: String
|
||||
let generator: String
|
||||
let generatorPatch: String
|
||||
let generatorPatchSHA256: String
|
||||
let gradleTest: String
|
||||
let fixtureSHA256: String
|
||||
let recipientPrivateKey: String
|
||||
let senderPublicKey: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case androidCommit = "android_commit"
|
||||
case generator
|
||||
case generatorPatch = "generator_patch"
|
||||
case generatorPatchSHA256 = "generator_patch_sha256"
|
||||
case gradleTest = "gradle_test"
|
||||
case fixtureSHA256 = "fixture_sha256"
|
||||
case recipientPrivateKey = "recipient_private_key"
|
||||
case senderPublicKey = "sender_public_key"
|
||||
}
|
||||
}
|
||||
|
||||
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
|
||||
#if SWIFT_PACKAGE
|
||||
let bundle = Bundle.module
|
||||
#else
|
||||
let bundle = Bundle(for: MockKeychain.self)
|
||||
#endif
|
||||
return try #require(bundle.url(forResource: name, withExtension: "json"))
|
||||
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
|
||||
@@ -267,6 +267,312 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
|
||||
let relayURL = "wss://private-batch-priority.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: false
|
||||
)
|
||||
var ephemeralIDs: [String] = []
|
||||
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
|
||||
let event = try makeSignedEvent(
|
||||
content: "ephemeral-\(i)",
|
||||
kind: .ephemeralEvent
|
||||
)
|
||||
ephemeralIDs.append(event.id)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
}
|
||||
let regular = try makeSignedEvent(content: "regular survives")
|
||||
context.manager.sendEvent(regular, to: [relayURL])
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
|
||||
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||
let allIDs = Set(batches.flatMap { $0 })
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
|
||||
XCTAssertTrue(allIDs.contains(regular.id))
|
||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
|
||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
|
||||
|
||||
// Later low-priority traffic may evict another ephemeral event, but
|
||||
// never one half (or both halves) of the protected private batch.
|
||||
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
|
||||
context.manager.sendEvent(lateEphemeral, to: [relayURL])
|
||||
XCTAssertTrue(
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||
.contains([primary.id, legacy.id])
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
|
||||
let relayURL = "wss://private-batch-tor-gate.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true
|
||||
)
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
|
||||
context.torWaiter.isReady = false
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertTrue(connection.sentStrings.isEmpty)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
let flushed = await waitUntil {
|
||||
connection.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchPrunesTerminalRelayAfterHealthyDelivery() async throws {
|
||||
let healthyURL = "wss://private-batch-healthy.example"
|
||||
let deadURL = "wss://private-batch-dead.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost
|
||||
)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
var terminalFailureCount = 0
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [healthyURL, deadURL],
|
||||
terminalFailure: { terminalFailureCount += 1 }
|
||||
))
|
||||
|
||||
let drained = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: healthyURL)?.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(drained)
|
||||
XCTAssertEqual(terminalFailureCount, 0)
|
||||
|
||||
// The terminal target is excluded during cooldown. More than one full
|
||||
// protected-capacity worth of subsequent logical sends must continue
|
||||
// to drain through the healthy relay instead of wedging globally.
|
||||
for _ in 0...100 {
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [healthyURL, deadURL]
|
||||
))
|
||||
let sent = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
}
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchAllTerminalTargetsReportsWholeBatchFailure() async throws {
|
||||
let deadURL = "wss://private-batch-all-dead.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost
|
||||
)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
var terminalFailureCount = 0
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [deadURL],
|
||||
terminalFailure: { terminalFailureCount += 1 }
|
||||
))
|
||||
|
||||
let failed = await waitUntil {
|
||||
terminalFailureCount == 1 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(failed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchRejectsBeforeWritingWhenProtectedCapacityIsFull() async throws {
|
||||
let stalledRelayURL = "wss://private-batch-full.example"
|
||||
let connectedRelayURL = "wss://private-batch-connected.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: true
|
||||
)
|
||||
context.manager.ensureConnections(to: [connectedRelayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == connectedRelayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
context.torForeground.value = false
|
||||
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
let pair = [primary, legacy]
|
||||
|
||||
for _ in 0..<(TransportConfig.nostrPendingSendQueueCap / pair.count) {
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(pair, to: [stalledRelayURL]))
|
||||
}
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
|
||||
let rejectedPrimary = try makeSignedEvent(content: "rejected-primary", kind: .privateEnvelope)
|
||||
let rejectedLegacy = try makeSignedEvent(content: "rejected-legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertFalse(
|
||||
context.manager.sendPrivateEnvelopeBatch(
|
||||
[rejectedPrimary, rejectedLegacy],
|
||||
to: [connectedRelayURL]
|
||||
)
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertTrue(
|
||||
context.sessionFactory.latestConnection(for: connectedRelayURL)?.sentStrings.isEmpty == true
|
||||
)
|
||||
let queuedIDs = Set(
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch.flatMap { $0 }
|
||||
)
|
||||
XCTAssertFalse(queuedIDs.contains(rejectedPrimary.id))
|
||||
XCTAssertFalse(queuedIDs.contains(rejectedLegacy.id))
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterFirstWriteFails() async throws {
|
||||
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [NSError(domain: "send", code: 1)],
|
||||
expectedFirstConnectionWrites: 1
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterSecondWriteFails() async throws {
|
||||
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [nil, NSError(domain: "send", code: 2)],
|
||||
expectedFirstConnectionWrites: 2
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchRemainsPendingUntilBothWritesSucceed() async throws {
|
||||
let relayURL = "wss://private-batch-two-write-commit.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
connection.deferSendCompletions = true
|
||||
|
||||
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
|
||||
XCTAssertTrue(firstWriteStarted)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
connection.flushDeferredSendCompletions()
|
||||
let secondWriteStarted = await waitUntil { connection.sentStrings.count == 2 }
|
||||
XCTAssertTrue(secondWriteStarted)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
connection.flushDeferredSendCompletions()
|
||||
let committed = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(committed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchDisconnectDuringWriteReplaysOnReplacementConnection() async throws {
|
||||
let relayURL = "wss://private-batch-background-reconnect.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let firstConnection = try XCTUnwrap(
|
||||
context.sessionFactory.latestConnection(for: relayURL)
|
||||
)
|
||||
firstConnection.deferSendCompletions = true
|
||||
let firstWriteStarted = await waitUntil { firstConnection.sentStrings.count == 1 }
|
||||
XCTAssertTrue(firstWriteStarted)
|
||||
|
||||
// Backgrounding cancels the socket while its first write callback is
|
||||
// still outstanding. The pair must remain replayable in the queue.
|
||||
context.manager.disconnect()
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
context.manager.connect()
|
||||
|
||||
let replayed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
guard connections.count == 2, let replacement = connections.last else {
|
||||
return false
|
||||
}
|
||||
return replacement.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(replayed)
|
||||
|
||||
// A late success callback from the canceled socket must neither send
|
||||
// the legacy half on that stale socket nor disturb the completed pair.
|
||||
firstConnection.flushDeferredSendCompletions()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(firstConnection.sentStrings.count, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
}
|
||||
|
||||
private func assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [Error?],
|
||||
expectedFirstConnectionWrites: Int
|
||||
) async throws {
|
||||
let relayURL = "wss://private-batch-write-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
firstConnection.sendErrorSequence = failureSequence
|
||||
|
||||
let stayedQueued = await waitUntil {
|
||||
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
|
||||
}
|
||||
XCTAssertTrue(stayedQueued)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
// The failed socket is disconnected with bounded backoff. Its queue
|
||||
// item is 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 {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -548,6 +854,52 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
}
|
||||
|
||||
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
|
||||
let relayURL = "wss://private-recovery-filters.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: "recipient",
|
||||
since: Date(timeIntervalSince1970: 1_234_567)
|
||||
)
|
||||
|
||||
context.manager.subscribe(
|
||||
filters: filters,
|
||||
id: "private-recovery",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in }
|
||||
)
|
||||
|
||||
let sent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
let request = try XCTUnwrap(
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
|
||||
)
|
||||
let data = try XCTUnwrap(request.data(using: .utf8))
|
||||
let array = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
)
|
||||
XCTAssertEqual(array.count, 4)
|
||||
XCTAssertEqual(array[0] as? String, "REQ")
|
||||
XCTAssertEqual(array[1] as? String, "private-recovery")
|
||||
let encodedFilters = try [array[2], array[3]].map {
|
||||
try XCTUnwrap($0 as? [String: Any])
|
||||
}
|
||||
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
|
||||
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
||||
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
||||
])
|
||||
for filter in encodedFilters {
|
||||
XCTAssertEqual(
|
||||
filter["limit"] as? Int,
|
||||
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||
)
|
||||
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
|
||||
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
|
||||
}
|
||||
}
|
||||
|
||||
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -1646,12 +1998,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
return filter
|
||||
}
|
||||
|
||||
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||
private func makeSignedEvent(
|
||||
content: String,
|
||||
kind: NostrProtocol.EventKind = .textNote
|
||||
) throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
kind: kind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
@@ -1791,6 +2146,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
private let pingError: Error?
|
||||
var sendError: Error?
|
||||
var sendErrorSequence: [Error?] = []
|
||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private(set) var resumeCallCount = 0
|
||||
private(set) var cancelCallCount = 0
|
||||
@@ -1820,14 +2176,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
var deferSendCompletions = false
|
||||
private var deferredSendCompletions: [(Error?) -> Void] = []
|
||||
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
sentMessages.append(message)
|
||||
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
||||
if deferSendCompletions {
|
||||
deferredSendCompletions.append(completionHandler)
|
||||
deferredSendCompletions.append((error, completionHandler))
|
||||
} else {
|
||||
completionHandler(sendError)
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,7 +2192,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
let pending = deferredSendCompletions
|
||||
deferredSendCompletions = []
|
||||
pending.forEach {
|
||||
$0(sendError)
|
||||
$0.completion($0.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
||||
#expect(undeliverable)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@Test("Private message resolves short peer ID and emits both migration formats")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
let keychain = MockKeychain()
|
||||
@@ -154,7 +154,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -164,8 +164,12 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
#expect(probe.sentEvents.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
@@ -176,9 +180,9 @@ struct NostrTransportTests {
|
||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Migration window publishes primary and compatibility envelopes, then stops")
|
||||
@Test("Coordinated migration always publishes primary and compatibility envelopes")
|
||||
@MainActor
|
||||
func migrationWindowDualPublishesUntilDeadline() async throws {
|
||||
func migrationAlwaysDualPublishes() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -198,11 +202,7 @@ struct NostrTransportTests {
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendEvent: migrationProbe.record(event:),
|
||||
now: {
|
||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||
.addingTimeInterval(-1)
|
||||
}
|
||||
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
|
||||
)
|
||||
)
|
||||
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
@@ -228,32 +228,281 @@ struct NostrTransportTests {
|
||||
#expect(message.messageID == "migration-pm")
|
||||
#expect(message.content == "migration payload")
|
||||
}
|
||||
}
|
||||
|
||||
let postMigrationProbe = NostrTransportProbe()
|
||||
let postMigrationTransport = NostrTransport(
|
||||
@Test("Rejected migration batch does not register half-delivery state")
|
||||
@MainActor
|
||||
func rejectedMigrationBatchRegistersNothing() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let probe = NostrTransportProbe()
|
||||
var rejectedKinds: [Int] = []
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in
|
||||
rejectedKinds = events.map(\.kind)
|
||||
return false
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: "must stay atomic",
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "atomic-reject"
|
||||
)
|
||||
|
||||
let attempted = await TestHelpers.waitUntil({ rejectedKinds.count == 2 }, timeout: 5.0)
|
||||
#expect(attempted)
|
||||
#expect(rejectedKinds == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Rejected user message emits a visible failed-delivery event")
|
||||
@MainActor
|
||||
func rejectedUserMessageEmitsFailureEvent() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let eventProbe = NostrTransportEventProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
sendPrivateEnvelopeBatch: { _, _ in false }
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
transport.eventDelegate = eventProbe
|
||||
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: "must fail visibly",
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "visible-reject"
|
||||
)
|
||||
|
||||
let reported = await TestHelpers.waitUntil(
|
||||
{ eventProbe.failedMessageIDs == ["visible-reject"] },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(reported)
|
||||
}
|
||||
|
||||
@Test("Rejected favorite notification retains and retries the exact pair")
|
||||
@MainActor
|
||||
func rejectedFavoriteNotificationRetriesExactPair() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((96..<128).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Retry favorite"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var attempts: [[String]] = []
|
||||
weak var releasedTransport: NostrTransport?
|
||||
do {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
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,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendEvent: postMigrationProbe.record(event:),
|
||||
now: { NostrProtocol.legacyPrivateEnvelopePublicationDeadline }
|
||||
sendPrivateEnvelopeBatch: { events, _ in
|
||||
attempts.append(events.map(\.id))
|
||||
return attempts.count > 1
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
postMigrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
postMigrationTransport.sendPrivateMessage(
|
||||
"post migration",
|
||||
to: peerID,
|
||||
recipientNickname: "Migration peer",
|
||||
messageID: "post-migration-pm"
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
let sentPrimaryOnly = await TestHelpers.waitUntil(
|
||||
{ postMigrationProbe.sentEvents.count == 1 },
|
||||
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
|
||||
let firstAttempt = await TestHelpers.waitUntil(
|
||||
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(sentPrimaryOnly)
|
||||
#expect(postMigrationProbe.sentEvents.first?.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||
#expect(firstAttempt)
|
||||
|
||||
for _ in 0..<3 where attempts.count < 2 {
|
||||
_ = probe.runNextScheduledAction()
|
||||
_ = await TestHelpers.waitUntil(
|
||||
{ attempts.count == 2 || probe.scheduledActionCount > 0 },
|
||||
timeout: 1.0
|
||||
)
|
||||
}
|
||||
#expect(attempts.count == 2)
|
||||
#expect(attempts[0] == attempts[1])
|
||||
}
|
||||
|
||||
@Test("Control retry queue is bounded and evicted callbacks are harmless")
|
||||
@MainActor
|
||||
func controlRetryQueueIsBounded() async throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "bounded retry fixture",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var retryAttempts = 0
|
||||
let queue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { _, _ in
|
||||
retryAttempts += 1
|
||||
return false
|
||||
},
|
||||
registerPendingPrivateEnvelope: { _ in },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
|
||||
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||
queue.enqueue(
|
||||
key: "control-\(index)",
|
||||
events: events,
|
||||
registerPending: false
|
||||
)
|
||||
}
|
||||
|
||||
#expect(queue.debugPendingCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
#expect(!queue.debugContains(key: "control-0"))
|
||||
#expect(queue.debugContains(key: "control-1"))
|
||||
|
||||
// The oldest callback was scheduled before eviction. Running it must
|
||||
// observe the missing key and return without touching dependencies.
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
|
||||
queue.removeAll()
|
||||
#expect(queue.debugPendingCount == 0)
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
}
|
||||
|
||||
@Test("Multiple transports share one globally bounded control retry owner")
|
||||
@MainActor
|
||||
func multipleTransportsShareControlRetryQueue() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "shared retry fixture",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var retryAttempts = 0
|
||||
let sharedQueue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { _, _ in
|
||||
retryAttempts += 1
|
||||
return false
|
||||
},
|
||||
registerPendingPrivateEnvelope: { _ in },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
let first = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||
)
|
||||
let second = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||
)
|
||||
|
||||
first.debugEnqueueControlRetry(key: "shared", events: events)
|
||||
second.debugEnqueueControlRetry(key: "shared", events: events)
|
||||
#expect(first.debugControlRetryCount == 1)
|
||||
#expect(second.debugControlRetryCount == 1)
|
||||
|
||||
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||
let transport = index.isMultiple(of: 2) ? first : second
|
||||
transport.debugEnqueueControlRetry(key: "global-\(index)", events: events)
|
||||
}
|
||||
#expect(first.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
#expect(second.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
|
||||
// The first scheduled callback belongs to the now-evicted shared key.
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
}
|
||||
|
||||
@Test("Favorite notification embeds current npub")
|
||||
@@ -279,7 +528,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -289,7 +538,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -320,7 +569,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -330,7 +579,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
@@ -353,7 +602,7 @@ struct NostrTransportTests {
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -368,7 +617,7 @@ struct NostrTransportTests {
|
||||
messageID: "geo-1"
|
||||
)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
@@ -377,7 +626,7 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "geo-1")
|
||||
#expect(privateMessage.content == "geo hello")
|
||||
#expect(result.packet.recipientID == nil)
|
||||
#expect(probe.pendingPrivateEnvelopeIDs == [event.id])
|
||||
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
|
||||
}
|
||||
|
||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||
@@ -403,7 +652,7 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -418,20 +667,20 @@ struct NostrTransportTests {
|
||||
transport.sendReadReceipt(second, to: fullPeerID)
|
||||
|
||||
let readReceiptTimeout: TimeInterval = 5.0
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt event")
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt pair")
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
|
||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||
#expect(firstPayload.type == .readReceipt)
|
||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||
|
||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
||||
let secondEvent = probe.sentEvents[2]
|
||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||
#expect(secondPayload.type == .readReceipt)
|
||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||
@@ -500,12 +749,13 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool = { _, _ in true },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||
now: @escaping @MainActor () -> Date = {
|
||||
NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(1)
|
||||
}
|
||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -514,10 +764,10 @@ struct NostrTransportTests {
|
||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
sendEvent: sendEvent,
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity,
|
||||
now: now
|
||||
envelopeRetryQueue: envelopeRetryQueue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -572,6 +822,17 @@ private enum NostrTransportTestError: Error {
|
||||
case invalidPrivateMessage
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class NostrTransportEventProbe: TransportEventDelegate {
|
||||
private(set) var failedMessageIDs: [String] = []
|
||||
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
|
||||
case .failed = status else { return }
|
||||
failedMessageIDs.append(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func base64URLDecode(_ string: String) -> Data? {
|
||||
var candidate = string
|
||||
let padding = (4 - (candidate.count % 4)) % 4
|
||||
@@ -608,10 +869,11 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return scheduledActionsStorage.count
|
||||
}
|
||||
|
||||
func record(event: NostrEvent) {
|
||||
func record(batch: [NostrEvent]) -> Bool {
|
||||
lock.lock()
|
||||
sentEventsStorage.append(event)
|
||||
sentEventsStorage.append(contentsOf: batch)
|
||||
lock.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func recordPendingPrivateEnvelope(id: String) {
|
||||
|
||||
Reference in New Issue
Block a user